diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d751f9a..5b63f49 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { APP_GUARD } from '@nestjs/core'; import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard'; import { OriginGuard } from '@/common/guards/origin.guard'; import { AuthModule } from '@/modules/auth/auth.module'; +import { ContactsModule } from '@/modules/contacts/contacts.module'; import { HealthModule } from '@/modules/health/health.module'; import { MailModule } from '@/modules/mail/mail.module'; import { UploadModule } from '@/modules/upload/upload.module'; @@ -65,6 +66,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 AuthModule, // 웹메일 API (Stalwart JMAP 어댑터) MailModule, + // 주소록 API — 내 주소록 · 사내 목록 (8단계, 메일 모듈도 받는 사람 추천에 쓴다) + ContactsModule, UploadModule, UserModule, ], diff --git a/backend/src/common/decorators/trim-string.decorator.spec.ts b/backend/src/common/decorators/trim-string.decorator.spec.ts index e6147e4..2262093 100644 --- a/backend/src/common/decorators/trim-string.decorator.spec.ts +++ b/backend/src/common/decorators/trim-string.decorator.spec.ts @@ -1,13 +1,19 @@ import { plainToInstance } from 'class-transformer'; -import { TrimString } from './trim-string.decorator'; +import { TrimString, TrimStringOrNull } from './trim-string.decorator'; class Named { @TrimString() name!: unknown; } +class Optional { + @TrimStringOrNull() + name!: unknown; +} + const nameOf = (name: unknown) => plainToInstance(Named, { name }).name; +const optionalOf = (name: unknown) => plainToInstance(Optional, { name }).name; describe('TrimString — DTO 변환에서 앞뒤 공백을 지운다', () => { it('문자열은 JS trim() 과 같게 — 탭 · 줄바꿈 · NBSP · U+3000 도 지운다, 가운데는 그대로', () => { @@ -22,3 +28,19 @@ describe('TrimString — DTO 변환에서 앞뒤 공백을 지운다', () => { expect(nameOf([' a '])).toEqual([' a ']); }); }); + +describe('TrimStringOrNull — 앞뒤 공백을 지우고 빈 글은 null', () => { + it('지운 뒤 남은 글이 없으면 null, 있으면 지운 글', () => { + expect(optionalOf(' 한빛상사 ')).toBe('한빛상사'); + expect(optionalOf('\n첫 줄\n둘째 줄\n')).toBe('첫 줄\n둘째 줄'); + for (const blank of ['', ' ', '\t\n\u00a0\u3000']) { + expect(optionalOf(blank)).toBeNull(); + } + }); + + it('문자열이 아니면 그대로 둔다 — null 은 null, 키가 없으면 undefined(형식 검사가 막는다)', () => { + expect(optionalOf(null)).toBeNull(); + expect(optionalOf(7)).toBe(7); + expect(plainToInstance(Optional, {}).name).toBeUndefined(); + }); +}); diff --git a/backend/src/common/decorators/trim-string.decorator.ts b/backend/src/common/decorators/trim-string.decorator.ts index 62f0057..60ee40f 100644 --- a/backend/src/common/decorators/trim-string.decorator.ts +++ b/backend/src/common/decorators/trim-string.decorator.ts @@ -3,7 +3,7 @@ import { Transform, type TransformFnParams } from 'class-transformer'; /** * 앞뒤 공백을 지운다 — DTO 변환(class-transformer) 단계라 검사(class-validator)는 지운 값으로 한다. * 문자열이 아니면 그대로 두어 IsString · IsEmail 같은 검사가 막는다. 지우는 공백은 JS `trim()` 과 같다(NBSP · U+3000 포함 — 웹의 `.trim()` 과 같게 센다). - * 메일함 이름(dto/mailbox.dto.ts) · 메일 필터의 이름 · 찾을 글 · 전달 주소(dto/mail-filter.dto.ts)가 쓴다 + * 메일함 이름(dto/mailbox.dto.ts) · 메일 필터의 이름 · 찾을 글 · 전달 주소(dto/mail-filter.dto.ts) · 주소록의 주소 · 번호 · 그룹 이름이 쓴다 */ export function TrimString(): PropertyDecorator { return Transform(trimStringValue); @@ -14,3 +14,19 @@ export function trimStringValue({ value }: TransformFnParams): unknown { const raw: unknown = value; return typeof raw === 'string' ? raw.trim() : raw; } + +/** + * 앞뒤 공백을 지우고, 남은 글이 없으면 null 로 바꾼다 — "값 없음"을 null 로 받는 칸(연락처의 이름 · 회사 · 부서 · 직책 · 메모 — + * 계약 3절 "빈 글은 null")이 쓴다. 검사는 바꾼 값으로 한다 (null 이면 ValidateIf 로 건너뛰게 둔다). 문자열이 아니면 그대로 둔다 + */ +export function TrimStringOrNull(): PropertyDecorator { + return Transform(trimStringOrNullValue); +} + +/** TrimStringOrNull 의 변환 */ +export function trimStringOrNullValue({ value }: TransformFnParams): unknown { + const raw: unknown = value; + if (typeof raw !== 'string') return raw; + const trimmed = raw.trim(); + return trimmed === '' ? null : trimmed; +} diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index 6d90089..e8d95b3 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -64,6 +64,11 @@ describe('에러 코드 레지스트리', () => { expect(ERROR_CODES.MAIL_013.status).toBe(HttpStatus.BAD_REQUEST); }); + it('주소록 — 같은 이름의 그룹(CONTACT_001)과 주소록 한도(CONTACT_002)는 409 다', () => { + expect(ERROR_CODES.CONTACT_001.status).toBe(HttpStatus.CONFLICT); + expect(ERROR_CODES.CONTACT_002.status).toBe(HttpStatus.CONFLICT); + }); + it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => { const clientOnlyCodes = Object.entries(ERROR_CODES) .filter(([, definition]) => 'clientOnly' in definition) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 62df90a..265b83a 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -161,6 +161,19 @@ const DOMAIN_ERROR_CODES = { message: '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', }, + // 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다). + // 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절) + CONTACT_001: { + status: HttpStatus.CONFLICT, + message: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', + }, + // 주소록 한도(연락처 3,000개 · 그룹 100개 · 그룹 하나에 구성원 500명)를 넘는다 — 아무것도 바꾸지 않았다 (계약 3절). + // 문구의 숫자는 modules/contacts/contacts.constants.ts 의 한도와 같아야 한다 (contacts.constants.spec.ts 가 확인한다) + CONTACT_002: { + status: HttpStatus.CONFLICT, + message: + '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index 4022f55..cf01df6 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -33,7 +33,11 @@ const mailSettings: MailSettings = { gatewayPassword: 'gateway-secret', attachmentMaxBytes: 20 * 1024 * 1024, attachmentTotalMaxBytes: 25 * 1024 * 1024, - blockedAccounts: new Set(['admin@arasystem.test']), + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', }; const settings: AuthSettings = { diff --git a/backend/src/modules/auth/employee-address.ts b/backend/src/modules/auth/employee-address.ts index 9d06b4f..34c0fe9 100644 --- a/backend/src/modules/auth/employee-address.ts +++ b/backend/src/modules/auth/employee-address.ts @@ -29,7 +29,9 @@ export function normalizeEmail(email: string): string { * 로그인할 수 있는 직원 주소인지 확인한다. * - 사내 도메인(MAIL_DOMAIN) 주소만 * - 게이트웨이 서비스 계정은 직접 로그인할 수 없다 - * - 막는 계정(메일 관리자 `@<도메인>`·MAIL_BLOCKED_ACCOUNTS)도 직원 주소가 아니다 (mail.settings.ts 의 blockedAccounts) + * - 막는 계정(메일 관리자 `@<도메인>`·사내 목록 계정 `@<도메인>`·MAIL_BLOCKED_ACCOUNTS)도 + * 직원 주소가 아니다 (mail.settings.ts 의 blockedAccounts) + * 사내 목록(계약 3절)에서 빼는 계정도 이 함수로 고른다 (contacts/directory-policy.ts) — 로그인을 막는 목록과 같게 */ export function isEmployeeAddress( email: string, diff --git a/backend/src/modules/contacts/address-book.spec.ts b/backend/src/modules/contacts/address-book.spec.ts new file mode 100644 index 0000000..383ee8a --- /dev/null +++ b/backend/src/modules/contacts/address-book.spec.ts @@ -0,0 +1,498 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { + applyAddressBookWrite, + buildBookView, + cardWeight, + emailKey, + groupWeight, + newCardUid, + weightAfterChanges, +} from './address-book'; +import { matchesContact, parseContactSearch } from './contact-search'; +import type { + AddressBookCard, + AddressBookContactCard, + AddressBookGroupCard, + AddressBookSnapshot, +} from './contacts.provider'; + +const card = ( + id: string, + overrides: Partial = {}, +): AddressBookContactCard => ({ + id, + uid: `urn:uuid:${id}`, + name: id, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + ...overrides, +}); + +const group = ( + id: string, + name: string, + memberUids: string[], + overrides: Partial = {}, +): AddressBookGroupCard => ({ + id, + uid: `urn:uuid:${id}`, + name, + memberUids, + ...overrides, +}); + +const snapshot = ( + contacts: AddressBookContactCard[], + groups: AddressBookGroupCard[] = [], +): AddressBookSnapshot => ({ version: 'v1', contacts, groups }); + +describe('buildBookView (주소록 한 사람분)', () => { + it('연락처 · 그룹을 이름 차례로 — 연락처의 그룹 · 그룹의 구성원 수는 uid 로 잇는다', () => { + const view = buildBookView( + snapshot( + [ + card('c-ha', { name: '하늘' }), + card('c-ga', { name: '가나다' }), + card('c-na', { name: '나비' }), + ], + [ + group('g-z', '협력사', ['urn:uuid:c-ha']), + group('g-a', '거래처', ['urn:uuid:c-ha', 'urn:uuid:c-ga']), + ], + ), + ); + + expect(view.contacts.map((entry) => entry.contact.id)).toEqual([ + 'c-ga', + 'c-na', + 'c-ha', + ]); + expect(view.groups.map((entry) => entry.group)).toEqual([ + { id: 'g-a', name: '거래처', memberCount: 2 }, + { id: 'g-z', name: '협력사', memberCount: 1 }, + ]); + // 그룹 차례 — 거래처 → 협력사 + expect(view.contactsById.get('c-ha')?.contact.groupIds).toEqual([ + 'g-a', + 'g-z', + ]); + expect(view.contactsById.get('c-na')?.contact.groupIds).toEqual([]); + // 구성원은 연락처의 이름 차례 + expect(view.groupsById.get('g-a')?.memberIds).toEqual(['c-ga', 'c-ha']); + expect(view.version).toBe('v1'); + }); + + it('구성원 수는 이 주소록의 연락처만 센다 — 지워진 카드 · 다른 주소록 · 그룹을 가리키는 uid 는 세지 않는다', () => { + const view = buildBookView( + snapshot( + [card('c1')], + [ + group('g-inner', '안쪽', []), + group('g', '거래처', [ + 'urn:uuid:c1', + 'urn:uuid:gone', + 'urn:uuid:g-inner', + ]), + ], + ), + ); + + expect(view.groupsById.get('g')?.group.memberCount).toBe(1); + expect(view.groupsById.get('g')?.memberIds).toEqual(['c1']); + }); + + it('uid 가 없는 카드는 어느 그룹에도 들지 않는다 · 같은 uid 의 카드 둘은 둘 다 구성원이다', () => { + const view = buildBookView( + snapshot( + [ + card('no-uid', { uid: null }), + card('twin-a', { uid: 'urn:uuid:twin' }), + card('twin-b', { uid: 'urn:uuid:twin' }), + ], + [group('g', '거래처', ['urn:uuid:twin'])], + ), + ); + + expect(view.contactsById.get('no-uid')?.contact.groupIds).toEqual([]); + expect(view.groupsById.get('g')?.group.memberCount).toBe(2); + }); + + it('주소로 연락처를 찾는 표(대소문자 무시) · 받는 사람 추천 줄 — 연락처의 이름 차례, 대표 주소가 먼저, 같은 주소는 한 번', () => { + const view = buildBookView( + snapshot([ + card('c-b', { + name: '박지훈', + company: '한빛상사', + emails: [ + { address: 'Jihoon@Hanbit.example', label: 'work' }, + { address: 'jihoon@home.example', label: 'personal' }, + { address: 'JIHOON@hanbit.example', label: 'other' }, + ], + }), + card('c-a', { + name: '가나다', + emails: [{ address: 'jihoon@hanbit.example', label: 'other' }], + }), + ]), + ); + + expect( + view.contactsByEmail + .get('jihoon@hanbit.example') + ?.map((entry) => entry.contact.id), + ).toEqual(['c-a', 'c-b']); + expect(view.suggestionLines).toEqual([ + { + name: '가나다', + email: 'jihoon@hanbit.example', + company: null, + emailLabel: 'other', + }, + { + name: '박지훈', + email: 'Jihoon@Hanbit.example', + company: '한빛상사', + emailLabel: 'work', + }, + { + name: '박지훈', + email: 'jihoon@home.example', + company: '한빛상사', + emailLabel: 'personal', + }, + ]); + }); + + it('읽는 상한(5,000장) · 그룹 100개 × 구성원 500명도 만들고 찾는 데 계산량이 몇 배로 늘지 않는다 (CPU 시간 — 지금 250ms 남짓)', () => { + const contacts = Array.from({ length: 5_000 }, (_, index) => + card(`c${index}`, { + name: `연락처 ${(index * 7_919) % 5_000} 박지훈`, + emails: [0, 1, 2].map((n) => ({ + address: `p${index}.${n}@x.example`, + label: 'work' as const, + })), + phones: [{ number: `010-${index}-5540`, label: 'mobile' as const }], + company: '한빛상사', + note: 'x'.repeat(200), + }), + ); + const groups = Array.from({ length: 100 }, (_, g) => + group( + `g${g}`, + `그룹 ${g}`, + Array.from( + { length: 500 }, + (_, m) => `urn:uuid:c${(g * 37 + m) % 5_000}`, + ), + ), + ); + const query = parseContactSearch('ㅂㅈㅎ'); + if (query === null) throw new Error('찾는 글을 해석하지 못했다'); + + const cpuMs = startCpuTimer(); + const view = buildBookView(snapshot(contacts, groups)); + for (let round = 0; round < 10; round += 1) { + expect( + view.contacts.filter((entry) => matchesContact(entry.search, query)), + ).toHaveLength(5_000); + } + expect(cpuMs()).toBeLessThan(2_500); + expect(view.groupsById.get('g99')?.group.memberCount).toBe(500); + }); + + it('uid 로 찾는 표 — 연락처(같은 uid 둘이면 둘 다) · 이 주소록이 쓰는 uid(그룹 포함, uid 없는 카드 제외)', () => { + const view = buildBookView( + snapshot( + [ + card('a', { uid: 'urn:uuid:twin' }), + card('b', { uid: 'urn:uuid:twin' }), + card('c', { uid: null }), + ], + [group('g', '거래처', [])], + ), + ); + expect( + view.contactsByUid.get('urn:uuid:twin')?.map((entry) => entry.card.id), + ).toEqual(['a', 'b']); + expect([...view.uidsInUse].sort()).toEqual(['urn:uuid:g', 'urn:uuid:twin']); + }); + + it('크기는 글자 수로 어림한다 — 메모가 길면 크다', () => { + const small = buildBookView(snapshot([card('c1')])); + const large = buildBookView( + snapshot([card('c1', { note: 'x'.repeat(1_000) })]), + ); + expect(large.weight - small.weight).toBe(1_000); + }); +}); + +describe('applyAddressBookWrite (쓴 바꾸기를 기억한 주소록에)', () => { + const base = snapshot( + [ + card('c1', { name: '하나', company: '옛 회사', department: '옛 부서' }), + card('c2'), + ], + [group('g1', '거래처', ['urn:uuid:c1'])], + ); + const contactCard = ( + id: string, + overrides: Partial = {}, + ): AddressBookCard => ({ kind: 'contact', card: card(id, overrides) }); + + it('지우기 · 그룹 고치기(보낸 구성원)를 옮기고, 만들거나 고친 연락처는 어댑터가 준 모양으로 — 새 판을 단다', () => { + const next = applyAddressBookWrite( + base, + { + create: [ + { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields: { + name: '새 사람', + emails: [{ address: 'new@x.example', label: 'work' }], + phones: [], + company: null, + department: null, + title: null, + note: null, + }, + }, + { + kind: 'group', + key: 'group', + uid: 'urn:uuid:g-new', + name: '새 그룹', + memberUids: ['urn:uuid:new'], + }, + ], + update: [ + { + kind: 'contact', + id: 'c1', + fields: { company: '새 회사' }, + }, + { + kind: 'group', + id: 'g1', + memberUids: ['urn:uuid:c1', 'urn:uuid:new'], + }, + ], + destroy: ['c2'], + }, + { + createdIds: new Map([ + ['contact', 'c-new'], + ['group', 'g-new'], + ]), + cards: new Map([ + // 어댑터가 칸 안의 남의 정보와 합친 결과 — 부서는 그대로, 직책은 다른 프로그램의 둘째 직책이 보인다 + [ + 'c1', + contactCard('c1', { + name: '하나', + company: '새 회사', + department: '옛 부서', + title: '둘째 직책', + }), + ], + [ + 'c-new', + contactCard('c-new', { uid: 'urn:uuid:new', name: '새 사람' }), + ], + [ + 'g-new', + { + kind: 'group', + card: group('g-new', '새 그룹', ['urn:uuid:new'], { + uid: 'urn:uuid:g-new', + }), + }, + ], + ]), + }, + 'v2', + ); + + expect(next?.version).toBe('v2'); + expect(next?.contacts.map((entry) => entry.id)).toEqual(['c1', 'c-new']); + expect(next?.contacts[0]).toMatchObject({ + company: '새 회사', + department: '옛 부서', + title: '둘째 직책', + }); + expect(next?.groups).toEqual([ + group('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:new']), + group('g-new', '새 그룹', ['urn:uuid:new'], { uid: 'urn:uuid:g-new' }), + ]); + // 받은 주소록은 그대로 둔다 + expect(base.contacts).toHaveLength(2); + expect(base.groups[0]?.memberUids).toEqual(['urn:uuid:c1']); + }); + + it('고친 연락처의 모양이 없으면 보낸 칸을 옮긴다 · 그룹 이름만 바꾸면 구성원은 그대로', () => { + const next = applyAddressBookWrite( + base, + { + create: [], + update: [ + { kind: 'contact', id: 'c1', fields: { department: null } }, + { kind: 'group', id: 'g1', name: '협력사' }, + ], + destroy: [], + }, + { createdIds: new Map(), cards: new Map() }, + 'v2', + ); + expect(next?.contacts[0]).toMatchObject({ + company: '옛 회사', + department: null, + }); + expect(next?.groups).toEqual([group('g1', '협력사', ['urn:uuid:c1'])]); + }); + + it('만든 카드의 id 나 모양이 없으면 null — 다시 읽어야 한다', () => { + const created = { + create: [ + { + kind: 'group' as const, + key: 'group', + uid: newCardUid(), + name: '새 그룹', + memberUids: [], + }, + ], + update: [], + destroy: [], + }; + expect( + applyAddressBookWrite( + base, + created, + { createdIds: new Map(), cards: new Map() }, + 'v2', + ), + ).toBeNull(); + expect( + applyAddressBookWrite( + base, + created, + { createdIds: new Map([['group', 'g-new']]), cards: new Map() }, + 'v2', + ), + ).toBeNull(); + }); +}); + +describe('weightAfterChanges (쓰기 전에 재는 크기 — 계약 3절 "크기")', () => { + const base = snapshot( + [card('c1', { note: 'x'.repeat(100) }), card('c2')], + [group('g1', '거래처', ['urn:uuid:c1'])], + ); + const book = buildBookView(base); + const fields = { + name: '새 사람', + emails: [{ address: 'new@x.example', label: 'work' as const }], + phones: [], + company: null, + department: null, + title: null, + note: 'y'.repeat(500), + }; + + it('만들기는 더하고 · 지우기는 빼고 · 고치기는 바뀐 만큼', () => { + expect( + weightAfterChanges(book, { create: [], update: [], destroy: [] }), + ).toBe(book.weight); + expect( + weightAfterChanges(book, { + create: [{ kind: 'contact', key: 'k', uid: 'urn:uuid:n', fields }], + update: [], + destroy: [], + }), + ).toBe(book.weight + cardWeight({ id: '', uid: 'urn:uuid:n', ...fields })); + expect( + weightAfterChanges(book, { create: [], update: [], destroy: ['c1'] }), + ).toBe(book.weight - cardWeight(base.contacts[0])); + // 메모 100자 → 10자 + expect( + weightAfterChanges(book, { + create: [], + update: [ + { kind: 'contact', id: 'c1', fields: { note: 'z'.repeat(10) } }, + ], + destroy: [], + }), + ).toBe(book.weight - 90); + // 그룹에 구성원 하나 더 + expect( + weightAfterChanges(book, { + create: [], + update: [ + { + kind: 'group', + id: 'g1', + memberUids: ['urn:uuid:c1', 'urn:uuid:c2'], + }, + ], + destroy: [], + }), + ).toBe( + book.weight - + groupWeight(base.groups[0]) + + groupWeight(group('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:c2'])), + ); + }); + + it('되돌리기는 지울 때의 모양으로 잰다 — 그룹은 다시 넣을 구성원으로', () => { + const restoredGroup = group('old', '옛 그룹', [ + 'urn:uuid:c1', + 'urn:uuid:gone', + ]); + expect( + weightAfterChanges(book, { + create: [ + { + kind: 'restored', + key: 'r', + uid: 'urn:uuid:old', + original: { source: '{}', bytes: 2 }, + memberUids: ['urn:uuid:c1'], + card: { kind: 'group', card: restoredGroup }, + }, + ], + update: [], + destroy: [], + }), + ).toBe( + book.weight + + groupWeight({ + ...restoredGroup, + id: '', + uid: 'urn:uuid:old', + memberUids: ['urn:uuid:c1'], + }), + ); + }); +}); + +describe('도움 함수', () => { + it('새 카드의 uid 는 urn:uuid 형식이고 매번 다르다', () => { + const a = newCardUid(); + const b = newCardUid(); + expect(a).toMatch( + /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(a).not.toBe(b); + }); + + it('주소 비교는 앞뒤 공백을 빼고 소문자로', () => { + expect(emailKey(' Ara.Kim@AraSystem.Test ')).toBe( + 'ara.kim@arasystem.test', + ); + }); +}); diff --git a/backend/src/modules/contacts/address-book.ts b/backend/src/modules/contacts/address-book.ts new file mode 100644 index 0000000..7c747a8 --- /dev/null +++ b/backend/src/modules/contacts/address-book.ts @@ -0,0 +1,362 @@ +import { randomUUID } from 'crypto'; + +import { + compareContacts, + compareGroups, + contactSearchIndex, + type ContactSearchIndex, +} from './contact-search'; +import type { + AddressBookCard, + AddressBookCardUpdate, + AddressBookChanges, + AddressBookContactCard, + AddressBookGroupCard, + AddressBookSnapshot, + NewAddressBookCard, +} from './contacts.provider'; +import type { + Contact, + ContactFields, + ContactGroup, + ContactSuggestionLine, +} from './contacts.types'; + +/** + * 주소록 한 사람분을 화면 모양으로 — 연락처(속한 그룹) · 그룹(구성원 수) · 찾기 자료 · 받는 사람 추천 줄 (순수 함수). + * 엔진에서 읽은 것(AddressBookSnapshot)으로 한 번 만들어 판(version)과 함께 기억한다 (contacts.service.ts). + * + * - 그룹 구성원은 카드의 uid 로 잇는다 — 이 주소록에 있는 연락처만 센다(지워진 카드를 가리키는 uid 는 세지 않는다) + * - 연락처 · 그룹은 이름 차례(contact-search.ts) + */ + +/** 연락처 한 장 — 카드(uid)와 화면 모양 · 찾기 자료 */ +export interface BookContact { + readonly card: AddressBookContactCard; + readonly contact: Contact; + readonly search: ContactSearchIndex; +} + +/** 그룹 하나 — 카드와 화면 모양 · 구성원(이 주소록의 연락처 id, 이름 차례) */ +export interface BookGroup { + readonly card: AddressBookGroupCard; + readonly group: ContactGroup; + readonly memberIds: readonly string[]; +} + +export interface BookView { + readonly version: string; + readonly snapshot: AddressBookSnapshot; + /** 이름 차례 */ + readonly contacts: readonly BookContact[]; + readonly groups: readonly BookGroup[]; + readonly contactsById: ReadonlyMap; + readonly groupsById: ReadonlyMap; + /** 주소(소문자) → 그 주소가 있는 연락처 (이름 차례) — 사내 목록에서 넣을 때 같은 주소의 연락처를 찾는다 */ + readonly contactsByEmail: ReadonlyMap; + /** uid → 그 uid 의 연락처 (같은 uid 의 카드가 둘 이상일 수 있다 — 다른 프로그램이 만든 것) */ + readonly contactsByUid: ReadonlyMap; + /** 이 주소록의 카드(연락처 · 그룹)가 쓰는 uid — 엔진은 한 주소록 안의 같은 uid 를 받지 않는다(2026-10-29 확인) */ + readonly uidsInUse: ReadonlySet; + /** 받는 사람 추천의 주소록 묶음 — 연락처의 이름 차례, 연락처 안에서는 대표 주소가 먼저 */ + readonly suggestionLines: readonly ContactSuggestionLine[]; + /** 대략의 크기(글자 수) — 쓰기의 크기 한도(MAX_ADDRESS_BOOK_WEIGHT)와 기억하는 양의 상한에 쓴다 (contact-book-cache.ts) */ + readonly weight: number; +} + +/** 카드 하나에 더하는 대략의 덩치 — 객체 · 배열 자리 */ +const CARD_OVERHEAD = 64; +const ITEM_OVERHEAD = 16; + +/** 새 카드의 uid — 다른 프로그램(CardDAV)과 같은 형식. 엔진은 만든 뒤 uid 를 바꾸지 못하게 한다 */ +export function newCardUid(): string { + return `urn:uuid:${randomUUID()}`; +} + +/** 비교 · 중복 확인용 주소 — 앞뒤 공백을 빼고 소문자로 */ +export function emailKey(address: string): string { + return address.trim().toLowerCase(); +} + +function textWeight(...texts: (string | null)[]): number { + return texts.reduce((sum, text) => sum + (text?.length ?? 0), 0); +} + +/** 연락처 카드 하나의 크기 — 우리가 다루는 칸의 글자 수 + 카드 · 항목마다 조금 (계약 3절 "크기") */ +export function cardWeight(card: AddressBookContactCard): number { + return ( + CARD_OVERHEAD + + textWeight( + card.id, + card.uid, + card.name, + card.company, + card.department, + card.title, + card.note, + ) + + card.emails.reduce( + (sum, email) => sum + ITEM_OVERHEAD + email.address.length, + 0, + ) + + card.phones.reduce( + (sum, phone) => sum + ITEM_OVERHEAD + phone.number.length, + 0, + ) + ); +} + +/** 그룹 카드 하나의 크기 */ +export function groupWeight(card: AddressBookGroupCard): number { + return ( + CARD_OVERHEAD + + textWeight(card.id, card.uid, card.name) + + card.memberUids.reduce((sum, uid) => sum + ITEM_OVERHEAD + uid.length, 0) + ); +} + +function fieldsOf(card: AddressBookContactCard): ContactFields { + return { + name: card.name, + emails: card.emails, + phones: card.phones, + company: card.company, + department: card.department, + title: card.title, + note: card.note, + }; +} + +/** 주소록 한 사람분을 만든다 */ +export function buildBookView(snapshot: AddressBookSnapshot): BookView { + // uid → 그 uid 의 연락처 id (같은 uid 의 카드가 둘 이상일 수 있다 — 엔진이 막지 않는다) + const idsByUid = new Map(); + for (const card of snapshot.contacts) { + if (card.uid === null) continue; + const ids = idsByUid.get(card.uid) ?? []; + ids.push(card.id); + idsByUid.set(card.uid, ids); + } + + const contactOrder = [...snapshot.contacts].sort(compareContacts); + const rank = new Map(contactOrder.map((card, index) => [card.id, index])); + const rankOf = (id: string) => rank.get(id) ?? 0; + + const groupCards = [...snapshot.groups].sort(compareGroups); + const groupIdsByContact = new Map(); + const groups: BookGroup[] = groupCards.map((card) => { + const members = new Set(); + for (const uid of card.memberUids) { + for (const id of idsByUid.get(uid) ?? []) members.add(id); + } + const memberIds = [...members].sort((a, b) => rankOf(a) - rankOf(b)); + for (const id of memberIds) { + const groupIds = groupIdsByContact.get(id) ?? []; + groupIds.push(card.id); + groupIdsByContact.set(id, groupIds); + } + return { + card, + group: { id: card.id, name: card.name, memberCount: memberIds.length }, + memberIds, + }; + }); + + const contacts: BookContact[] = contactOrder.map((card) => { + const contact: Contact = { + id: card.id, + ...fieldsOf(card), + groupIds: groupIdsByContact.get(card.id) ?? [], + }; + // 찾기 자료는 처음 찾을 때 만든다 — 바꿀 때마다 주소록을 다시 만드는데, 그때마다 모든 연락처의 글을 맞추지 않게 + // (5,000장에 CPU 90ms 남짓 — 2026-10-29 잼) + let search: ContactSearchIndex | null = null; + return { + card, + contact, + get search() { + search ??= contactSearchIndex(contact); + return search; + }, + }; + }); + + const contactsByUid = new Map(); + for (const entry of contacts) { + if (entry.card.uid === null) continue; + const owners = contactsByUid.get(entry.card.uid) ?? []; + owners.push(entry); + contactsByUid.set(entry.card.uid, owners); + } + const uidsInUse = new Set(contactsByUid.keys()); + for (const card of snapshot.groups) { + if (card.uid !== null) uidsInUse.add(card.uid); + } + + const contactsByEmail = new Map(); + const suggestionLines: ContactSuggestionLine[] = []; + for (const entry of contacts) { + const seen = new Set(); + for (const email of entry.contact.emails) { + const key = emailKey(email.address); + if (seen.has(key)) continue; + seen.add(key); + const owners = contactsByEmail.get(key) ?? []; + owners.push(entry); + contactsByEmail.set(key, owners); + suggestionLines.push({ + name: entry.contact.name, + email: email.address, + company: entry.contact.company, + emailLabel: email.label, + }); + } + } + + return { + version: snapshot.version, + snapshot, + contacts, + groups, + contactsById: new Map(contacts.map((entry) => [entry.card.id, entry])), + groupsById: new Map(groups.map((entry) => [entry.card.id, entry])), + contactsByEmail, + contactsByUid, + uidsInUse, + suggestionLines, + weight: + snapshot.contacts.reduce((sum, card) => sum + cardWeight(card), 0) + + snapshot.groups.reduce((sum, card) => sum + groupWeight(card), 0), + }; +} + +/** 고치기의 바꾼 칸만 (undefined 는 바꾸지 않음) */ +function definedFields(fields: Partial): Partial { + return Object.fromEntries( + Object.entries(fields).filter(([, value]) => value !== undefined), + ); +} + +function applyContactUpdate( + card: AddressBookContactCard, + update: AddressBookCardUpdate | undefined, +): AddressBookContactCard { + if (update?.kind !== 'contact') return card; + return { ...card, ...definedFields(update.fields) }; +} + +function applyGroupUpdate( + card: AddressBookGroupCard, + update: AddressBookCardUpdate | undefined, +): AddressBookGroupCard { + if (update?.kind !== 'group') return card; + return { + ...card, + ...(update.name !== undefined ? { name: update.name } : {}), + ...(update.memberUids !== undefined + ? { memberUids: [...update.memberUids] } + : {}), + }; +} + +/** 만들 카드의 크기 (id 는 엔진이 정하므로 빼고 잰다) */ +function newCardWeight(card: NewAddressBookCard): number { + if (card.kind === 'contact') { + return cardWeight({ id: '', uid: card.uid, ...card.fields }); + } + if (card.kind === 'group') { + return groupWeight({ + id: '', + uid: card.uid, + name: card.name, + memberUids: card.memberUids, + }); + } + const original = card.card; + return original.kind === 'contact' + ? cardWeight({ ...original.card, id: '', uid: card.uid }) + : groupWeight({ + ...original.card, + id: '', + uid: card.uid, + memberUids: card.memberUids ?? original.card.memberUids, + }); +} + +/** 바꾸기를 한 뒤의 주소록 크기 (어림 — 고친 칸은 보낸 값으로 잰다) — 크기 한도(계약 3절 "크기")를 엔진에 쓰기 전에 본다 */ +export function weightAfterChanges( + book: BookView, + changes: AddressBookChanges, +): number { + const destroyed = new Set(changes.destroy); + let weight = book.weight; + for (const id of destroyed) { + const contact = book.contactsById.get(id)?.card; + if (contact) weight -= cardWeight(contact); + const group = book.groupsById.get(id)?.card; + if (group) weight -= groupWeight(group); + } + for (const update of changes.update) { + if (destroyed.has(update.id)) continue; + if (update.kind === 'contact') { + const current = book.contactsById.get(update.id)?.card; + if (current) { + weight += + cardWeight(applyContactUpdate(current, update)) - cardWeight(current); + } + } else { + const current = book.groupsById.get(update.id)?.card; + if (current) { + weight += + groupWeight(applyGroupUpdate(current, update)) - groupWeight(current); + } + } + } + for (const card of changes.create) weight += newCardWeight(card); + return weight; +} + +/** 엔진에 쓴 바꾸기 — 만든 카드의 id · 만들거나 고친 카드의 지금 모양 (어댑터가 엔진에 보낸 카드를 읽은 것) */ +export interface WrittenChanges { + readonly createdIds: ReadonlyMap; + readonly cards: ReadonlyMap; +} + +/** + * 엔진이 받은 바꾸기를 기억한 주소록에 옮긴다 — 판이 같을 때만 쓰므로(ifInState) 엔진의 새 내용과 같다. + * 연락처의 고치기 · 만든 카드는 어댑터가 준 모양(칸 안의 남의 정보와 합친 결과)을, 그룹 고치기는 보낸 이름 · 구성원을 쓴다. + * 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다) + */ +export function applyAddressBookWrite( + snapshot: AddressBookSnapshot, + changes: AddressBookChanges, + written: WrittenChanges, + version: string, +): AddressBookSnapshot | null { + const destroyed = new Set(changes.destroy); + const updates = new Map(changes.update.map((update) => [update.id, update])); + const fresh = (id: string, kind: AddressBookCard['kind']) => { + const card = written.cards.get(id); + return card?.kind === kind ? card : undefined; + }; + + const contacts = snapshot.contacts + .filter((card) => !destroyed.has(card.id)) + .map((card) => { + const read = fresh(card.id, 'contact'); + if (read?.kind === 'contact') return read.card; + return applyContactUpdate(card, updates.get(card.id)); + }); + const groups = snapshot.groups + .filter((card) => !destroyed.has(card.id)) + .map((card) => applyGroupUpdate(card, updates.get(card.id))); + + for (const card of changes.create) { + const id = written.createdIds.get(card.key); + const read = id === undefined ? undefined : written.cards.get(id); + if (read === undefined) return null; + if (read.kind === 'contact') contacts.push(read.card); + else groups.push(read.card); + } + return { version, contacts, groups }; +} diff --git a/backend/src/modules/contacts/contact-book-cache.spec.ts b/backend/src/modules/contacts/contact-book-cache.spec.ts new file mode 100644 index 0000000..14b692c --- /dev/null +++ b/backend/src/modules/contacts/contact-book-cache.spec.ts @@ -0,0 +1,123 @@ +import type { BookView } from './address-book'; +import { ContactBookCache } from './contact-book-cache'; + +/** 판과 크기만 쓰는 가짜 주소록 */ +const view = (version: string, weight = 10): BookView => + ({ version, weight }) as unknown as BookView; + +describe('ContactBookCache (직원마다 기억하는 주소록)', () => { + let now: number; + const clock = () => now; + + beforeEach(() => { + now = 1_000_000; + }); + + it('판이 같을 때만 꺼내 준다 — 다르면 null (다시 읽는다)', () => { + const cache = new ContactBookCache( + { + maxUsers: 10, + maxTotalWeight: 1_000, + maxUserWeight: 1_000, + idleMs: 60_000, + }, + clock, + ); + const book = view('v1'); + cache.set('ara', book); + + expect(cache.get('ara', 'v1')).toBe(book); + expect(cache.get('ara', 'v2')).toBeNull(); + expect(cache.get('doyun', 'v1')).toBeNull(); + }); + + it('사람 수 상한 — 가장 오래 쓰지 않은 사람부터 잊는다', () => { + const cache = new ContactBookCache( + { + maxUsers: 2, + maxTotalWeight: 1_000, + maxUserWeight: 1_000, + idleMs: 60_000, + }, + clock, + ); + cache.set('a', view('va')); + cache.set('b', view('vb')); + // a 를 쓴다 — 이제 가장 오래 쓰지 않은 사람은 b + expect(cache.get('a', 'va')).not.toBeNull(); + cache.set('c', view('vc')); + + expect(cache.get('a', 'va')).not.toBeNull(); + expect(cache.get('b', 'vb')).toBeNull(); + expect(cache.get('c', 'vc')).not.toBeNull(); + expect(cache.size).toBe(2); + }); + + it('크기 상한 — 합이 넘으면 오래된 사람부터 잊고, 한 사람 상한을 넘는 주소록은 기억하지 않는다', () => { + const cache = new ContactBookCache( + { maxUsers: 10, maxTotalWeight: 100, maxUserWeight: 60, idleMs: 60_000 }, + clock, + ); + cache.set('a', view('va', 60)); + cache.set('b', view('vb', 30)); + cache.set('c', view('vc', 30)); + + expect(cache.get('a', 'va')).toBeNull(); + expect(cache.get('b', 'vb')).not.toBeNull(); + expect(cache.get('c', 'vc')).not.toBeNull(); + + cache.set('huge', view('vh', 61)); + expect(cache.get('huge', 'vh')).toBeNull(); + // 큰 주소록 때문에 다른 사람을 밀어내지 않는다 + expect(cache.size).toBe(2); + }); + + it('한 사람 상한 안의 주소록은 늘 기억한다 — 다른 사람을 밀어내고라도 (계약 3절 "크기" 안의 주소록은 늘 기억할 수 있다)', () => { + const cache = new ContactBookCache( + { maxUsers: 10, maxTotalWeight: 100, maxUserWeight: 80, idleMs: 60_000 }, + clock, + ); + cache.set('a', view('va', 50)); + cache.set('b', view('vb', 40)); + cache.set('big', view('vbig', 80)); + + expect(cache.get('big', 'vbig')).not.toBeNull(); + expect(cache.get('a', 'va')).toBeNull(); + expect(cache.get('b', 'vb')).toBeNull(); + }); + + it('같은 사람을 다시 기억하면 크기를 한 번만 센다 · 잊으면 크기도 빠진다', () => { + const cache = new ContactBookCache( + { maxUsers: 10, maxTotalWeight: 100, maxUserWeight: 100, idleMs: 60_000 }, + clock, + ); + for (let index = 0; index < 5; index += 1) { + cache.set('a', view(`v${index}`, 60)); + } + cache.set('b', view('vb', 40)); + expect(cache.size).toBe(2); + + cache.delete('a'); + cache.set('c', view('vc', 60)); + expect(cache.get('b', 'vb')).not.toBeNull(); + expect(cache.get('c', 'vc')).not.toBeNull(); + }); + + it('오래 쓰지 않은 것은 기억할 때 함께 지운다 (줄지 않는 표를 두지 않는다)', () => { + const cache = new ContactBookCache( + { + maxUsers: 10, + maxTotalWeight: 1_000, + maxUserWeight: 1_000, + idleMs: 60_000, + }, + clock, + ); + cache.set('old', view('vo')); + now += 60_000; + cache.set('new', view('vn')); + + expect(cache.size).toBe(1); + expect(cache.get('old', 'vo')).toBeNull(); + }); +}); diff --git a/backend/src/modules/contacts/contact-book-cache.ts b/backend/src/modules/contacts/contact-book-cache.ts new file mode 100644 index 0000000..9f46bd9 --- /dev/null +++ b/backend/src/modules/contacts/contact-book-cache.ts @@ -0,0 +1,82 @@ +import type { BookView } from './address-book'; + +export interface ContactBookCacheLimits { + /** 기억하는 사람 수 */ + readonly maxUsers: number; + /** 모두 합친 대략의 크기(글자 수) — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다 */ + readonly maxTotalWeight: number; + /** 한 사람의 크기 상한 — 이보다 큰 주소록은 기억하지 않는다(요청마다 다시 읽는다). 쓰기의 크기 한도와 같은 잣대 */ + readonly maxUserWeight: number; + /** 이만큼 쓰지 않으면 잊는다 (ms) */ + readonly idleMs: number; +} + +interface Entry { + readonly view: BookView; + lastUsedAt: number; +} + +/** + * 직원마다 기억하는 주소록 (계약 3절 "캐시") — 판(version)이 같을 때만 꺼내 준다. 쓰는 쪽은 요청마다 엔진에 판을 물어 견준다. + * 가장 오래 쓰지 않은 사람부터 잊는다(사람 수 · 모두 합친 크기 상한), 오래 쓰지 않은 것은 기억할 때 함께 지운다 (줄지 않는 표를 두지 않는다). + * 한 사람의 상한(maxUserWeight)은 모두 합친 상한보다 작게 둔다 — 크기 한도 안의 주소록은 늘 기억할 수 있다(다른 사람을 밀어낼 뿐). + * + * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 따로 기억한다 — 틀리지는 않는다(쓸 때마다 판을 엔진에 묻는다), 처음 읽기가 인스턴스마다 생길 뿐이다. + */ +export class ContactBookCache { + /** 넣은(쓴) 차례 — 맨 앞이 가장 오래 쓰지 않은 사람 */ + private readonly entries = new Map(); + private totalWeight = 0; + + constructor( + private readonly limits: ContactBookCacheLimits, + private readonly now: () => number = () => Date.now(), + ) {} + + /** 판이 같으면 기억한 주소록 (쓴 사람을 맨 뒤로), 아니면 null */ + get(user: string, version: string): BookView | null { + const entry = this.entries.get(user); + if (!entry || entry.view.version !== version) return null; + entry.lastUsedAt = this.now(); + this.entries.delete(user); + this.entries.set(user, entry); + return entry.view; + } + + set(user: string, view: BookView): void { + this.delete(user); + this.dropIdle(); + // 한 사람의 상한을 넘는 주소록(다른 프로그램이 크게 만든 것)은 기억하지 않는다 — 요청마다 다시 읽는다 + if (view.weight > this.limits.maxUserWeight) return; + + this.entries.set(user, { view, lastUsedAt: this.now() }); + this.totalWeight += view.weight; + while ( + this.entries.size > this.limits.maxUsers || + this.totalWeight > this.limits.maxTotalWeight + ) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.delete(oldest); + } + } + + delete(user: string): void { + const entry = this.entries.get(user); + if (!entry) return; + this.entries.delete(user); + this.totalWeight -= entry.view.weight; + } + + /** 기억한 사람 수 (점검용) */ + get size(): number { + return this.entries.size; + } + + private dropIdle(): void { + const cutoff = this.now() - this.limits.idleMs; + for (const [user, entry] of this.entries) { + if (entry.lastUsedAt <= cutoff) this.delete(user); + } + } +} diff --git a/backend/src/modules/contacts/contact-groups.controller.ts b/backend/src/modules/contacts/contact-groups.controller.ts new file mode 100644 index 0000000..d22239a --- /dev/null +++ b/backend/src/modules/contacts/contact-groups.controller.ts @@ -0,0 +1,164 @@ +import { + Body, + Controller, + Delete, + Get, + Header, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { ContactIdPipe } from './contact-id.pipe'; +import { + CONTACT_GROUPS_ROUTE, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_GROUP_NAME_LENGTH, + MAX_CONTACT_GROUPS, + MAX_CONTACTS, + MAX_GROUP_MEMBERS_PER_REQUEST, +} from './contacts.constants'; +import { ContactsService } from './contacts.service'; +import { + ContactGroupCreateDto, + ContactGroupDeletedDto, + ContactGroupDto, + ContactGroupMembersAddedDto, + ContactGroupMembersDto, + ContactGroupPageDto, + ContactGroupRenameDto, +} from './dto/contact-group.dto'; + +const NAME_RULES = `이름은 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LENGTH}자 · 제어 문자 없음, 이 주소록 안에서 겹치지 않는다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).`; + +// 내 주소록 — 그룹 (계약 3절 "주소록", 8단계). 엔진 카드는 JSContact kind group — 구성원은 연락처 카드의 uid 로 잇는다 +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller(CONTACT_GROUPS_ROUTE) +export class ContactGroupsController { + constructor(private readonly contacts: ContactsService) {} + + @Get() + @Header('Cache-Control', 'private, no-store') + @ApiOperation({ + summary: '그룹 목록 (커서 페이지네이션 · total 포함)', + description: + '이름 차례(한국어 사전 차례 → id). memberCount 는 이 주소록에 있는 연락처 가운데 구성원 수. ' + + '그룹은 받는 사람 추천에 넣지 않는다 — 쓰기 화면이 이 목록을 받아 그룹 이름으로 찾는다. Cache-Control: private, no-store.', + }) + @ApiStandardResponse(ContactGroupPageDto, { description: '그룹 목록' }) + @ApiErrorResponse('VAL_001', '잘못된 커서 · limit 범위 밖') + list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: CursorPaginationQueryDto, + ) { + return this.contacts.listGroups(user, query); + } + + @Post() + @ApiOperation({ + summary: '그룹 만들기', + description: + `${NAME_RULES} 그룹은 ${MAX_CONTACT_GROUPS}개까지. 본문은 { name } 만 — 구성원은 POST /api/contact-groups/{id}/members 로 넣고, ` + + '지운 그룹은 POST /api/contact-restorations 로 되돌린다.', + }) + @ApiStandardResponse(ContactGroupDto, { + status: 201, + description: '만든 그룹', + }) + @ApiErrorResponse('VAL_001', '이름 형식 · 길이 · 모르는 필드') + @ApiErrorResponse( + 'CONTACT_001', + `CONTACT_001 같은 이름의 그룹이 있다 / CONTACT_002 그룹 ${MAX_CONTACT_GROUPS}개 한도 — 둘 다 409, 아무것도 바꾸지 않았다`, + ) + create( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: ContactGroupCreateDto, + ) { + return this.contacts.createGroup(user, dto); + } + + @Patch(':id') + @ApiOperation({ + summary: '그룹 이름 바꾸기', + description: `${NAME_RULES} 같은 이름이면 바꾸지 않고 그대로 돌려준다.`, + }) + @ApiStandardResponse(ContactGroupDto, { description: '바꾼 그룹' }) + @ApiErrorResponse('VAL_001', '이름 형식 · 길이 · 모르는 필드') + @ApiErrorResponse('RES_001', '없는 그룹 (형식이 틀린 id 포함)') + @ApiErrorResponse('CONTACT_001', '같은 이름의 다른 그룹이 있다') + rename( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + @Body() dto: ContactGroupRenameDto, + ) { + return this.contacts.renameGroup(user, id, dto.name); + } + + @Delete(':id') + @ApiOperation({ + summary: '그룹 지우기', + description: + '그룹 카드만 지운다 — 연락처는 그대로. 응답은 이름 · 구성원과 되돌리기 표(restoreToken — POST /api/contact-restorations 로 5분 안에 한 번, ' + + '그때의 구성원 가운데 지금도 있는 연락처로 다시 만든다. 서버가 기억하지 못했으면 null).', + }) + @ApiStandardResponse(ContactGroupDeletedDto, { + description: '지운 그룹 — 이름 · 구성원 · 되돌리기 표', + }) + @ApiErrorResponse('RES_001', '없는 그룹') + remove( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + ) { + return this.contacts.removeGroup(user, id); + } + + @Post(':id/members') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '그룹에 구성원 넣기', + description: + `contactIds(내 연락처)와 directoryIds(사내 목록)를 합쳐 1~${MAX_GROUP_MEMBERS_PER_REQUEST}개. ` + + '사내 목록에서 고른 사람은 같은 주소(대소문자 무시)의 연락처가 내 주소록에 있으면 그 연락처를, 없으면 이름 · 주소로 새로 만들어 넣는다. ' + + `이미 구성원인 연락처 · uid 가 없는 카드는 세지 않는다. 연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 하나에 ${MAX_CONTACT_GROUP_MEMBERS}명 · ` + + '주소록 크기 800만까지 — 넘으면 아무것도 하지 않는다.', + }) + @ApiStandardResponse(ContactGroupMembersAddedDto, { + description: '넣은 뒤의 그룹 · 이번에 넣은 수 · 새로 만든 연락처 수', + }) + @ApiErrorResponse( + 'VAL_001', + `합쳐 0개 · ${MAX_GROUP_MEMBERS_PER_REQUEST}개 초과 · 형식 · 겹침 · 모르는 필드`, + ) + @ApiErrorResponse('RES_001', '없는 그룹 · 연락처 · 사내 목록 id') + @ApiErrorResponse( + 'CONTACT_002', + `연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 크기 한도 — 아무것도 바꾸지 않았다`, + ) + @ApiErrorResponse('MAIL_001', '메일 엔진 장애 · 사내 목록을 읽지 못함') + addMembers( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + @Body() dto: ContactGroupMembersDto, + ) { + return this.contacts.addGroupMembers(user, id, dto); + } +} diff --git a/backend/src/modules/contacts/contact-id.pipe.ts b/backend/src/modules/contacts/contact-id.pipe.ts new file mode 100644 index 0000000..17fcd73 --- /dev/null +++ b/backend/src/modules/contacts/contact-id.pipe.ts @@ -0,0 +1,20 @@ +import { Injectable, PipeTransform } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { CONTACT_ID_PATTERN } from './contacts.constants'; + +/** + * 경로의 연락처 · 그룹 id 형식 확인. + * 형식이 틀린 id 는 "그런 대상이 없다"와 같으므로 RES_001 로 응답한다 (엔진까지 보내지 않는다 — 메일의 MailIdPipe 와 같은 규칙) + */ +@Injectable() +export class ContactIdPipe implements PipeTransform { + transform(value: unknown): string { + if (typeof value === 'string' && CONTACT_ID_PATTERN.test(value)) { + return value; + } + throw new BusinessException('RES_001', { + context: { reason: 'malformed-contact-id' }, + }); + } +} diff --git a/backend/src/modules/contacts/contact-paging.ts b/backend/src/modules/contacts/contact-paging.ts new file mode 100644 index 0000000..038bd52 --- /dev/null +++ b/backend/src/modules/contacts/contact-paging.ts @@ -0,0 +1,55 @@ +import { + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { + decodeOffsetCursor, + encodeOffsetCursor, +} from '@/shared/utils/offset-cursor'; + +/** + * 주소록 · 그룹 · 사내 목록의 커서 페이지 (계약 3절) — 서버가 그 자리에서 거르고 줄 세운 목록의 위치를 커서로 준다. + * 커서는 받은 그 범위(목록 · q · groupId · kind)로만 쓴다 — 다르면 엔진에 묻기 전에 400 VAL_001. + * 차례 사이에 목록이 바뀌면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다. + */ + +/** 목록 범위 — 목록 이름과 거르는 값(없으면 null)을 이은 글 */ +export function listScope( + list: string, + ...filters: (string | null | undefined)[] +): string { + return JSON.stringify([list, ...filters.map((value) => value ?? null)]); +} + +/** 커서의 위치 — 없으면 0 */ +export function cursorOffset( + cursor: string | undefined, + scope: string, +): number { + if (cursor === undefined) return 0; + const reading = decodeOffsetCursor(cursor, scope); + if ('problem' in reading) { + throw new BusinessException('VAL_001', { + context: { reason: `invalid-contact-cursor:${reading.problem}` }, + }); + } + return reading.offset; +} + +/** 거른 목록의 한 쪽 — total 은 거른 개수 */ +export function pageOf( + items: readonly T[], + start: number, + limit: number, + scope: string, +): PaginatedResult { + const page = items.slice(start, start + limit); + const next = start + page.length; + const hasNext = next < items.length; + return toPaginatedResult([...page], { + hasNext, + nextCursor: hasNext ? encodeOffsetCursor(next, scope) : null, + total: items.length, + }); +} diff --git a/backend/src/modules/contacts/contact-restorations.controller.ts b/backend/src/modules/contacts/contact-restorations.controller.ts new file mode 100644 index 0000000..f056512 --- /dev/null +++ b/backend/src/modules/contacts/contact-restorations.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + CONTACT_RESTORATIONS_ROUTE, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_GROUPS, + MAX_CONTACTS, +} from './contacts.constants'; +import { ContactsService } from './contacts.service'; +import { + ContactRestorationDto, + ContactRestoredDto, +} from './dto/contact-restoration.dto'; + +// 내 주소록 — 지운 연락처 · 그룹 되돌리기 (계약 3절 "되돌리기", 8단계). 지우기 응답의 restoreToken 으로 원본 그대로 다시 만든다 +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(CONTACT_RESTORATIONS_ROUTE) +export class ContactRestorationsController { + constructor(private readonly contacts: ContactsService) {} + + @Post() + @ApiOperation({ + summary: '지운 연락처 · 그룹 되돌리기', + description: + '서버가 지우기 직전에 읽은 엔진 카드 전체(다른 프로그램의 칸 · uid 포함)를 그대로 다시 만든다 — 같은 uid(그사이 같은 uid 의 카드가 생겼으면 새 uid), ' + + `id 는 새로 받는다. 연락처는 그때 속했고 지금도 있는 그룹에 다시 넣고(구성원 ${MAX_CONTACT_GROUP_MEMBERS}명이 찬 그룹은 건너뛴다), ` + + '그룹은 그때의 구성원 가운데 지금도 있는 연락처로 다시 만든다. 표는 5분 · 한 번만(성공하면 지운다 — 실패하면 5분 안에 다시 쓸 수 있다). ' + + '서버를 다시 띄우면 표는 사라진다.', + }) + @ApiStandardResponse(ContactRestoredDto, { + status: 201, + description: '다시 만든 연락처 · 그룹', + }) + @ApiErrorResponse('VAL_001', '표 형식 · 모르는 필드') + @ApiErrorResponse( + 'RES_001', + '표가 없음 · 지남 · 이미 씀 · 다른 직원의 표 (모두 같게 답한다)', + ) + @ApiErrorResponse( + 'CONTACT_001', + `CONTACT_001 같은 이름의 그룹이 그사이 생겼다 / CONTACT_002 연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 ${MAX_CONTACT_GROUPS}개 · 크기 한도 — 둘 다 409, 아무것도 바꾸지 않았다`, + ) + @ApiErrorResponse('MAIL_001', '메일 엔진 장애 (엔진이 원본을 받지 않음 포함)') + restore( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: ContactRestorationDto, + ) { + return this.contacts.restore(user, dto.token); + } +} diff --git a/backend/src/modules/contacts/contact-restore-store.spec.ts b/backend/src/modules/contacts/contact-restore-store.spec.ts new file mode 100644 index 0000000..4f250cd --- /dev/null +++ b/backend/src/modules/contacts/contact-restore-store.spec.ts @@ -0,0 +1,114 @@ +import { + ContactRestoreStore, + type RestorableCard, +} from './contact-restore-store'; +import { + CONTACT_RESTORE_TOKEN_BYTES, + CONTACT_RESTORE_TOKEN_PATTERN, +} from './contacts.constants'; + +const LIMITS = { + ttlMs: 5 * 60_000, + maxPerUser: 3, + maxCardBytes: 100, + maxTotalBytes: 250, + tokenBytes: CONTACT_RESTORE_TOKEN_BYTES, +}; + +const contact = (bytes = 10, name = '박지훈'): RestorableCard => ({ + kind: 'contact', + original: { source: '{}', bytes }, + card: { + id: 'c1', + uid: 'urn:uuid:c1', + name, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }, + groups: [], +}); + +describe('ContactRestoreStore (되돌리기 표)', () => { + let now: number; + let store: ContactRestoreStore; + + beforeEach(() => { + now = 1_000_000; + store = new ContactRestoreStore(LIMITS, () => now); + }); + + it('표는 무작위 128비트 base64url(22자) — 그 직원만 찾는다', () => { + const card = contact(); + const token = store.remember('ara', card); + expect(token).toMatch(CONTACT_RESTORE_TOKEN_PATTERN); + expect(store.find('ara', token ?? '')).toBe(card); + expect(store.find('doyun', token ?? '')).toBeNull(); + expect(store.find('ara', 'AAAAAAAAAAAAAAAAAAAAAA')).toBeNull(); + // 다른 직원이 찾아도 표는 그대로 남는다 + expect(store.find('ara', token ?? '')).toBe(card); + expect(store.remember('ara', contact())).not.toBe(token); + }); + + it('한 번만 — 쓰는 쪽이 forget 하면 다시 찾지 못한다', () => { + const token = store.remember('ara', contact()) ?? ''; + store.forget(token); + expect(store.find('ara', token)).toBeNull(); + expect(store.size).toBe(0); + // 없는 표를 잊어도 괜찮다 + expect(() => store.forget(token)).not.toThrow(); + }); + + it('5분이 지나면 찾지 못한다 (그때 잊는다)', () => { + const token = store.remember('ara', contact()) ?? ''; + now += LIMITS.ttlMs - 1; + expect(store.find('ara', token)).not.toBeNull(); + now += 1; + expect(store.find('ara', token)).toBeNull(); + expect(store.size).toBe(0); + }); + + it('새로 기억할 때 지난 표를 함께 잊는다 (줄지 않는 표를 두지 않는다)', () => { + store.remember('ara', contact()); + store.remember('doyun', contact()); + now += LIMITS.ttlMs; + store.remember('seojun', contact()); + expect(store.size).toBe(1); + }); + + it('직원마다 최근 maxPerUser 개 — 넘으면 그 직원의 가장 오래된 표부터 (다른 직원 것은 그대로)', () => { + const tokens = Array.from( + { length: LIMITS.maxPerUser + 1 }, + (_, index) => store.remember('ara', contact(10, `n${index}`)) ?? '', + ); + const other = store.remember('doyun', contact()) ?? ''; + expect(store.find('ara', tokens[0] ?? '')).toBeNull(); + for (const token of tokens.slice(1)) { + expect(store.find('ara', token)).not.toBeNull(); + } + expect(store.find('doyun', other)).not.toBeNull(); + }); + + it('원본이 maxCardBytes 를 넘으면 기억하지 않는다 — 표 null', () => { + expect(store.remember('ara', contact(LIMITS.maxCardBytes))).not.toBeNull(); + expect(store.remember('ara', contact(LIMITS.maxCardBytes + 1))).toBeNull(); + expect(store.size).toBe(1); + }); + + it('모두 합친 크기가 넘으면 가장 오래된 표부터 잊는다 — 직원이 달라도', () => { + const first = store.remember('ara', contact(100)) ?? ''; + const second = store.remember('doyun', contact(100)) ?? ''; + const third = store.remember('seojun', contact(100)) ?? ''; + expect(store.find('ara', first)).toBeNull(); + expect(store.find('doyun', second)).not.toBeNull(); + expect(store.find('seojun', third)).not.toBeNull(); + // 쓴 표를 잊으면 크기도 빠진다 — 다음 표가 앞 표를 밀어내지 않는다 + store.forget(second); + const fourth = store.remember('ara', contact(100)) ?? ''; + expect(store.find('seojun', third)).not.toBeNull(); + expect(store.find('ara', fourth)).not.toBeNull(); + }); +}); diff --git a/backend/src/modules/contacts/contact-restore-store.ts b/backend/src/modules/contacts/contact-restore-store.ts new file mode 100644 index 0000000..a35ea20 --- /dev/null +++ b/backend/src/modules/contacts/contact-restore-store.ts @@ -0,0 +1,143 @@ +import { randomBytes } from 'crypto'; + +import type { + AddressBookContactCard, + AddressBookGroupCard, + RemovedCardOriginal, +} from './contacts.provider'; + +/** 지운 연락처가 그때 속했던 그룹 — 되돌릴 때 uid 로 찾는다(그사이 되돌린 그룹은 id 가 바뀌어도 uid 는 같다), uid 가 없으면 id 로 */ +export interface RestorableGroupRef { + readonly id: string; + readonly uid: string | null; +} + +/** 되돌릴 수 있는 것 — 지운 카드의 원본(엔진 카드 전체)과 지울 때의 우리 칸 */ +export type RestorableCard = + | { + readonly kind: 'contact'; + readonly original: RemovedCardOriginal; + readonly card: AddressBookContactCard; + /** 그때 속했던 그룹 */ + readonly groups: readonly RestorableGroupRef[]; + } + | { + readonly kind: 'group'; + readonly original: RemovedCardOriginal; + readonly card: AddressBookGroupCard; + }; + +export interface ContactRestoreLimits { + /** 표를 기억하는 시간 (ms) */ + readonly ttlMs: number; + /** 직원마다 기억하는 표 수 — 넘으면 그 직원의 가장 오래된 표부터 잊는다 */ + readonly maxPerUser: number; + /** 원본 하나의 상한(UTF-8 바이트) — 넘으면 기억하지 않는다(표 null) */ + readonly maxCardBytes: number; + /** 모두 합친 원본의 상한(바이트) — 넘으면 가장 오래된 표부터 잊는다 */ + readonly maxTotalBytes: number; + /** 표의 무작위 바이트 수 */ + readonly tokenBytes: number; +} + +interface Entry { + readonly user: string; + readonly card: RestorableCard; + readonly expiresAt: number; +} + +/** + * 되돌리기 표 (계약 3절 "되돌리기") — 지운 연락처 · 그룹의 원본을 직원마다 잠깐 기억한다(서버 메모리). + * - 표는 무작위 128비트(base64url) — 지운 직원의 것만 쓸 수 있다(다른 직원 · 지난 표 · 없는 표는 find 가 null) + * - 한 번만 쓴다 — 되돌리기가 성공하면 쓰는 쪽이 forget 한다(실패하면 남겨 두어 다시 쓸 수 있다) + * - 5분 · 직원마다 최근 20개 · 원본 하나 256KB · 모두 합쳐 32MB (contacts.constants.ts) — 넣은 차례가 곧 끝나는 차례라 앞에서부터 잊는다 + * - 표 · 원본은 로그에 남기지 않는다 + * + * ⚠️ 프로세스 메모리다. 서버를 여러 대로 늘리면 공유 저장소로 옮겨야 한다 — 다른 인스턴스로 간 되돌리기는 404 RES_001 이 된다. + */ +export class ContactRestoreStore { + /** 표 → 기억한 것 (넣은 차례 — 맨 앞이 가장 먼저 끝난다) */ + private readonly entries = new Map(); + /** 직원 → 그 직원의 표 (넣은 차례) */ + private readonly byUser = new Map(); + private totalBytes = 0; + + constructor( + private readonly limits: ContactRestoreLimits, + private readonly now: () => number = () => Date.now(), + ) {} + + /** 기억하고 표를 준다 — 원본이 상한을 넘으면 기억하지 않고 null */ + remember(user: string, card: RestorableCard): string | null { + this.dropExpired(); + if (card.original.bytes > this.limits.maxCardBytes) return null; + + const tokens = this.byUser.get(user) ?? []; + while (tokens.length >= this.limits.maxPerUser) { + const oldest = tokens[0]; + if (oldest === undefined) break; + this.forget(oldest); + } + + let token = this.newToken(); + while (this.entries.has(token)) token = this.newToken(); + this.entries.set(token, { + user, + card, + expiresAt: this.now() + this.limits.ttlMs, + }); + const owned = this.byUser.get(user) ?? []; + owned.push(token); + this.byUser.set(user, owned); + this.totalBytes += card.original.bytes; + + while (this.totalBytes > this.limits.maxTotalBytes) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.forget(oldest); + } + return this.entries.has(token) ? token : null; + } + + /** 그 직원의 표 — 없거나 · 지났거나 · 다른 직원의 것이면 null (지난 표는 이때 잊는다) */ + find(user: string, token: string): RestorableCard | null { + const entry = this.entries.get(token); + if (!entry) return null; + if (entry.expiresAt <= this.now()) { + this.forget(token); + return null; + } + return entry.user === user ? entry.card : null; + } + + /** 표를 잊는다 (쓴 표 · 밀려난 표) */ + forget(token: string): void { + const entry = this.entries.get(token); + if (!entry) return; + this.entries.delete(token); + this.totalBytes -= entry.card.original.bytes; + const owned = this.byUser.get(entry.user); + if (!owned) return; + const index = owned.indexOf(token); + if (index >= 0) owned.splice(index, 1); + if (owned.length === 0) this.byUser.delete(entry.user); + } + + /** 기억한 표 수 (점검용) */ + get size(): number { + return this.entries.size; + } + + private newToken(): string { + return randomBytes(this.limits.tokenBytes).toString('base64url'); + } + + /** 지난 표를 잊는다 — 넣은 차례가 곧 끝나는 차례다 */ + private dropExpired(): void { + const now = this.now(); + for (const [token, entry] of this.entries) { + if (entry.expiresAt > now) break; + this.forget(token); + } + } +} diff --git a/backend/src/modules/contacts/contact-search.spec.ts b/backend/src/modules/contacts/contact-search.spec.ts new file mode 100644 index 0000000..d85644c --- /dev/null +++ b/backend/src/modules/contacts/contact-search.spec.ts @@ -0,0 +1,223 @@ +import { + compareContacts, + compareDirectoryEntries, + compareGroups, + contactSearchIndex, + foldText, + matchesContact, + matchesDirectoryEntry, + nameInitials, + parseContactSearch, + type ContactSearchQuery, +} from './contact-search'; +import type { Contact, DirectoryEntry } from './contacts.types'; + +const contact = (overrides: Partial = {}): Contact => ({ + id: 'c1', + name: '박지훈', + emails: [{ address: 'Jihoon.Park@Hanbit.example', label: 'work' }], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '한빛상사', + department: '구매팀', + title: 'Manager', + note: '메모에는 찾는 글이 있어도 맞지 않는다', + groupIds: [], + ...overrides, +}); + +const search = (q: string): ContactSearchQuery => { + const parsed = parseContactSearch(q); + if (parsed === null) throw new Error(`해석하지 못한 찾는 글: ${q}`); + return parsed; +}; + +const matches = (q: string, target: Contact = contact()) => + matchesContact(contactSearchIndex(target), search(q)); + +describe('찾는 글 해석 (parseContactSearch)', () => { + it('앞뒤 공백을 빼고 비면 null — 거르지 않는다', () => { + for (const q of [undefined, null, '', ' ', '\u3000']) { + expect(parseContactSearch(q)).toBeNull(); + } + }); + + it('초성만 친 글 — 띄어쓰기는 빼고, 겹자음은 두 초성으로 나눈다 (입력기가 ㄱ 다음 ㅅ 을 ㄳ 으로 합친다)', () => { + expect(parseContactSearch('ㅂㅈㅎ')).toEqual({ + kind: 'initials', + initials: 'ㅂㅈㅎ', + }); + expect(parseContactSearch(' ㅂ ㅈ ㅎ ')).toEqual({ + kind: 'initials', + initials: 'ㅂㅈㅎ', + }); + expect(parseContactSearch('ㄳㅇ')).toEqual({ + kind: 'initials', + initials: 'ㄱㅅㅇ', + }); + // 쌍자음은 그대로 (초성이다) + expect(parseContactSearch('ㄲㅊ')).toEqual({ + kind: 'initials', + initials: 'ㄲㅊ', + }); + }); + + it('초성과 다른 글자가 섞이면 글로 찾는다 — 번호처럼 생긴 글이면 숫자도', () => { + expect(parseContactSearch('박ㅈ')).toEqual({ + kind: 'text', + text: '박ㅈ', + digits: null, + }); + expect(parseContactSearch(' 010-3192 ')).toEqual({ + kind: 'text', + text: '010-3192', + digits: '0103192', + }); + expect(parseContactSearch('Hanbit')).toEqual({ + kind: 'text', + text: 'hanbit', + digits: null, + }); + // 숫자가 없는 기호뿐인 글은 번호로 보지 않는다 + expect(parseContactSearch('+-')).toMatchObject({ digits: null }); + // 숫자와 글자가 섞이면 번호가 아니다 + expect(parseContactSearch('a1')).toMatchObject({ digits: null }); + }); +}); + +describe('연락처 찾기 (matchesContact)', () => { + it('초성 — 이름의 초성과 견준다 (띄어쓰기는 빼고, 가운데부터여도)', () => { + expect(matches('ㅂㅈㅎ')).toBe(true); + expect(matches('ㅈㅎ')).toBe(true); + expect(matches('ㅂㅎ')).toBe(false); + expect(matches('ㅂㅈㅎ', contact({ name: '박 지훈' }))).toBe(true); + // 이름이 없으면 초성으로는 찾지 못한다 + expect(matches('ㅎ', contact({ name: null }))).toBe(false); + // 초성은 이름만 본다 — 회사(한빛상사)의 초성은 보지 않는다 + expect(matches('ㅎㅂ')).toBe(false); + }); + + it('겹자음으로 합쳐진 초성도 찾는다 — 김선우를 ㄱ ㅅ ㅇ 으로 치면 ㄳㅇ', () => { + expect(matches('ㄳㅇ', contact({ name: '김선우' }))).toBe(true); + }); + + it('이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 든 글 — ASCII 대소문자 무시, 메모는 보지 않는다', () => { + for (const q of [ + '지훈', + 'jihoon.park', + 'HANBIT', + '한빛', + '구매', + 'manager', + ]) { + expect(matches(q)).toBe(true); + } + expect(matches('찾는 글')).toBe(false); + expect(matches('없는 사람')).toBe(false); + }); + + it('전화번호는 숫자만 견준다 — 01031925540 · 3192 · 010 3192 가 010-3192-5540 에 맞는다', () => { + for (const q of ['01031925540', '3192', '010 3192', '(010)3192', '5540']) { + expect(matches(q)).toBe(true); + } + expect(matches('3193')).toBe(false); + // 번호 칸의 글 그대로도 맞는다 + expect(matches('010-3192')).toBe(true); + }); + + it('조합형(NFD)으로 친 한글도 찾는다', () => { + expect(matches('지훈'.normalize('NFD'))).toBe(true); + }); + + it('ASCII 가 아닌 대소문자는 가리지 않고 그대로 견준다 (계약 — ASCII 만 무시)', () => { + expect(foldText('ÄBC')).toBe('Äbc'); + }); +}); + +describe('사내 목록 찾기 (matchesDirectoryEntry)', () => { + const entry: DirectoryEntry = { + id: 'd', + name: '이도윤', + email: 'doyun.lee@arasystem.test', + kind: 'person', + }; + + it('이름 · 주소로 같은 규칙 — 초성 · 글', () => { + expect(matchesDirectoryEntry(entry, search('ㄷㅇ'))).toBe(true); + expect(matchesDirectoryEntry(entry, search('도윤'))).toBe(true); + expect(matchesDirectoryEntry(entry, search('DOYUN'))).toBe(true); + expect(matchesDirectoryEntry(entry, search('ㅅㅈ'))).toBe(false); + expect(matchesDirectoryEntry(entry, search('서준'))).toBe(false); + }); +}); + +describe('이름의 초성 (nameInitials)', () => { + it('한글 음절은 초성으로, 띄어쓰기는 빼고, 그 밖의 글자는 그대로', () => { + expect(nameInitials('박지훈')).toBe('ㅂㅈㅎ'); + expect(nameInitials('꽃 님')).toBe('ㄲㄴ'); + expect(nameInitials('Kim 아라')).toBe('kimㅇㄹ'); + }); +}); + +describe('차례', () => { + it('연락처 — 이름(한국어 사전 차례), 이름이 없으면 대표 주소, 같으면 대표 주소 → id, 이름도 주소도 없으면 맨 뒤', () => { + const cards = [ + contact({ id: 'z', name: null, emails: [] }), + contact({ id: 'c', name: '하늘', emails: [] }), + contact({ + id: 'b', + name: null, + emails: [{ address: 'apple@x.example', label: 'other' }], + }), + contact({ + id: 'a2', + name: '가나다', + emails: [{ address: 'b@x.example', label: 'work' }], + }), + contact({ + id: 'a1', + name: '가나다', + emails: [{ address: 'b@x.example', label: 'work' }], + }), + contact({ + id: 'a0', + name: '가나다', + emails: [{ address: 'a@x.example', label: 'work' }], + }), + contact({ id: 'd', name: '나비', emails: [] }), + ]; + + expect([...cards].sort(compareContacts).map((card) => card.id)).toEqual([ + 'a0', + 'a1', + 'a2', + 'd', + 'c', + // 한국어 사전 차례는 한글이 영문(apple — 이름이 없어 대표 주소로 견준다)보다 앞이다 + 'b', + 'z', + ]); + }); + + it('그룹 — 이름 → id · 사내 목록 — 이름 → 주소 → id', () => { + expect( + [ + { id: '2', name: '하나' }, + { id: '1', name: '거래처' }, + { id: '0', name: '거래처' }, + ] + .sort(compareGroups) + .map((group) => group.id), + ).toEqual(['0', '1', '2']); + + const people: DirectoryEntry[] = [ + { id: '3', name: '이도윤', email: 'b@x.test', kind: 'person' }, + { id: '2', name: '이도윤', email: 'a@x.test', kind: 'person' }, + { id: '1', name: '김아라', email: 'z@x.test', kind: 'person' }, + ]; + expect(people.sort(compareDirectoryEntries).map((p) => p.id)).toEqual([ + '1', + '2', + '3', + ]); + }); +}); diff --git a/backend/src/modules/contacts/contact-search.ts b/backend/src/modules/contacts/contact-search.ts new file mode 100644 index 0000000..943083d --- /dev/null +++ b/backend/src/modules/contacts/contact-search.ts @@ -0,0 +1,204 @@ +import type { Contact, DirectoryEntry } from './contacts.types'; + +/** + * 주소록 · 사내 목록의 찾기(q)와 차례 (순수 함수) — 계약 3절 "찾기" · "차례". + * + * - 초성만 친 글(ㄱ~ㅎ, 쌍자음 · 겹자음 포함)이면 이름의 초성과 견준다 — ㅂㅈㅎ → 박지훈. 겹자음은 나눈다(ㄱ 다음 ㅅ 을 치면 입력기가 ㄳ 으로 합친다) + * - 그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 그 글이 들어 있으면 맞다 (ASCII 대소문자 무시, 유니코드 NFC 로 맞춰 견준다) + * - 전화번호는 숫자만 견준다 — 번호처럼 생긴 글(숫자 · 띄어쓰기 · + - ( ) . # *)이면 그 숫자가 번호의 숫자 안에 있으면 맞다 + * (`01031925540` · `3192` 가 `010-3192-5540` 에 맞는다) + * - 사내 목록은 이름 · 주소로 같은 규칙 + * + * 엔진의 주소록 검색은 낱말 단위이고 이름 정렬을 하지 못해서(2026-10-29 확인) 서버가 주소록 전체를 두고 찾고 줄 세운다. + */ + +const SYLLABLE_START = 0xac00; +const SYLLABLE_END = 0xd7a3; +/** 한 초성에 딸린 글자 수 — 중성 21 × 종성 28 */ +const SYLLABLES_PER_INITIAL = 21 * 28; +/** 초성 — 호환 자모 */ +const INITIALS = Array.from('ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ'); +/** 호환 자모의 자음(ㄱ U+3131 ~ ㅎ U+314E) — 쌍자음 · 겹자음 포함 */ +const CONSONANT_JAMO = /^[ㄱ-ㅎ]$/u; +/** 겹자음 → 두 초성 */ +const SPLIT_CONSONANTS: Readonly> = { + ㄳ: 'ㄱㅅ', + ㄵ: 'ㄴㅈ', + ㄶ: 'ㄴㅎ', + ㄺ: 'ㄹㄱ', + ㄻ: 'ㄹㅁ', + ㄼ: 'ㄹㅂ', + ㄽ: 'ㄹㅅ', + ㄾ: 'ㄹㅌ', + ㄿ: 'ㄹㅍ', + ㅀ: 'ㄹㅎ', + ㅄ: 'ㅂㅅ', +}; +/** 번호처럼 생긴 글 — 숫자 · 띄어쓰기 · + - ( ) . # * 만 (숫자가 하나 이상인지는 따로 본다) */ +const PHONE_LIKE = /^[0-9\s+\-().#*]+$/u; +const WHITESPACE = /\s/u; +const NON_DIGITS = /\D+/g; + +/** 견주기용 글 — NFC 로 맞추고 ASCII 대문자만 소문자로 */ +export function foldText(text: string): string { + return text + .normalize('NFC') + .replace(/[A-Z]+/g, (letters) => letters.toLowerCase()); +} + +/** 이름의 초성 — 한글 음절은 초성으로, 띄어쓰기는 빼고, 그 밖의 글자는 그대로(견주기용으로 맞춰) 둔다 */ +export function nameInitials(name: string): string { + let initials = ''; + for (const char of foldText(name)) { + if (WHITESPACE.test(char)) continue; + const code = char.codePointAt(0) ?? 0; + initials += + code >= SYLLABLE_START && code <= SYLLABLE_END + ? (INITIALS[ + Math.floor((code - SYLLABLE_START) / SYLLABLES_PER_INITIAL) + ] ?? char) + : char; + } + return initials; +} + +/** 해석한 찾는 글 — 초성으로 찾기 · 글로 찾기(번호처럼 생겼으면 그 숫자도) */ +export type ContactSearchQuery = + | { readonly kind: 'initials'; readonly initials: string } + | { + readonly kind: 'text'; + readonly text: string; + /** 번호처럼 생긴 글이면 그 숫자 — 아니면 null */ + readonly digits: string | null; + }; + +/** 찾는 글을 해석한다 — 앞뒤 공백을 빼고 비면 null(거르지 않는다) */ +export function parseContactSearch( + query: string | null | undefined, +): ContactSearchQuery | null { + const trimmed = query?.trim() ?? ''; + if (trimmed === '') return null; + + const letters = Array.from(trimmed).filter((char) => !WHITESPACE.test(char)); + if (letters.every((char) => CONSONANT_JAMO.test(char))) { + return { + kind: 'initials', + initials: letters.map((char) => SPLIT_CONSONANTS[char] ?? char).join(''), + }; + } + + const digits = trimmed.replace(NON_DIGITS, ''); + return { + kind: 'text', + text: foldText(trimmed), + digits: PHONE_LIKE.test(trimmed) && digits !== '' ? digits : null, + }; +} + +/** 연락처 하나의 찾기 자료 — 주소록을 읽을 때 한 번 만들어 둔다 (address-book.ts) */ +export interface ContactSearchIndex { + /** 이름의 초성 — 이름이 없으면 빈 글 */ + readonly initials: string; + /** 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호 (견주기용) */ + readonly texts: readonly string[]; + /** 전화번호의 숫자 */ + readonly phoneDigits: readonly string[]; +} + +export function contactSearchIndex(contact: Contact): ContactSearchIndex { + const texts = [ + contact.name, + ...contact.emails.map((email) => email.address), + contact.company, + contact.department, + contact.title, + ...contact.phones.map((phone) => phone.number), + ] + .filter((text): text is string => text !== null && text !== '') + .map(foldText); + return { + initials: contact.name === null ? '' : nameInitials(contact.name), + texts, + phoneDigits: contact.phones + .map((phone) => phone.number.replace(NON_DIGITS, '')) + .filter((digits) => digits !== ''), + }; +} + +export function matchesContact( + index: ContactSearchIndex, + query: ContactSearchQuery, +): boolean { + if (query.kind === 'initials') return index.initials.includes(query.initials); + if (index.texts.some((text) => text.includes(query.text))) return true; + const { digits } = query; + return ( + digits !== null && index.phoneDigits.some((phone) => phone.includes(digits)) + ); +} + +/** 사내 목록 한 줄이 맞는가 — 이름 · 주소로 같은 규칙 */ +export function matchesDirectoryEntry( + entry: DirectoryEntry, + query: ContactSearchQuery, +): boolean { + if (query.kind === 'initials') { + return nameInitials(entry.name).includes(query.initials); + } + return ( + foldText(entry.name).includes(query.text) || + foldText(entry.email).includes(query.text) + ); +} + +/** 한국어 사전 차례 (계약 3절 "차례") */ +const collator = new Intl.Collator('ko'); + +/** 둘 다 있으면 사전 차례, 하나만 없으면 없는 쪽이 뒤 */ +function compareOptional(a: string | null, b: string | null): number { + if (a === null || b === null) { + return a === b ? 0 : a === null ? 1 : -1; + } + return collator.compare(a, b); +} + +function compareIds(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * 연락처의 차례 — 이름(없으면 대표 주소)으로 견주고, 같으면 대표 주소 → id. + * 이름도 주소도 없는 연락처(다른 프로그램이 전화번호만 넣은 카드 등)는 맨 뒤 + */ +export function compareContacts( + a: Pick, + b: Pick, +): number { + const primaryA = a.emails[0]?.address ?? null; + const primaryB = b.emails[0]?.address ?? null; + return ( + compareOptional(a.name ?? primaryA, b.name ?? primaryB) || + compareOptional(primaryA, primaryB) || + compareIds(a.id, b.id) + ); +} + +/** 그룹의 차례 — 이름 → id */ +export function compareGroups( + a: { readonly id: string; readonly name: string }, + b: { readonly id: string; readonly name: string }, +): number { + return collator.compare(a.name, b.name) || compareIds(a.id, b.id); +} + +/** 사내 목록의 차례 — 이름 → 주소 → id */ +export function compareDirectoryEntries( + a: DirectoryEntry, + b: DirectoryEntry, +): number { + return ( + collator.compare(a.name, b.name) || + collator.compare(a.email, b.email) || + compareIds(a.id, b.id) + ); +} diff --git a/backend/src/modules/contacts/contacts.constants.spec.ts b/backend/src/modules/contacts/contacts.constants.spec.ts new file mode 100644 index 0000000..c247ee2 --- /dev/null +++ b/backend/src/modules/contacts/contacts.constants.spec.ts @@ -0,0 +1,85 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { ERROR_CODES } from '@/common/errors/error-codes'; +import { + CONTACT_NOTE_PATTERN, + CONTACT_PHONE_DIGIT, + CONTACT_PHONE_PATTERN, + CONTACT_RESTORE_MAX_CARD_BYTES, + CONTACT_RESTORE_MAX_PER_USER, + CONTACT_RESTORE_MAX_TOTAL_BYTES, + MAX_ADDRESS_BOOK_WEIGHT, + CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_GROUPS, + MAX_CONTACTS, +} from './contacts.constants'; + +const isPhone = (number: string) => + CONTACT_PHONE_PATTERN.test(number) && CONTACT_PHONE_DIGIT.test(number); + +describe('주소록 상수', () => { + it('CONTACT_002 문구의 숫자가 한도와 같다 — 연락처 · 그룹 · 그룹 하나의 구성원', () => { + const { message } = ERROR_CODES.CONTACT_002; + expect(message).toContain( + `연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개`, + ); + expect(message).toContain(`그룹 ${MAX_CONTACT_GROUPS}개`); + expect(message).toContain(`${MAX_CONTACT_GROUP_MEMBERS}명`); + }); + + it('전화번호 — 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만', () => { + for (const number of [ + '010-3192-5540', + '+82 10 3192 5540', + '(02) 333.4444', + '1588-0000#2', + '*77', + '7', + ]) { + expect(isPhone(number)).toBe(true); + } + for (const number of [ + '', + ' ', + '+-()', + '#*', + 'tel:010-1234-5678', + '010-1234-5678 내선', + '010\t1234', + '010', + ]) { + expect(isPhone(number)).toBe(false); + } + }); + + it('전화번호 정규식은 길이에 비례한다 — 숫자 100만 개 뒤에 틀린 글자 하나도 바로 (CPU 시간)', () => { + // 한 정규식(`^[…]*[0-9][…]*$`)이던 때는 두 * 가 숫자를 나눠 가져 길이의 제곱 — 2만 자에 0.5초, 100만 자면 수십 분 (2026-10-29 리뷰 H1) + const digits = '1'.repeat(1_000_000); + const cpuMs = startCpuTimer(); + expect(isPhone(`${digits}x`)).toBe(false); + expect(isPhone(`x${digits}`)).toBe(false); + expect(isPhone(` ${'-'.repeat(1_000_000)}`)).toBe(false); + expect(isPhone(digits)).toBe(true); + // 지금 수 ms — 한도 1초는 부하를 견디고 옛 정규식(2만 자 0.5초 · 100만 자 수십 분)은 확실히 넘는다 + expect(cpuMs()).toBeLessThan(1_000); + }); + + it('메모 — 줄바꿈(\\n)만 되고 탭 · \\r · 그 밖의 제어 문자는 안 된다', () => { + expect(CONTACT_NOTE_PATTERN.test('첫 줄\n둘째 줄')).toBe(true); + for (const note of ['a\tb', 'a\r\nb', 'a\u0000', 'a\u007f', 'a\u0085']) { + expect(CONTACT_NOTE_PATTERN.test(note)).toBe(false); + } + }); + + it('크기 — 한 사람의 쓰기 한도(800만)는 캐시 전체(2,400만)보다 작다 (한도 안의 주소록은 늘 기억할 수 있다)', () => { + expect(MAX_ADDRESS_BOOK_WEIGHT).toBeLessThan( + CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT, + ); + }); + + it('되돌리기 — 한 직원이 표를 다 채워도(20개 × 256KB) 전체 상한 안이다', () => { + expect( + CONTACT_RESTORE_MAX_PER_USER * CONTACT_RESTORE_MAX_CARD_BYTES, + ).toBeLessThanOrEqual(CONTACT_RESTORE_MAX_TOTAL_BYTES); + }); +}); diff --git a/backend/src/modules/contacts/contacts.constants.ts b/backend/src/modules/contacts/contacts.constants.ts new file mode 100644 index 0000000..5407182 --- /dev/null +++ b/backend/src/modules/contacts/contacts.constants.ts @@ -0,0 +1,106 @@ +import { + MAIL_ID_PATTERN, + MAX_EMAIL_LENGTH, +} from '@/modules/mail/mail.constants'; + +/** + * 주소록 API 한도 · 형식 상수 — 계약(docs/mail/api-contract.md 3절 "주소록")의 숫자를 한 곳에 모은다. + * 글자 수는 앞뒤 공백을 뺀 값의 JS 문자열 길이(UTF-16 코드 단위 — 웹의 `.length` 와 같다)로 센다. + */ + +/** 라우트 경로 (`/api/contacts` · `/api/contact-groups` · `/api/contact-restorations` · `/api/directory-entries`) */ +export const CONTACTS_ROUTE = 'contacts'; +export const CONTACT_GROUPS_ROUTE = 'contact-groups'; +export const CONTACT_RESTORATIONS_ROUTE = 'contact-restorations'; +export const DIRECTORY_ENTRIES_ROUTE = 'directory-entries'; + +/** + * 주소록 한도 — 넘으면 409 CONTACT_002 (아무것도 바꾸지 않는다). + * ⚠️ CONTACT_002 의 문구(common/errors/error-codes.ts)에 같은 숫자가 있다 — contacts.constants.spec.ts 가 맞는지 본다 + */ +export const MAX_CONTACTS = 3_000; +export const MAX_CONTACT_GROUPS = 100; +export const MAX_CONTACT_GROUP_MEMBERS = 500; + +/** + * 주소록 크기 한도 — 서버가 재는 크기(address-book.ts 의 weight — 우리가 다루는 칸의 글자 수 + 항목마다 조금)를 이보다 크게 만드는 쓰기 + * (만들기 · 고치기 · 구성원 넣기 · 되돌리기)는 409 CONTACT_002. 줄이는 쓰기와 지우기는 늘 된다 (계약 3절 "크기"). + * 한 사람분 캐시의 상한과 같은 잣대다 — 이 안의 주소록은 늘 통째로 기억할 수 있다. + * 연락처 3,000개에 그룹 100개를 구성원 500명씩 채워도 연락처마다 1,000자 넘게 남는다 (2026-10-29 리뷰: 칸마다 최대인 연락처 3,000장은 2,400만이었다) + */ +export const MAX_ADDRESS_BOOK_WEIGHT = 8_000_000; + +/** 연락처 칸의 길이 — 이름 · 회사 · 부서 · 직책 100자, 메모 1,000자, 주소 254자, 번호 40자 */ +export const MAX_CONTACT_TEXT_LENGTH = 100; +export const MAX_CONTACT_NOTE_LENGTH = 1_000; +export const MAX_CONTACT_EMAIL_LENGTH = MAX_EMAIL_LENGTH; +export const MAX_CONTACT_PHONE_LENGTH = 40; + +/** 한 연락처의 이메일 · 전화번호 수 — 화면은 5개까지 더한다(다른 프로그램이 넣은 것은 그대로 보이고 저장된다) */ +export const MAX_CONTACT_EMAILS = 20; +export const MAX_CONTACT_PHONES = 20; + +/** 한 연락처가 속하는 그룹 수 (본문의 groupIds) */ +export const MAX_CONTACT_GROUP_IDS = 100; + +/** 그룹 이름 1~50자 */ +export const MAX_CONTACT_GROUP_NAME_LENGTH = 50; + +/** 구성원 넣기 한 번에 contactIds + directoryIds 합쳐 1~100개 */ +export const MAX_GROUP_MEMBERS_PER_REQUEST = 100; + +/** 찾는 글(q) 최대 길이 — 연락처 · 사내 목록. 메일 주소 하나(254자)를 통째로 찾을 수 있게 (읽기 화면 ⋯ 의 "주소록에 있는가") */ +export const MAX_CONTACT_QUERY_LENGTH = MAX_EMAIL_LENGTH; + +/** 연락처 · 그룹 · 사내 목록 id — 엔진의 객체 id(JMAP Id: URL-safe base64 문자 1~255자)와 같은 형식이다 */ +export const CONTACT_ID_PATTERN = MAIL_ID_PATTERN; + +/** + * 전화번호 — 숫자 · 띄어쓰기 · + - ( ) . # * 만(CONTACT_PHONE_PATTERN)이고 숫자가 하나 이상(CONTACT_PHONE_DIGIT). + * 두 정규식으로 나눠 둔다 — 한 정규식(`^[…]*[0-9][…]*$`)으로 쓰면 두 `*` 가 숫자를 나눠 가져, 맞지 않는 긴 글에서 길이의 제곱으로 느려진다 + * (2026-10-29 리뷰 — 4만 자 한 칸에 CPU 1.7초, 100만 자면 수십 분). 둘 다 길이에 비례한다. 웹(frontend shared/utils/contacts.ts 의 + * isValidContactPhone)도 같은 두 검사다 + */ +export const CONTACT_PHONE_PATTERN = /^[0-9 +\-().#*]+$/; +export const CONTACT_PHONE_DIGIT = /[0-9]/; + +/** 메모 — 줄바꿈(\n)은 되고 그 밖의 제어 문자(탭 · \r 포함 C0 · DEL · C1)는 안 된다 */ +export const CONTACT_NOTE_PATTERN = + // eslint-disable-next-line no-control-regex + /^[^\u0000-\u0009\u000b-\u001f\u007f-\u009f]*$/; + +/** + * 직원마다 기억하는 주소록 (계약 3절 "캐시") — 요청마다 엔진에 판(상태)만 묻고, 그대로면 다시 읽지 않는다. + * - 사람 수 200명 · 모두 합쳐 크기 2,400만(대략 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다) + * - 한 사람은 크기 한도(MAX_ADDRESS_BOOK_WEIGHT — 800만)까지 — 그보다 큰 주소록(다른 프로그램이 크게 만든 것)은 기억하지 않고 요청마다 다시 읽는다 + * - 30분 동안 쓰지 않으면 잊는다 (기억할 때 그때그때 지운다) + */ +export const CONTACT_BOOK_CACHE_MAX_USERS = 200; +export const CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT = 24_000_000; +export const CONTACT_BOOK_CACHE_IDLE_MS = 30 * 60_000; + +/** + * 판이 그대로일 때만 쓰는 횟수 — 그사이 다른 곳(다른 탭 · 메일 프로그램)에서 바뀌거나(stateMismatch) 엔진이 같은 카드의 동시 고치기를 + * 거절하면(serverUnavailable) 다시 읽고 다시 한다. 넘으면 MAIL_001 + */ +export const CONTACT_WRITE_ATTEMPTS = 3; + +/** + * 되돌리기 표 (계약 3절 "되돌리기") — 지운 연락처 · 그룹의 엔진 카드 전체를 직원마다 기억한다(서버 메모리). + * - 5분 · 직원마다 최근 20개 · 카드 하나가 256KB(JSON, UTF-8)를 넘으면 기억하지 않는다(표 null) + * - 모두 합쳐 32MB 를 넘으면 가장 오래된 표부터 잊는다 — 사진이 든 큰 카드를 여러 직원이 한꺼번에 지워도 메모리가 끝없이 늘지 않게 + * ⚠️ 서버를 여러 대로 늘리면 표도 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404 RES_001) + */ +export const CONTACT_RESTORE_TTL_MS = 5 * 60_000; +export const CONTACT_RESTORE_MAX_PER_USER = 20; +export const CONTACT_RESTORE_MAX_CARD_BYTES = 256 * 1024; +export const CONTACT_RESTORE_MAX_TOTAL_BYTES = 32 * 1024 * 1024; +/** 표 — 무작위 128비트를 base64url(채움 없이)로 쓴 22자 */ +export const CONTACT_RESTORE_TOKEN_BYTES = 16; +export const CONTACT_RESTORE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{22}$/; + +/** 사내 목록을 기억하는 시간 — 모든 직원이 함께 쓴다 (계약 3절 "사내 목록") */ +export const DIRECTORY_CACHE_TTL_MS = 10 * 60_000; + +/** 사내 목록을 읽지 못한 일을 error 로그로 다시 남기는 간격 — 계정이 없는 동안 요청마다 남기지 않게 */ +export const DIRECTORY_FAILURE_LOG_INTERVAL_MS = 10 * 60_000; diff --git a/backend/src/modules/contacts/contacts.controller.ts b/backend/src/modules/contacts/contacts.controller.ts new file mode 100644 index 0000000..b4a5d6a --- /dev/null +++ b/backend/src/modules/contacts/contacts.controller.ts @@ -0,0 +1,172 @@ +import { + Body, + Controller, + Delete, + Get, + Header, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { ContactIdPipe } from './contact-id.pipe'; +import { + CONTACTS_ROUTE, + MAX_CONTACT_EMAILS, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_NOTE_LENGTH, + MAX_CONTACT_PHONES, + MAX_CONTACT_QUERY_LENGTH, + MAX_CONTACTS, + MAX_CONTACT_TEXT_LENGTH, +} from './contacts.constants'; +import { ContactsService } from './contacts.service'; +import { + ContactDeletedDto, + ContactDto, + ContactInputDto, + ContactListQueryDto, + ContactPageDto, + ContactPatchDto, +} from './dto/contact.dto'; + +/** 개인 정보라 공유 캐시 · 브라우저 캐시 어디에도 남기지 않는다 (받는 사람 추천과 같다) */ +const NO_STORE = 'private, no-store'; + +const INPUT_RULES = + `이름이나 이메일 가운데 하나는 있어야 한다. 이름 · 회사 · 부서 · 직책 ${MAX_CONTACT_TEXT_LENGTH}자 · 메모 ${MAX_CONTACT_NOTE_LENGTH.toLocaleString('en-US')}자(줄바꿈만 되는 제어 문자) · ` + + `이메일 ${MAX_CONTACT_EMAILS}개(254자 · 메일 주소 형식 · 같은 연락처 안에서 겹치지 않게 — 대소문자 무시) · 전화번호 ${MAX_CONTACT_PHONES}개(40자 · 숫자와 띄어쓰기 · + - ( ) . # *). ` + + '글은 앞뒤 공백을 지워 저장한다(빈 글은 null).'; + +const SIZE_RULE = + '주소록 크기(우리가 다루는 칸의 글자 수 + 항목마다 조금)를 800만 넘게 하는 쓰기는 409 CONTACT_002 — 줄이는 쓰기 · 지우기는 늘 된다.'; + +// 내 주소록 — 연락처 (계약 3절 "주소록", 8단계). 엔진의 기본 주소록 하나를 그 직원 본인으로 읽고 쓴다 +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller(CONTACTS_ROUTE) +export class ContactsController { + constructor(private readonly contacts: ContactsService) {} + + @Get() + @Header('Cache-Control', NO_STORE) + @ApiOperation({ + summary: '연락처 목록 (커서 페이지네이션 · total 포함)', + description: + '이름 차례(한국어 사전 차례 — 이름이 없으면 대표 주소, 같으면 대표 주소 → id). q 는 초성만 친 글이면 이름의 초성과(ㅂㅈㅎ → 박지훈), ' + + '그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 든 것(ASCII 대소문자 무시 — 전화번호는 숫자만 견준다: 3192 가 010-3192-5540 에 맞는다). ' + + 'groupId 면 그 그룹의 구성원만, excludeGroupId 면 그 그룹의 구성원을 빼고(둘을 함께 쓰면 400 VAL_001). ' + + '커서는 받은 그 q · groupId · excludeGroupId 로만 쓴다. 차례 사이에 연락처가 바뀌면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다. ' + + '서버는 주소록을 엔진의 상태와 함께 기억하고 요청마다 상태만 확인한다. Cache-Control: private, no-store.', + }) + @ApiStandardResponse(ContactPageDto, { description: '연락처 목록' }) + @ApiErrorResponse( + 'VAL_001', + `q ${MAX_CONTACT_QUERY_LENGTH}자 초과 · groupId 와 excludeGroupId 를 함께 · 잘못된 커서 · 다른 q · 그룹의 커서 · limit 범위 밖 · 모르는 쿼리`, + ) + @ApiErrorResponse('RES_001', '없는 groupId · excludeGroupId') + list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: ContactListQueryDto, + ) { + return this.contacts.list(user, query); + } + + @Get(':id') + @Header('Cache-Control', NO_STORE) + @ApiOperation({ summary: '연락처 하나' }) + @ApiStandardResponse(ContactDto, { description: '연락처' }) + @ApiErrorResponse('RES_001', '없는 연락처 (형식이 틀린 id 포함)') + get( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + ) { + return this.contacts.get(user, id); + } + + @Post() + @ApiOperation({ + summary: '연락처 추가', + description: + `${INPUT_RULES} groupIds 의 그룹에도 함께 넣는다. 연락처는 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 하나에 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명까지. ` + + `${SIZE_RULE} 엔진 카드는 JSContact(kind individual) — 서버가 uid(urn:uuid)를 넣는다.`, + }) + @ApiStandardResponse(ContactDto, { + status: 201, + description: '만든 연락처', + }) + @ApiErrorResponse( + 'VAL_001', + '형식 · 길이 · 개수 · 이름도 이메일도 없음 · 같은 주소가 두 번 · 모르는 필드', + ) + @ApiErrorResponse('RES_001', '없는 groupId') + @ApiErrorResponse( + 'CONTACT_002', + `연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 크기 한도 — 아무것도 바꾸지 않았다`, + ) + create(@CurrentUser() user: AuthenticatedUser, @Body() dto: ContactInputDto) { + return this.contacts.create(user, dto); + } + + @Patch(':id') + @ApiOperation({ + summary: '연락처 고치기 (바꾼 칸만)', + description: + '보낸 칸만 바꾼다(하나 이상 — 비면 400 VAL_001). 보낸 칸만 검사하고 "이름이나 이메일"은 지금 연락처와 합친 결과로 본다. ' + + 'emails · phones · groupIds 는 보내면 통째로 바뀐다. 엔진 카드의 다른 칸(주소 · 생일 · 사진 · 다른 프로그램의 칸)과 칸 안의 남의 정보' + + '(같은 주소 · 번호 항목의 label · 다른 contexts · fax 같은 features, role 직책, 다른 조직 · 부서)는 그대로 둔다 — 종류를 바꾼 것만 우리 값으로. ' + + '이름을 고치면 이름 조각(components)은 지운다. groupIds 로 그룹에 넣고 뺀다 — 다른 프로그램이 uid 없이 만든 카드는 그룹에 넣을 수 없다(400 VAL_001). ' + + `같은 칸을 양쪽에서 바꿨으면 나중에 저장한 쪽이 이긴다(판을 묻지 않는다). ${INPUT_RULES} ${SIZE_RULE}`, + }) + @ApiStandardResponse(ContactDto, { description: '고친 연락처' }) + @ApiErrorResponse( + 'VAL_001', + '빈 본문 · 형식 · 길이 · 개수 · 합친 결과에 이름도 이메일도 없음 · 같은 주소가 두 번 · uid 없는 카드를 그룹에 넣음 · 모르는 필드', + ) + @ApiErrorResponse('RES_001', '없는 연락처 · 없는 groupId') + @ApiErrorResponse( + 'CONTACT_002', + `그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 크기 한도 — 아무것도 바꾸지 않았다`, + ) + patch( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + @Body() dto: ContactPatchDto, + ) { + return this.contacts.patch(user, id, dto); + } + + @Delete(':id') + @ApiOperation({ + summary: '연락처 지우기 (영구 삭제)', + description: + '엔진 주소록에는 휴지통이 없다. 그 연락처를 가리키는 그룹 구성원도 함께 뺀다. ' + + '응답은 지운 연락처와 되돌리기 표(restoreToken — POST /api/contact-restorations 로 5분 안에 한 번, 원본 그대로 다시 만든다. ' + + '서버가 기억하지 못했으면 null).', + }) + @ApiStandardResponse(ContactDeletedDto, { + description: '지운 연락처와 되돌리기 표', + }) + @ApiErrorResponse('RES_001', '없는 연락처') + remove( + @CurrentUser() user: AuthenticatedUser, + @Param('id', ContactIdPipe) id: string, + ) { + return this.contacts.remove(user, id); + } +} diff --git a/backend/src/modules/contacts/contacts.module.ts b/backend/src/modules/contacts/contacts.module.ts new file mode 100644 index 0000000..f6c3ff8 --- /dev/null +++ b/backend/src/modules/contacts/contacts.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; + +import { JmapContactsProvider } from '@/modules/mail/jmap/jmap-contacts.provider'; +import { JmapDirectoryProvider } from '@/modules/mail/jmap/jmap-directory.provider'; +import { MailEngineModule } from '@/modules/mail/mail-engine.module'; +import { ContactGroupsController } from './contact-groups.controller'; +import { ContactRestorationsController } from './contact-restorations.controller'; +import { ContactsController } from './contacts.controller'; +import { CONTACTS_PROVIDER, DIRECTORY_PROVIDER } from './contacts.provider'; +import { ContactsService } from './contacts.service'; +import { DirectoryEntriesController } from './directory-entries.controller'; +import { DirectoryService } from './directory.service'; + +/** + * 주소록 모듈 (계약 3절 "주소록" — 8단계) — 내 주소록(연락처 · 그룹)과 사내 목록. + * 엔진을 바꿀 때는 `CONTACTS_PROVIDER` · `DIRECTORY_PROVIDER` 의 구현(useClass)만 바꾼다 — 서비스 · 컨트롤러는 포트만 안다. + * 지금 구현은 Stalwart(JMAP) — 메일과 같은 엔진 연결(MailEngineModule)을 쓰고, 어댑터는 `modules/mail/jmap/` 에 있다. + * + * 메일 모듈이 받는 사람 추천에 쓰도록 서비스를 내보낸다 — 이 모듈은 메일 모듈을 가져오지 않는다 (순환 없음) + */ +@Module({ + imports: [MailEngineModule], + controllers: [ + ContactsController, + ContactGroupsController, + ContactRestorationsController, + DirectoryEntriesController, + ], + providers: [ + { provide: CONTACTS_PROVIDER, useClass: JmapContactsProvider }, + { provide: DIRECTORY_PROVIDER, useClass: JmapDirectoryProvider }, + ContactsService, + DirectoryService, + ], + exports: [ContactsService, DirectoryService], +}) +export class ContactsModule {} diff --git a/backend/src/modules/contacts/contacts.provider.ts b/backend/src/modules/contacts/contacts.provider.ts new file mode 100644 index 0000000..295a5c2 --- /dev/null +++ b/backend/src/modules/contacts/contacts.provider.ts @@ -0,0 +1,173 @@ +import type { ContactFields, DirectoryEntryKind } from './contacts.types'; + +/** + * 주소록 · 사내 목록 엔진 포트 — 서비스 계층은 이 인터페이스만 안다 (메일 엔진 포트 mail.provider.ts 와 같은 규칙). + * + * 지금 구현은 Stalwart(JMAP) — 주소록은 `modules/mail/jmap/jmap-contacts.provider.ts`, 사내 목록은 `jmap-directory.provider.ts`. + * 엔진을 바꿀 때는 이 두 인터페이스의 새 구현을 contacts.module.ts 에 연결하면 된다. + * + * 규칙: + * - 우리 도메인 타입만 받고 돌려준다 (엔진 타입 · 카드 형식을 밖으로 흘리지 않는다) + * - 실패는 BusinessException(에러 코드)으로 던진다 — 엔진 장애 MAIL_001 등 + * - `user` 는 인증된 직원 주소다. 어댑터는 그 직원의 주소록에만 접근한다 + * - 주소록은 엔진의 **기본 주소록** 하나만 쓴다 (계약 3절) + */ +export const CONTACTS_PROVIDER = Symbol('CONTACTS_PROVIDER'); +export const DIRECTORY_PROVIDER = Symbol('DIRECTORY_PROVIDER'); + +/** 주소록의 연락처 카드 한 장 (그룹 아님) — 엔진 카드에서 우리가 다루는 칸만 */ +export interface AddressBookContactCard extends ContactFields { + readonly id: string; + /** + * 카드의 uid — 그룹 구성원이 이 값으로 잇는다. 다른 프로그램이 uid 없이 만든 카드에는 없다(null) — + * 엔진이 만든 뒤에는 uid 를 바꾸지 못하게 해서(2026-10-29 확인) 그런 카드는 그룹에 넣을 수 없다 + */ + readonly uid: string | null; +} + +/** 주소록의 그룹 카드 */ +export interface AddressBookGroupCard { + readonly id: string; + readonly uid: string | null; + /** 그룹 이름 — 없으면 빈 글 */ + readonly name: string; + /** 구성원 카드의 uid — 이 주소록에 없는 카드(지워짐 · 다른 주소록)를 가리키는 것도 그대로 둔다 */ + readonly memberUids: readonly string[]; +} + +/** 주소록의 카드 하나 — 연락처 또는 그룹 */ +export type AddressBookCard = + | { readonly kind: 'contact'; readonly card: AddressBookContactCard } + | { readonly kind: 'group'; readonly card: AddressBookGroupCard }; + +/** 주소록 전체 — 그 내용의 판(version)과 함께 */ +export interface AddressBookSnapshot { + /** 판 — 불투명 문자열. 엔진에서 주소록이 무엇이든 바뀌면 달라진다 (writeAddressBook 에 그대로 돌려준다) */ + readonly version: string; + readonly contacts: readonly AddressBookContactCard[]; + readonly groups: readonly AddressBookGroupCard[]; +} + +/** + * 지운 카드의 원본 — 엔진 카드 전체(다른 프로그램의 칸 · uid 포함)를 어댑터가 글로 쓴 것. + * 서비스는 들여다보지 않고 기억했다가 되돌리기(NewAddressBookCard 의 restored)에 그대로 돌려준다 (계약 3절 "되돌리기") + */ +export interface RemovedCardOriginal { + /** 엔진 카드 전체 — 어댑터만 읽는다 */ + readonly source: string; + /** source 의 크기(UTF-8 바이트) — 되돌리기 표에 기억할지 정한다(256KB) */ + readonly bytes: number; +} + +/** 새로 만들 카드 — key 는 이 바꾸기 안의 이름(결과의 createdIds 의 키), uid 는 서비스가 정한다 (그룹이 같은 바꾸기 안에서 가리킨다) */ +export type NewAddressBookCard = + | { + readonly kind: 'contact'; + readonly key: string; + readonly uid: string; + readonly fields: ContactFields; + } + | { + readonly kind: 'group'; + readonly key: string; + readonly uid: string; + readonly name: string; + readonly memberUids: readonly string[]; + } + | { + /** 지운 카드를 원본 그대로 다시 만든다 — uid · 주소록만 바꾸고(그룹은 구성원도) 나머지 칸은 원본 그대로 */ + readonly kind: 'restored'; + readonly key: string; + readonly uid: string; + readonly original: RemovedCardOriginal; + /** 그룹이면 다시 넣을 구성원 uid(원본의 구성원 대신), 연락처면 null */ + readonly memberUids: readonly string[] | null; + /** 원본의 우리 칸 (지울 때 읽은 모양) — 서비스가 크기를 미리 잴 때 쓴다 */ + readonly card: AddressBookCard; + }; + +/** + * 고칠 카드 — 바꾸는 칸만(undefined 는 그대로). 연락처는 칸 안의 남의 정보를 지킨다 — 어댑터가 지금 카드와 합친다(계약 3절 "고치기"). + * 그룹의 구성원은 통째로 바꾼다 — 엔진은 구성원이 없는 그룹에 한 사람만 더하는 패치를 받지 않는다(2026-10-29 확인) + */ +export type AddressBookCardUpdate = + | { + readonly kind: 'contact'; + readonly id: string; + readonly fields: Partial; + } + | { + readonly kind: 'group'; + readonly id: string; + readonly name?: string; + readonly memberUids?: readonly string[]; + }; + +/** 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다) */ +export interface AddressBookChanges { + readonly create: readonly NewAddressBookCard[]; + readonly update: readonly AddressBookCardUpdate[]; + readonly destroy: readonly string[]; +} + +/** + * 바꾸기의 결과 + * - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다) · + * 만들거나 고친 연락처 · 만든 그룹의 지금 모양(id → 카드 — 엔진에 보낸 카드를 읽은 것, 그룹 고치기는 넣지 않는다) · 지운 카드의 원본(id → 원본) + * - conflict: 받은 판과 엔진의 지금 판이 달라 아무것도 하지 않았다 (그사이 다른 곳에서 바뀌었다 — 다시 읽고 다시 한다) + * - busy: 엔진이 같은 카드의 동시 고치기를 거절했다(serverUnavailable) — 일부가 들어갔을 수 있다. 다시 읽고 다시 한다 + * (서비스의 계획은 앞 시도가 들어갔는지 보고 이어 한다 — 지운 카드의 원본은 함께 준다) + */ +export type AddressBookWriteOutcome = + | { + readonly kind: 'written'; + readonly version: string | null; + readonly createdIds: ReadonlyMap; + readonly cards: ReadonlyMap; + readonly removed: ReadonlyMap; + } + | { readonly kind: 'conflict' } + | { + readonly kind: 'busy'; + readonly removed: ReadonlyMap; + }; + +export interface ContactsProvider { + /** 지금 주소록의 판 — 카드를 읽지 않는 가벼운 조회. readAddressBook 의 version 과 견준다 */ + getAddressBookVersion(user: string): Promise; + + /** 주소록 전체 (기본 주소록의 연락처 · 그룹) — 엔진에 주소록이 없으면 빈 주소록 */ + readAddressBook(user: string): Promise; + + /** + * 바꾸기를 한 번에 — 받은 판(version)이 엔진의 지금 판과 같을 때만 한다(아니면 conflict, 아무것도 하지 않음). + * 고칠 연락처 · 지울 카드는 그 판 그대로 먼저 읽는다(칸 안의 남의 정보를 지키고 · 되돌리기 원본을 남기려고 — 그사이 바뀌었으면 conflict). + * 엔진이 동시 고치기를 거절하면 busy. 엔진이 일부를 받지 않으면 그 코드로 던진다(엔진 오류 종류에 따라 VAL_001 · MAIL_001 등 — + * 받은 것은 이미 바뀌었을 수 있다). 주소록이 없는데 만들 카드가 있으면 MAIL_001 + */ + writeAddressBook( + user: string, + version: string, + changes: AddressBookChanges, + ): Promise; +} + +/** 엔진의 계정 하나 — 사내 목록이 될 수 있는 것(개인 · 그룹 계정)만 */ +export interface DirectoryAccount { + /** 엔진의 계정 id */ + readonly id: string; + /** 개인 계정은 직원(person), 그룹 계정은 공용 주소(shared) */ + readonly kind: DirectoryEntryKind; + /** 엔진의 표시 이름(계정의 설명 — 관리 화면에서 넣는 이름) — 없으면 null */ + readonly displayName: string | null; + /** 계정의 주소 — 없으면 null */ + readonly email: string | null; +} + +export interface DirectoryProvider { + /** + * 엔진의 계정 목록 — 서버만 쓰는 사내 목록 계정으로 읽는다 (계약 3절 "사내 목록"). + * 그 계정이 없거나 읽을 권한이 없으면 · 엔진 장애면 던진다 (MAIL_001 등). 걸러 내기(관리자 · 게이트웨이 · 막은 계정)는 서비스가 한다 + */ + listAccounts(): Promise; +} diff --git a/backend/src/modules/contacts/contacts.service.spec.ts b/backend/src/modules/contacts/contacts.service.spec.ts new file mode 100644 index 0000000..3c65f50 --- /dev/null +++ b/backend/src/modules/contacts/contacts.service.spec.ts @@ -0,0 +1,1349 @@ +import { Logger } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { BusinessException } from '@/common/errors/business.exception'; +import { cardWeight } from './address-book'; +import { + CONTACT_RESTORE_MAX_CARD_BYTES, + CONTACT_RESTORE_TTL_MS, + MAX_ADDRESS_BOOK_WEIGHT, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_GROUPS, + MAX_CONTACTS, +} from './contacts.constants'; +import type { + AddressBookCard, + AddressBookChanges, + AddressBookContactCard, + AddressBookGroupCard, + AddressBookSnapshot, + AddressBookWriteOutcome, + ContactsProvider, + RemovedCardOriginal, +} from './contacts.provider'; +import { ContactsService } from './contacts.service'; +import type { + ContactFields, + ContactInput, + DirectoryEntry, +} from './contacts.types'; +import type { DirectoryService } from './directory.service'; +import type { ContactListQueryDto } from './dto/contact.dto'; +import type { DirectoryEntryQueryDto } from './dto/directory-entry.dto'; + +const ARA: AuthenticatedUser = { + email: 'Ara.Kim@arasystem.test', + name: '김아라', +}; +const DOYUN: AuthenticatedUser = { + email: 'doyun.lee@arasystem.test', + name: '이도윤', +}; + +/** + * 엔진 주소록 대역 — 판(state)이 같을 때만 쓰고(아니면 conflict), 쓸 때마다 판이 오른다. + * 서비스가 기억한 주소록에 옮긴 결과와 견주려고 바꾸기를 따로(서비스의 address-book.ts 를 쓰지 않고) 옮긴다. + * 직원마다 따로 두지 않는다 — 한 직원의 주소록만 흉내 낸다 + */ +class FakeAddressBook implements ContactsProvider { + state = 1; + readonly contacts = new Map(); + readonly groups = new Map(); + /** 우리가 다루지 않는 칸(다른 프로그램의 칸) — 지울 때 원본에 실린다 */ + readonly extras = new Map>(); + /** 되돌리기로 만든 카드가 받은 원본 */ + readonly restoredFrom = new Map(); + readonly writes: AddressBookChanges[] = []; + reads = 0; + /** 다음 쓰기들 앞에서 다른 곳이 먼저 바꾼다 (conflict) */ + outsideChanges = 0; + /** 다음 쓰기가 실패한다 (엔진이 일부를 받지 않음 등) */ + failure: BusinessException | null = null; + /** 다음 쓰기들이 busy — lost 는 아무것도 들어가지 않음, applied 는 들어간 뒤 busy (서비스는 결과를 모른다) */ + readonly busy: ('lost' | 'applied')[] = []; + private nextId = 1; + + readonly getAddressBookVersion = jest.fn(() => + Promise.resolve(`v${this.state}`), + ); + + readonly readAddressBook = jest.fn((): Promise => { + this.reads += 1; + return Promise.resolve(this.snapshot()); + }); + + readonly writeAddressBook = jest.fn( + ( + _user: string, + version: string, + changes: AddressBookChanges, + ): Promise => { + if (this.outsideChanges > 0) { + this.outsideChanges -= 1; + this.state += 1; + } + if (version !== `v${this.state}`) { + return Promise.resolve({ kind: 'conflict' }); + } + if (this.failure) { + const failure = this.failure; + this.failure = null; + this.state += 1; + return Promise.reject(failure); + } + const removed = new Map(); + for (const id of changes.destroy) { + const card = this.contacts.get(id) ?? this.groups.get(id); + if (card) removed.set(id, this.original(card)); + } + const mode = this.busy.shift(); + if (mode === 'lost') return Promise.resolve({ kind: 'busy', removed }); + const { createdIds, cards } = this.apply(changes); + if (mode === 'applied') { + return Promise.resolve({ kind: 'busy', removed }); + } + return Promise.resolve({ + kind: 'written', + version: `v${this.state}`, + createdIds, + cards, + removed, + }); + }, + ); + + snapshot(): AddressBookSnapshot { + return { + version: `v${this.state}`, + contacts: [...this.contacts.values()], + groups: [...this.groups.values()], + }; + } + + /** 다른 프로그램(CardDAV)이 바꾼다 */ + touch(): void { + this.state += 1; + } + + seedContact( + id: string, + overrides: Partial = {}, + ): AddressBookContactCard { + const card: AddressBookContactCard = { + id, + uid: `urn:uuid:${id}`, + name: id, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + ...overrides, + }; + this.contacts.set(id, card); + return card; + } + + seedGroup( + id: string, + name: string, + memberUids: string[] = [], + ): AddressBookGroupCard { + const card: AddressBookGroupCard = { + id, + uid: `urn:uuid:${id}`, + name, + memberUids, + }; + this.groups.set(id, card); + return card; + } + + private original(card: { id: string }): RemovedCardOriginal { + const source = JSON.stringify({ ...card, ...this.extras.get(card.id) }); + return { source, bytes: Buffer.byteLength(source, 'utf8') }; + } + + private apply(changes: AddressBookChanges): { + createdIds: Map; + cards: Map; + } { + this.writes.push(changes); + const createdIds = new Map(); + const cards = new Map(); + for (const card of changes.create) { + const id = `n${this.nextId++}`; + createdIds.set(card.key, id); + if (card.kind === 'contact') { + const stored = { id, uid: card.uid, ...card.fields }; + this.contacts.set(id, stored); + cards.set(id, { kind: 'contact', card: stored }); + } else if (card.kind === 'group') { + const stored = { + id, + uid: card.uid, + name: card.name, + memberUids: [...card.memberUids], + }; + this.groups.set(id, stored); + cards.set(id, { kind: 'group', card: stored }); + } else { + this.restoredFrom.set(id, card.original.source); + if (card.card.kind === 'contact') { + const stored = { ...card.card.card, id, uid: card.uid }; + this.contacts.set(id, stored); + cards.set(id, { kind: 'contact', card: stored }); + } else { + const stored = { + ...card.card.card, + id, + uid: card.uid, + memberUids: [...(card.memberUids ?? card.card.card.memberUids)], + }; + this.groups.set(id, stored); + cards.set(id, { kind: 'group', card: stored }); + } + } + } + for (const update of changes.update) { + if (update.kind === 'contact') { + const current = this.contacts.get(update.id); + if (current) { + const fields = Object.fromEntries( + Object.entries(update.fields).filter(([, v]) => v !== undefined), + ); + const stored = { ...current, ...fields }; + this.contacts.set(update.id, stored); + cards.set(update.id, { kind: 'contact', card: stored }); + } + } else { + const current = this.groups.get(update.id); + if (current) { + this.groups.set(update.id, { + ...current, + name: update.name ?? current.name, + memberUids: update.memberUids + ? [...update.memberUids] + : current.memberUids, + }); + } + } + } + for (const id of changes.destroy) { + this.contacts.delete(id); + this.groups.delete(id); + } + this.state += 1; + return { createdIds, cards }; + } +} + +const emptyFields: ContactFields = { + name: null, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, +}; + +const input = (overrides: Partial = {}): ContactInput => ({ + ...emptyFields, + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }], + groupIds: [], + ...overrides, +}); + +const listQuery = ( + overrides: Partial = {}, +): ContactListQueryDto => ({ limit: 20, ...overrides }); + +async function failureOf( + promise: Promise, +): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught, + ); + if (!(error instanceof BusinessException)) { + throw new Error(`BusinessException 이 아니다: ${String(error)}`); + } + return error; +} + +describe('ContactsService (주소록)', () => { + let book: FakeAddressBook; + let findByIds: jest.Mock, [readonly string[]]>; + let directoryList: jest.Mock; + let now: number; + let service: ContactsService; + let logSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + + const newService = () => + new ContactsService( + book, + { findByIds, list: directoryList } as unknown as DirectoryService, + () => now, + ); + + beforeEach(() => { + book = new FakeAddressBook(); + now = 1_800_000_000_000; + findByIds = jest + .fn, [readonly string[]]>() + .mockResolvedValue([]); + directoryList = jest.fn(); + service = newService(); + logSpy = jest + .spyOn(Logger.prototype, 'log') + .mockImplementation(() => undefined); + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('목록 · 하나 · 기억', () => { + beforeEach(() => { + book.seedContact('c-ha', { + name: '하늘', + phones: [{ number: '010-3192-5540', label: 'mobile' }], + }); + book.seedContact('c-pa', { + name: '박지훈', + company: '한빛상사', + emails: [{ address: 'Jihoon@Hanbit.example', label: 'work' }], + }); + book.seedContact('c-ga', { name: '가나다' }); + book.seedGroup('g1', '거래처', ['urn:uuid:c-pa', 'urn:uuid:gone']); + }); + + it('이름 차례 · total 포함 — 연락처의 그룹은 이 주소록의 그룹만', async () => { + const page = await service.list(ARA, listQuery()); + + expect(page.items.map((contact) => contact.id)).toEqual([ + 'c-ga', + 'c-pa', + 'c-ha', + ]); + expect(page.items[1]?.groupIds).toEqual(['g1']); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 3, + }); + }); + + it('q — 초성 · 회사 · 전화번호 숫자 / groupId — 그 그룹의 구성원만 / excludeGroupId — 그 그룹의 구성원을 빼고', async () => { + const ids = async (overrides: Partial) => + (await service.list(ARA, listQuery(overrides))).items.map((c) => c.id); + + expect(await ids({ q: 'ㅂㅈㅎ' })).toEqual(['c-pa']); + expect(await ids({ q: ' 한빛 ' })).toEqual(['c-pa']); + expect(await ids({ q: '31925540' })).toEqual(['c-ha']); + expect(await ids({ groupId: 'g1' })).toEqual(['c-pa']); + expect(await ids({ groupId: 'g1', q: '하늘' })).toEqual([]); + expect(await ids({ excludeGroupId: 'g1' })).toEqual(['c-ga', 'c-ha']); + expect(await ids({ excludeGroupId: 'g1', q: '하늘' })).toEqual(['c-ha']); + }); + + it('없는 그룹(groupId · excludeGroupId)은 RES_001 · 둘을 함께 쓰거나 다른 q · 그룹의 커서는 엔진에 묻기 전에 VAL_001', async () => { + for (const query of [{ groupId: 'nope' }, { excludeGroupId: 'nope' }]) { + expect( + (await failureOf(service.list(ARA, listQuery(query)))).code, + ).toBe('RES_001'); + } + + const first = await service.list(ARA, listQuery({ limit: 1 })); + const cursor = first.pageInfo.nextCursor ?? ''; + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 3 }); + expect( + (await service.list(ARA, listQuery({ limit: 1, cursor }))).items.map( + (c) => c.id, + ), + ).toEqual(['c-pa']); + + book.getAddressBookVersion.mockClear(); + for (const other of [ + listQuery({ cursor, q: '박' }), + listQuery({ cursor, groupId: 'g1' }), + listQuery({ cursor, excludeGroupId: 'g1' }), + listQuery({ groupId: 'g1', excludeGroupId: 'g1' }), + ]) { + expect((await failureOf(service.list(ARA, other))).code).toBe( + 'VAL_001', + ); + } + expect(book.getAddressBookVersion).not.toHaveBeenCalled(); + }); + + it('판이 그대로면 다시 읽지 않고, 다른 곳(메일 프로그램)에서 바뀌면 다시 읽는다 — 주소는 대소문자 없이 한 사람', async () => { + await service.list(ARA, listQuery()); + await service.get({ ...ARA, email: 'ara.kim@ARASYSTEM.TEST' }, 'c-ga'); + expect(book.reads).toBe(1); + expect(book.getAddressBookVersion).toHaveBeenCalledTimes(2); + expect(book.getAddressBookVersion).toHaveBeenLastCalledWith( + 'ara.kim@arasystem.test', + ); + + book.touch(); + await service.list(ARA, listQuery()); + expect(book.reads).toBe(2); + }); + + it('같은 때의 여러 요청은 한 번만 읽는다', async () => { + await Promise.all([ + service.list(ARA, listQuery()), + service.get(ARA, 'c-ga'), + service.suggestionLines(ARA.email), + ]); + expect(book.reads).toBe(1); + }); + + it('하나 — 없는 연락처 · 그룹 id 는 RES_001', async () => { + expect((await service.get(ARA, 'c-pa')).company).toBe('한빛상사'); + for (const id of ['nope', 'g1']) { + expect((await failureOf(service.get(ARA, id))).code).toBe('RES_001'); + } + }); + + it('받는 사람 추천 줄 — 연락처의 이름 차례, 주소가 있는 연락처만', async () => { + expect(await service.suggestionLines(ARA.email)).toEqual([ + { + name: '박지훈', + email: 'Jihoon@Hanbit.example', + company: '한빛상사', + emailLabel: 'work', + }, + ]); + }); + + it('사내 목록 — excludeGroupId 면 그 그룹 구성원이 가진 모든 주소(소문자)를 사내 목록 서비스에 넘긴다, 없는 그룹은 RES_001', async () => { + book.seedContact('c-two', { + emails: [ + { address: 'One@X.example', label: 'work' }, + { address: 'two@x.example', label: 'personal' }, + ], + }); + book.seedGroup('g2', '둘', ['urn:uuid:c-two', 'urn:uuid:c-pa']); + directoryList.mockImplementation( + async ( + _query: DirectoryEntryQueryDto, + excluded?: () => Promise>, + ) => [...((await excluded?.()) ?? [])].sort(), + ); + + await expect( + service.listDirectory(ARA, { limit: 20, excludeGroupId: 'g2' }), + ).resolves.toEqual([ + 'jihoon@hanbit.example', + 'one@x.example', + 'two@x.example', + ]); + await expect(service.listDirectory(ARA, { limit: 20 })).resolves.toEqual( + [], + ); + expect( + ( + await failureOf( + service.listDirectory(ARA, { limit: 20, excludeGroupId: 'nope' }), + ) + ).code, + ).toBe('RES_001'); + }); + }); + + describe('연락처 추가 (create)', () => { + it('uid 를 넣어 만들고 고른 그룹에 넣는다 — 응답은 그룹까지, 다시 읽지 않고 바로 목록에 보인다', async () => { + book.seedGroup('g1', '거래처', ['urn:uuid:other']); + await service.list(ARA, listQuery()); + + const created = await service.create(ARA, input({ groupIds: ['g1'] })); + + expect(created).toMatchObject({ + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }], + groupIds: ['g1'], + }); + const stored = book.contacts.get(created.id); + expect(stored?.uid).toMatch(/^urn:uuid:[0-9a-f-]{36}$/); + expect(book.groups.get('g1')?.memberUids).toEqual([ + 'urn:uuid:other', + stored?.uid, + ]); + // 한 번의 엔진 요청에 만들기 + 그룹 고치기 + expect(book.writes).toHaveLength(1); + expect(book.writes[0]?.create).toHaveLength(1); + expect(book.writes[0]?.update).toHaveLength(1); + + const page = await service.list(ARA, listQuery()); + expect(page.items.map((contact) => contact.id)).toEqual([created.id]); + expect(book.reads).toBe(1); + // 기억한 주소록이 엔진의 새 내용과 같다 + expect(page.items).toEqual( + (await newService().list(ARA, listQuery())).items, + ); + }); + + it('이름이나 이메일 가운데 하나 — 둘 다 없거나 같은 주소(대소문자 무시)가 두 번이면 엔진에 묻기 전에 VAL_001', async () => { + expect( + ( + await failureOf( + service.create(ARA, input({ name: null, emails: [] })), + ) + ).context?.reason, + ).toBe('contact-name-or-email-required'); + expect( + ( + await failureOf( + service.create( + ARA, + input({ + emails: [ + { address: 'a@x.example', label: 'work' }, + { address: 'A@X.example', label: 'personal' }, + ], + }), + ), + ) + ).context?.reason, + ).toBe('contact-duplicate-email'); + expect(book.getAddressBookVersion).not.toHaveBeenCalled(); + + // 이름만 · 이메일만은 된다 + await service.create(ARA, input({ emails: [] })); + await service.create(ARA, input({ name: null })); + expect(book.contacts.size).toBe(2); + }); + + it(`연락처 ${MAX_CONTACTS}개 · 그룹 하나 ${MAX_CONTACT_GROUP_MEMBERS}명이면 CONTACT_002 — 아무것도 쓰지 않는다 · 없는 그룹은 RES_001`, async () => { + for (let index = 0; index < MAX_CONTACT_GROUP_MEMBERS; index += 1) { + book.seedContact(`m${index}`); + } + book.seedGroup( + 'full', + '가득', + Array.from( + { length: MAX_CONTACT_GROUP_MEMBERS }, + (_, index) => `urn:uuid:m${index}`, + ), + ); + expect( + (await failureOf(service.create(ARA, input({ groupIds: ['full'] })))) + .code, + ).toBe('CONTACT_002'); + expect( + (await failureOf(service.create(ARA, input({ groupIds: ['nope'] })))) + .code, + ).toBe('RES_001'); + + for ( + let index = MAX_CONTACT_GROUP_MEMBERS; + index < MAX_CONTACTS; + index += 1 + ) { + book.seedContact(`m${index}`); + } + book.touch(); + expect((await failureOf(service.create(ARA, input()))).code).toBe( + 'CONTACT_002', + ); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it('로그에는 개수 · id 만 — 이름 · 주소는 남기지 않는다', async () => { + await service.create(ARA, input({ note: '비밀 메모' })); + + const logged = logSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain('연락처를 만들었습니다'); + expect(logged).not.toMatch(/박지훈|jihoon|hanbit|비밀 메모|ara\.kim@/); + }); + }); + + describe('크기 한도 (계약 3절 "크기")', () => { + /** 주소록을 한도 가까이 채운다 — 메모가 긴 연락처 몇 장 */ + const fillTo = (target: number) => { + const note = 'x'.repeat(1_000_000); + let weight = 0; + let index = 0; + while (weight + 1_100_000 < target) { + weight += cardWeight(book.seedContact(`big${index}`, { note })); + index += 1; + } + return weight; + }; + + it(`크기를 ${MAX_ADDRESS_BOOK_WEIGHT.toLocaleString('en-US')} 넘게 하는 쓰기(만들기 · 고치기)는 CONTACT_002 — 아무것도 쓰지 않는다`, async () => { + fillTo(MAX_ADDRESS_BOOK_WEIGHT); + const room = MAX_ADDRESS_BOOK_WEIGHT - bookWeight(); + const longText = 'y'.repeat(Math.min(room + 1, 200_000)); + + const failure = await failureOf( + service.create(ARA, input({ note: longText.repeat(6) })), + ); + expect(failure.code).toBe('CONTACT_002'); + expect(failure.context?.reason).toBe('address-book-too-large'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + // 한도 안이면 된다 + await expect(service.create(ARA, input())).resolves.toMatchObject({ + name: '박지훈', + }); + }); + + it('이미 넘은 주소록(다른 프로그램이 크게 만든 것)도 줄이는 쓰기 · 지우기는 된다 — 키우는 쓰기만 막는다', async () => { + const note = 'x'.repeat(1_000_000); + // 1,100만 — 아래에서 두 장을 줄여도 한도를 넘는다 + for (let index = 0; index < 11; index += 1) { + book.seedContact(`big${index}`, { note }); + } + expect(bookWeight()).toBeGreaterThan(MAX_ADDRESS_BOOK_WEIGHT); + + await expect( + service.patch(ARA, 'big0', { note: '짧게' }), + ).resolves.toMatchObject({ note: '짧게' }); + await expect(service.remove(ARA, 'big1')).resolves.toMatchObject({ + contact: { id: 'big1' }, + }); + expect( + (await failureOf(service.create(ARA, input({ name: '하나 더' })))).code, + ).toBe('CONTACT_002'); + }); + + function bookWeight(): number { + // 서비스가 재는 크기와 같은 잣대 — 연락처마다 cardWeight + return [...book.contacts.values()].reduce( + (sum, card) => sum + cardWeight(card), + 0, + ); + } + }); + + describe('연락처 고치기 (patch — 바꾼 칸만)', () => { + beforeEach(() => { + book.seedContact('c1', { + name: '박지훈', + emails: [{ address: 'jihoon@hanbit.example', label: 'work' }], + company: '한빛상사', + department: '구매팀', + }); + book.seedGroup('g-old', '옛 그룹', ['urn:uuid:c1', 'urn:uuid:other']); + book.seedGroup('g-new', '새 그룹'); + }); + + it('보낸 칸 가운데 바뀐 것만 엔진에 — 회사 · 부서도 따로(칸 안의 남의 정보는 어댑터가 지킨다), 그룹은 넣고 빼기를 한 번에', async () => { + const updated = await service.patch(ARA, 'c1', { + company: '한빛상사', + department: '영업팀', + title: '과장', + groupIds: ['g-new'], + }); + + expect(updated).toMatchObject({ + id: 'c1', + company: '한빛상사', + department: '영업팀', + title: '과장', + groupIds: ['g-new'], + }); + const [write] = book.writes; + expect(write?.update).toEqual( + expect.arrayContaining([ + { + kind: 'contact', + id: 'c1', + fields: { department: '영업팀', title: '과장' }, + }, + { kind: 'group', id: 'g-new', memberUids: ['urn:uuid:c1'] }, + { kind: 'group', id: 'g-old', memberUids: ['urn:uuid:other'] }, + ]), + ); + expect(write?.update).toHaveLength(3); + }); + + it('보내지 않은 칸은 그대로 — groupIds 를 보내지 않으면 그룹도 그대로', async () => { + const updated = await service.patch(ARA, 'c1', { note: '메모' }); + expect(updated).toMatchObject({ + name: '박지훈', + company: '한빛상사', + note: '메모', + groupIds: ['g-old'], + }); + expect(book.writes[0]?.update).toEqual([ + { kind: 'contact', id: 'c1', fields: { note: '메모' } }, + ]); + }); + + it('빈 본문은 VAL_001 (엔진에 묻기 전에) · 바뀐 것이 없으면 쓰지 않고 지금 연락처를 준다', async () => { + expect( + (await failureOf(service.patch(ARA, 'c1', {}))).context?.reason, + ).toBe('contact-patch-empty'); + expect( + (await failureOf(service.patch(ARA, 'c1', { name: undefined }))).context + ?.reason, + ).toBe('contact-patch-empty'); + expect(book.getAddressBookVersion).not.toHaveBeenCalled(); + + const same = await service.patch(ARA, 'c1', { + company: '한빛상사', + groupIds: ['g-old'], + }); + expect(same.groupIds).toEqual(['g-old']); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it('"이름이나 이메일"은 합친 결과로 — 이름만 지우면 되고, 이름 · 이메일을 모두 지우면 VAL_001 · 같은 주소 두 번도 VAL_001', async () => { + await expect( + service.patch(ARA, 'c1', { name: null }), + ).resolves.toMatchObject({ name: null }); + expect( + (await failureOf(service.patch(ARA, 'c1', { emails: [] }))).context + ?.reason, + ).toBe('contact-name-or-email-required'); + expect( + ( + await failureOf( + service.patch(ARA, 'c1', { + emails: [ + { address: 'a@x.example', label: 'work' }, + { address: 'A@x.example', label: 'other' }, + ], + }), + ) + ).context?.reason, + ).toBe('contact-duplicate-email'); + }); + + it('없는 연락처 · 그룹은 RES_001 · 가득 찬 그룹에 넣으면 CONTACT_002', async () => { + expect( + (await failureOf(service.patch(ARA, 'nope', { note: 'x' }))).code, + ).toBe('RES_001'); + expect( + (await failureOf(service.patch(ARA, 'c1', { groupIds: ['nope'] }))) + .code, + ).toBe('RES_001'); + + book.seedGroup( + 'full', + '가득', + Array.from({ length: MAX_CONTACT_GROUP_MEMBERS }, (_, index) => { + book.seedContact(`m${index}`); + return `urn:uuid:m${index}`; + }), + ); + book.touch(); + expect( + ( + await failureOf( + service.patch(ARA, 'c1', { groupIds: ['g-old', 'full'] }), + ) + ).code, + ).toBe('CONTACT_002'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it('uid 가 없는 카드(다른 프로그램이 만든 것)는 그룹에 넣을 수 없다 — VAL_001, 칸만 고치는 것은 된다', async () => { + book.seedContact('no-uid', { uid: null, name: '옛 카드' }); + book.touch(); + + expect( + (await failureOf(service.patch(ARA, 'no-uid', { groupIds: ['g-new'] }))) + .context?.reason, + ).toBe('contact-without-uid'); + await expect( + service.patch(ARA, 'no-uid', { name: '새 이름' }), + ).resolves.toMatchObject({ name: '새 이름', groupIds: [] }); + }); + }); + + describe('지우기 · 되돌리기 (remove · removeGroup · restore)', () => { + it('연락처 — 그룹 구성원에서도 빼고 지운다. 응답은 지우기 전의 연락처(그룹 포함)와 되돌리기 표', async () => { + book.seedContact('c1', { name: '하나' }); + book.seedGroup('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:c2']); + + const removed = await service.remove(ARA, 'c1'); + + expect(removed.contact).toMatchObject({ + id: 'c1', + name: '하나', + groupIds: ['g1'], + }); + expect(removed.restoreToken).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(book.contacts.has('c1')).toBe(false); + expect(book.groups.get('g1')?.memberUids).toEqual(['urn:uuid:c2']); + expect((await failureOf(service.get(ARA, 'c1'))).code).toBe('RES_001'); + // 표는 로그에 남기지 않는다 + const logged = [ + ...(logSpy.mock.calls as unknown[][]), + ...(warnSpy.mock.calls as unknown[][]), + ] + .map(([message]) => String(message)) + .join('\n'); + expect(logged).not.toContain(removed.restoreToken); + }); + + it('연락처 되돌리기 — 원본 그대로(같은 uid) 다시 만들고 그때 속했던 그룹에 다시 넣는다 · 표는 한 번만', async () => { + book.seedContact('c1', { + name: '하나', + emails: [{ address: 'one@x.example', label: 'work' }], + }); + book.extras.set('c1', { nicknames: { k: { name: '별명' } } }); + book.seedGroup('g1', '거래처', ['urn:uuid:c1']); + book.seedGroup('g2', '협력사', ['urn:uuid:c1']); + const { restoreToken } = await service.remove(ARA, 'c1'); + + const restored = await service.restore(ARA, restoreToken ?? ''); + + expect(restored).toMatchObject({ + kind: 'contact', + contact: { name: '하나', groupIds: ['g1', 'g2'] }, + }); + const id = restored.kind === 'contact' ? restored.contact.id : ''; + expect(id).not.toBe('c1'); + expect(book.contacts.get(id)?.uid).toBe('urn:uuid:c1'); + expect(book.groups.get('g1')?.memberUids).toEqual(['urn:uuid:c1']); + // 다른 프로그램의 칸도 그대로 넘긴다 + expect(JSON.parse(book.restoredFrom.get(id) ?? '{}')).toMatchObject({ + id: 'c1', + nicknames: { k: { name: '별명' } }, + }); + // 한 번만 + expect( + (await failureOf(service.restore(ARA, restoreToken ?? ''))).code, + ).toBe('RES_001'); + }); + + it('연락처 되돌리기 — 그사이 그룹을 지웠다 되돌렸으면(새 id · 같은 uid) 그 그룹에, 지운 그룹 · 가득 찬 그룹은 건너뛴다', async () => { + book.seedContact('c1'); + book.seedGroup('g1', '거래처', ['urn:uuid:c1']); + book.seedGroup('g2', '없어질 그룹', ['urn:uuid:c1']); + const fullMembers = Array.from( + { length: MAX_CONTACT_GROUP_MEMBERS - 1 }, + (_, index) => { + book.seedContact(`m${index}`); + return `urn:uuid:m${index}`; + }, + ); + book.seedGroup('g3', '거의 가득', [...fullMembers, 'urn:uuid:c1']); + const contact = await service.remove(ARA, 'c1'); + // 그룹을 지우고 되돌린다 — 새 id, 같은 uid + const group = await service.removeGroup(ARA, 'g1'); + const regrouped = await service.restore(ARA, group.restoreToken ?? ''); + const newGroupId = regrouped.kind === 'group' ? regrouped.group.id : ''; + expect(newGroupId).not.toBe('g1'); + await service.removeGroup(ARA, 'g2'); + // 가득 찬 그룹을 채운다 + book.seedContact('filler'); + book.groups.set('g3', { + ...book.groups.get('g3')!, + memberUids: [...fullMembers, 'urn:uuid:filler'], + }); + book.touch(); + + const restored = await service.restore(ARA, contact.restoreToken ?? ''); + + expect(restored.kind === 'contact' && restored.contact.groupIds).toEqual([ + newGroupId, + ]); + }); + + it('연락처 되돌리기 — 그사이 같은 uid 의 카드가 생겼으면 새 uid (엔진은 한 주소록 안의 같은 uid 를 받지 않는다)', async () => { + book.seedContact('c1', { uid: 'urn:uuid:same' }); + const { restoreToken } = await service.remove(ARA, 'c1'); + book.seedContact('other', { uid: 'urn:uuid:same' }); + book.touch(); + + const restored = await service.restore(ARA, restoreToken ?? ''); + const id = restored.kind === 'contact' ? restored.contact.id : ''; + expect(book.contacts.get(id)?.uid).toMatch(/^urn:uuid:[0-9a-f-]{36}$/); + }); + + it(`연락처 되돌리기 — 연락처 ${MAX_CONTACTS}개면 CONTACT_002 이고 표는 남는다(자리가 나면 다시 쓸 수 있다)`, async () => { + book.seedContact('c1'); + const { restoreToken } = await service.remove(ARA, 'c1'); + for (let index = 0; index < MAX_CONTACTS; index += 1) { + book.seedContact(`m${index}`); + } + book.touch(); + expect( + (await failureOf(service.restore(ARA, restoreToken ?? ''))).code, + ).toBe('CONTACT_002'); + + book.contacts.delete('m0'); + book.touch(); + await expect( + service.restore(ARA, restoreToken ?? ''), + ).resolves.toMatchObject({ kind: 'contact' }); + }); + + it('그룹 — 그룹만 지우고 이름 · 구성원 · 표를 준다, 되돌리면 그때의 구성원 가운데 지금도 있는 연락처로', async () => { + book.seedContact('c-b', { name: '나비' }); + book.seedContact('c-a', { name: '가나다' }); + book.seedGroup('g1', '거래처', [ + 'urn:uuid:c-b', + 'urn:uuid:c-a', + 'urn:uuid:gone', + ]); + + const removed = await service.removeGroup(ARA, 'g1'); + expect(removed).toMatchObject({ + id: 'g1', + name: '거래처', + memberIds: ['c-a', 'c-b'], + }); + expect(removed.restoreToken).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(book.groups.size).toBe(0); + expect(book.contacts.size).toBe(2); + + book.contacts.delete('c-b'); + book.touch(); + const restored = await service.restore(ARA, removed.restoreToken ?? ''); + expect(restored).toMatchObject({ + kind: 'group', + group: { name: '거래처', memberCount: 1 }, + }); + const id = restored.kind === 'group' ? restored.group.id : ''; + expect(book.groups.get(id)).toMatchObject({ + uid: 'urn:uuid:g1', + memberUids: ['urn:uuid:c-a'], + }); + }); + + it(`그룹 되돌리기 — 같은 이름이 그사이 생겼으면 CONTACT_001 · 그룹 ${MAX_CONTACT_GROUPS}개면 CONTACT_002 (표는 남는다)`, async () => { + book.seedGroup('g1', '거래처'); + const { restoreToken } = await service.removeGroup(ARA, 'g1'); + book.seedGroup('g-same', ' 거래처 '); + book.touch(); + expect( + (await failureOf(service.restore(ARA, restoreToken ?? ''))).code, + ).toBe('CONTACT_001'); + + book.groups.delete('g-same'); + for (let index = 0; index < MAX_CONTACT_GROUPS; index += 1) { + book.seedGroup(`x${index}`, `그룹 ${index}`); + } + book.touch(); + expect( + (await failureOf(service.restore(ARA, restoreToken ?? ''))).code, + ).toBe('CONTACT_002'); + }); + + it('표 — 다른 직원의 것 · 5분 지난 것 · 없는 것은 RES_001 (모두 같게)', async () => { + book.seedContact('c1'); + book.seedContact('c2'); + const first = await service.remove(ARA, 'c1'); + const second = await service.remove(ARA, 'c2'); + + expect( + (await failureOf(service.restore(DOYUN, first.restoreToken ?? ''))) + .code, + ).toBe('RES_001'); + expect( + (await failureOf(service.restore(ARA, 'AAAAAAAAAAAAAAAAAAAAAA'))).code, + ).toBe('RES_001'); + + now += CONTACT_RESTORE_TTL_MS; + expect( + (await failureOf(service.restore(ARA, second.restoreToken ?? ''))).code, + ).toBe('RES_001'); + // 다른 직원이 물었던 표도 5분 안이었다면 쓸 수 있었다 — 지금은 지났다 + expect( + (await failureOf(service.restore(ARA, first.restoreToken ?? ''))).code, + ).toBe('RES_001'); + }); + + it(`원본이 ${CONTACT_RESTORE_MAX_CARD_BYTES / 1024}KB 를 넘으면 표는 null (지우기는 된다 · warn 로그는 크기만)`, async () => { + book.seedContact('photo', { name: '사진 있는 사람' }); + book.extras.set('photo', { + media: { m: { uri: `data:image/jpeg;base64,${'A'.repeat(300_000)}` } }, + }); + + const removed = await service.remove(ARA, 'photo'); + + expect(removed.restoreToken).toBeNull(); + expect(book.contacts.has('photo')).toBe(false); + const warned = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(warned).toContain('bytes='); + expect(warned).not.toContain('사진 있는 사람'); + }); + + it('같은 uid 의 다른 카드가 남아 있으면 그 카드의 구성원 자격은 그대로 둔다', async () => { + book.seedContact('twin-a', { uid: 'urn:uuid:twin' }); + book.seedContact('twin-b', { uid: 'urn:uuid:twin' }); + book.seedGroup('g1', '거래처', ['urn:uuid:twin']); + + await service.remove(ARA, 'twin-a'); + + expect(book.groups.get('g1')?.memberUids).toEqual(['urn:uuid:twin']); + expect(book.writes[0]?.update).toEqual([]); + }); + + it('없는 연락처 · 그룹은 RES_001', async () => { + expect((await failureOf(service.remove(ARA, 'nope'))).code).toBe( + 'RES_001', + ); + expect((await failureOf(service.removeGroup(ARA, 'nope'))).code).toBe( + 'RES_001', + ); + }); + }); + + describe('그룹', () => { + it('목록은 이름 차례 · 구성원 수는 이 주소록의 연락처만', async () => { + book.seedContact('c1'); + book.seedGroup('g2', '협력사'); + book.seedGroup('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:gone']); + + const page = await service.listGroups(ARA, { limit: 20 }); + + expect(page.items).toEqual([ + { id: 'g1', name: '거래처', memberCount: 1 }, + { id: 'g2', name: '협력사', memberCount: 0 }, + ]); + expect(page.pageInfo.total).toBe(2); + }); + + it('만들기 — 구성원 없이, 같은 이름(앞뒤 공백 · ASCII 대소문자 무시)은 CONTACT_001, 100개면 CONTACT_002', async () => { + book.seedGroup('g1', 'Partners'); + + expect( + (await failureOf(service.createGroup(ARA, { name: 'partners' }))).code, + ).toBe('CONTACT_001'); + const created = await service.createGroup(ARA, { name: '거래처' }); + expect(created).toMatchObject({ name: '거래처', memberCount: 0 }); + expect(book.writes[0]?.create).toMatchObject([ + { kind: 'group', name: '거래처', memberUids: [] }, + ]); + + for ( + let index = book.groups.size; + index < MAX_CONTACT_GROUPS; + index += 1 + ) { + book.seedGroup(`x${index}`, `그룹 ${index}`); + } + book.touch(); + expect( + (await failureOf(service.createGroup(ARA, { name: '하나 더' }))).code, + ).toBe('CONTACT_002'); + }); + + it('이름 바꾸기 — 같은 이름이면 쓰지 않고, 다른 그룹과 같으면 CONTACT_001, 대소문자만 바꾸기는 된다', async () => { + book.seedGroup('g1', 'partners'); + book.seedGroup('g2', '거래처'); + + await service.renameGroup(ARA, 'g1', 'partners'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + + expect( + (await failureOf(service.renameGroup(ARA, 'g1', '거래처'))).code, + ).toBe('CONTACT_001'); + await expect( + service.renameGroup(ARA, 'g1', 'Partners'), + ).resolves.toMatchObject({ id: 'g1', name: 'Partners' }); + expect( + (await failureOf(service.renameGroup(ARA, 'nope', 'x'))).code, + ).toBe('RES_001'); + }); + }); + + describe('구성원 넣기 (addGroupMembers)', () => { + beforeEach(() => { + book.seedContact('c1', { name: '하나' }); + book.seedContact('c2', { name: '둘' }); + book.seedContact('dup', { + name: '이도윤(거래처)', + emails: [{ address: 'Doyun.Lee@arasystem.test', label: 'other' }], + }); + book.seedGroup('g1', '거래처', ['urn:uuid:c1']); + }); + + it('내 연락처 · 사내 목록에서 — 같은 주소의 연락처는 그 연락처를, 없으면 이름 · 주소(회사)로 새로 만든다. 이미 구성원은 세지 않는다', async () => { + findByIds.mockResolvedValue([ + { + id: 'd', + name: '이도윤', + email: 'doyun.lee@arasystem.test', + kind: 'person', + }, + { + id: 's', + name: '영업팀', + email: 'sales@arasystem.test', + kind: 'shared', + }, + ]); + + const result = await service.addGroupMembers(ARA, 'g1', { + contactIds: ['c1', 'c2'], + directoryIds: ['d', 's'], + }); + + expect(result).toEqual({ + group: { id: 'g1', name: '거래처', memberCount: 4 }, + addedCount: 3, + createdCount: 1, + }); + expect(findByIds).toHaveBeenCalledWith(['d', 's']); + const created = [...book.contacts.values()].find( + (card) => card.emails[0]?.address === 'sales@arasystem.test', + ); + expect(created).toMatchObject({ + name: '영업팀', + emails: [{ address: 'sales@arasystem.test', label: 'work' }], + }); + expect(book.writes).toHaveLength(1); + }); + + it('모두 이미 구성원이면 쓰지 않는다', async () => { + await expect( + service.addGroupMembers(ARA, 'g1', { contactIds: ['c1'] }), + ).resolves.toEqual({ + group: { id: 'g1', name: '거래처', memberCount: 1 }, + addedCount: 0, + createdCount: 0, + }); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it('합쳐 0개 · 100개 넘음은 VAL_001 — 없는 그룹 · 연락처 · 사내 목록 id 는 RES_001', async () => { + for (const body of [ + {}, + { contactIds: [] }, + { + contactIds: Array.from({ length: 60 }, (_, i) => `c${i}`), + directoryIds: Array.from({ length: 41 }, (_, i) => `d${i}`), + }, + ]) { + expect( + (await failureOf(service.addGroupMembers(ARA, 'g1', body))).code, + ).toBe('VAL_001'); + } + expect( + ( + await failureOf( + service.addGroupMembers(ARA, 'nope', { contactIds: ['c1'] }), + ) + ).code, + ).toBe('RES_001'); + expect( + ( + await failureOf( + service.addGroupMembers(ARA, 'g1', { contactIds: ['nope'] }), + ) + ).code, + ).toBe('RES_001'); + + findByIds.mockRejectedValue(new BusinessException('RES_001')); + expect( + ( + await failureOf( + service.addGroupMembers(ARA, 'g1', { directoryIds: ['x'] }), + ) + ).code, + ).toBe('RES_001'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it(`구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 연락처 ${MAX_CONTACTS}개를 넘으면 CONTACT_002 — 아무것도 쓰지 않는다`, async () => { + for (let index = 0; index < MAX_CONTACT_GROUP_MEMBERS - 1; index += 1) { + book.seedContact(`m${index}`); + } + book.seedGroup( + 'g1', + '거래처', + Array.from( + { length: MAX_CONTACT_GROUP_MEMBERS - 1 }, + (_, index) => `urn:uuid:m${index}`, + ), + ); + book.touch(); + // 한 명은 들어간다 · 두 명은 넘는다 + expect( + ( + await failureOf( + service.addGroupMembers(ARA, 'g1', { contactIds: ['c1', 'c2'] }), + ) + ).code, + ).toBe('CONTACT_002'); + + for (let index = book.contacts.size; index < MAX_CONTACTS; index += 1) { + book.seedContact(`z${index}`); + } + book.touch(); + findByIds.mockResolvedValue([ + { + id: 'n', + name: '새 직원', + email: 'new@arasystem.test', + kind: 'person', + }, + ]); + expect( + ( + await failureOf( + service.addGroupMembers(ARA, 'g1', { directoryIds: ['n'] }), + ) + ).code, + ).toBe('CONTACT_002'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + }); + + describe('판이 어긋날 때 · 엔진이 바쁠 때 · 실패 · 차례', () => { + it('그사이 다른 곳에서 바뀌면 다시 읽고 다시 정한다 — 세 번 어긋나면 MAIL_001', async () => { + book.outsideChanges = 1; + await expect(service.create(ARA, input())).resolves.toMatchObject({ + name: '박지훈', + }); + expect(book.writeAddressBook).toHaveBeenCalledTimes(2); + expect(book.reads).toBe(2); + + book.outsideChanges = 3; + const error = await failureOf(service.create(ARA, input({ name: '또' }))); + expect(error.code).toBe('MAIL_001'); + expect(error.context?.reason).toBe('address-book-keeps-changing'); + }); + + it('어긋난 뒤 다시 정할 때 지금 주소록으로 본다 — 그사이 같은 이름의 그룹이 생겼으면 CONTACT_001', async () => { + book.writeAddressBook.mockImplementationOnce(() => { + book.seedGroup('g-other', '거래처'); + book.touch(); + return Promise.resolve({ kind: 'conflict' }); + }); + + expect( + (await failureOf(service.createGroup(ARA, { name: '거래처' }))).code, + ).toBe('CONTACT_001'); + }); + + it('엔진이 바쁘면(busy — 아무것도 들어가지 않음) 다시 읽고 한 번 더 한다 · 세 번 모두 바쁘면 MAIL_001(address-book-busy)', async () => { + book.busy.push('lost'); + await expect(service.create(ARA, input())).resolves.toMatchObject({ + name: '박지훈', + }); + expect(book.writeAddressBook).toHaveBeenCalledTimes(2); + expect(book.contacts.size).toBe(1); + + book.busy.push('lost', 'lost', 'lost'); + const error = await failureOf(service.create(ARA, input({ name: '또' }))); + expect(error.code).toBe('MAIL_001'); + expect(error.context?.reason).toBe('address-book-busy'); + }); + + it('바쁘다고 했지만 들어갔으면(applied) 다음 시도가 이어 한다 — 만든 연락처를 같은 uid 로 찾아 두 장 만들지 않는다', async () => { + book.seedGroup('g1', '거래처'); + book.busy.push('applied'); + + const created = await service.create(ARA, input({ groupIds: ['g1'] })); + + expect(book.contacts.size).toBe(1); + expect(created).toMatchObject({ name: '박지훈', groupIds: ['g1'] }); + expect(book.writes).toHaveLength(1); + }); + + it('바쁘다고 했지만 지우기가 들어갔으면 — 다음 시도는 그때 읽은 연락처와 되돌리기 표로 답한다(RES_001 이 아니다)', async () => { + book.seedContact('c1', { name: '하나' }); + book.busy.push('applied'); + + const removed = await service.remove(ARA, 'c1'); + + expect(removed.contact).toMatchObject({ id: 'c1', name: '하나' }); + expect(removed.restoreToken).not.toBeNull(); + await expect( + service.restore(ARA, removed.restoreToken ?? ''), + ).resolves.toMatchObject({ kind: 'contact', contact: { name: '하나' } }); + }); + + it('바쁘다고 했지만 되돌리기가 들어갔으면 — 다음 시도는 그 카드를 찾아 이어 한다(두 번 만들지 않는다)', async () => { + book.seedContact('c1', { name: '하나' }); + book.seedGroup('g1', '거래처', ['urn:uuid:c1']); + const { restoreToken } = await service.remove(ARA, 'c1'); + book.busy.push('applied'); + + const restored = await service.restore(ARA, restoreToken ?? ''); + + expect(book.contacts.size).toBe(1); + expect(restored).toMatchObject({ + kind: 'contact', + contact: { name: '하나', groupIds: ['g1'] }, + }); + expect( + (await failureOf(service.restore(ARA, restoreToken ?? ''))).code, + ).toBe('RES_001'); + }); + + it('엔진이 바꾸기를 받지 않으면 그 코드로 던지고 기억한 주소록을 버린다 — 다음 요청은 다시 읽는다', async () => { + await service.list(ARA, listQuery()); + book.failure = new BusinessException('VAL_001', { + context: { reason: 'address-book-change-refused' }, + }); + + expect((await failureOf(service.create(ARA, input()))).code).toBe( + 'VAL_001', + ); + await service.list(ARA, listQuery()); + expect(book.reads).toBe(2); + }); + + it('엔진이 새 판을 알려 주지 않으면 응답은 주고 기억하지 않는다', async () => { + book.writeAddressBook.mockImplementationOnce( + (_user, _version, changes) => { + book.state += 1; + const card = changes.create[0]; + return Promise.resolve({ + kind: 'written', + version: null, + createdIds: new Map(changes.create.map((c) => [c.key, 'n9'])), + cards: new Map( + card?.kind === 'contact' + ? [ + [ + 'n9', + { + kind: 'contact', + card: { id: 'n9', uid: card.uid, ...card.fields }, + }, + ], + ] + : [], + ), + removed: new Map(), + }); + }, + ); + + await expect(service.create(ARA, input())).resolves.toMatchObject({ + id: 'n9', + name: '박지훈', + }); + await service.list(ARA, listQuery()); + expect(book.reads).toBe(2); + }); + + it('같은 직원의 바꾸기는 차례로 한다 — 동시에 보내도 어긋나지 않는다', async () => { + const results = await Promise.all([ + service.create(ARA, input({ name: '하나' })), + service.create(ARA, input({ name: '둘', emails: [] })), + service.createGroup(ARA, { name: '거래처' }), + ]); + + expect(results.map((result) => result.name)).toEqual([ + '하나', + '둘', + '거래처', + ]); + // 어긋남 없이 세 번만 썼다 + expect(book.writeAddressBook).toHaveBeenCalledTimes(3); + expect(book.contacts.size).toBe(2); + }); + }); +}); diff --git a/backend/src/modules/contacts/contacts.service.ts b/backend/src/modules/contacts/contacts.service.ts new file mode 100644 index 0000000..b27638b --- /dev/null +++ b/backend/src/modules/contacts/contacts.service.ts @@ -0,0 +1,1219 @@ +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import type { + CursorPaginationQueryDto, + PaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { + applyAddressBookWrite, + buildBookView, + emailKey, + newCardUid, + weightAfterChanges, + type BookContact, + type BookGroup, + type BookView, + type WrittenChanges, +} from './address-book'; +import { ContactBookCache } from './contact-book-cache'; +import { cursorOffset, listScope, pageOf } from './contact-paging'; +import { + ContactRestoreStore, + type RestorableCard, + type RestorableGroupRef, +} from './contact-restore-store'; +import { matchesContact, parseContactSearch } from './contact-search'; +import { + CONTACT_BOOK_CACHE_IDLE_MS, + CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT, + CONTACT_BOOK_CACHE_MAX_USERS, + CONTACT_RESTORE_MAX_CARD_BYTES, + CONTACT_RESTORE_MAX_PER_USER, + CONTACT_RESTORE_MAX_TOTAL_BYTES, + CONTACT_RESTORE_TOKEN_BYTES, + CONTACT_RESTORE_TTL_MS, + CONTACT_WRITE_ATTEMPTS, + MAX_ADDRESS_BOOK_WEIGHT, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_GROUPS, + MAX_CONTACTS, + MAX_GROUP_MEMBERS_PER_REQUEST, +} from './contacts.constants'; +import { + CONTACTS_PROVIDER, + type AddressBookCardUpdate, + type AddressBookChanges, + type AddressBookContactCard, + type ContactsProvider, + type NewAddressBookCard, + type RemovedCardOriginal, +} from './contacts.provider'; +import type { + Contact, + ContactDeleted, + ContactEmail, + ContactFields, + ContactGroup, + ContactGroupDeleted, + ContactGroupMembersAdded, + ContactInput, + ContactPatch, + ContactPhone, + ContactRestored, + ContactSuggestionLine, + DirectoryEntry, +} from './contacts.types'; +import { DirectoryService } from './directory.service'; +import type { ContactListQueryDto } from './dto/contact.dto'; +import type { DirectoryEntryQueryDto } from './dto/directory-entry.dto'; + +/** 되돌리기 표의 시계 — 테스트가 바꿔 끼운다 (없으면 Date.now) */ +export const CONTACTS_CLOCK = Symbol('CONTACTS_CLOCK'); + +/** 이 바꾸기 안에서 새 카드를 가리키는 이름 */ +const NEW_CONTACT_KEY = 'contact'; +const NEW_GROUP_KEY = 'group'; +const NEW_MEMBER_KEY_PREFIX = 'member-'; +const RESTORED_KEY = 'restored'; + +const NO_CHANGES: AddressBookChanges = { create: [], update: [], destroy: [] }; + +/** + * 한 번의 바꾸기 시도 — 계획이 앞 시도를 알아본다. + * 엔진이 serverUnavailable 로 거절하면(busy) 일부가 들어갔을 수 있다 — 다음 시도의 계획은 앞 시도에서 만든 카드(같은 uid)를 찾아 이어 한다 + */ +interface WriteAttempt { + /** 이 바꾸기에서 새 카드에 쓸 uid — 다시 할 때도 같은 값 (앞 시도가 들어갔는지 알아보는 데 쓴다) */ + uid(key: string): string; + /** 앞 시도의 결과를 모른다 (엔진이 busy) */ + readonly uncertain: boolean; +} + +/** 쓴 결과 — 만든 카드의 id · 지운 카드의 원본(되돌리기 표 — 앞 시도에서 읽은 것도) */ +interface WriteResult { + readonly createdIds: ReadonlyMap; + readonly removed: ReadonlyMap; +} + +/** 한 번의 바꾸기 계획 — 지금 주소록으로 바꿀 것을 정하고, 바꾼 뒤의 주소록으로 응답을 만든다 */ +interface ChangePlan { + readonly changes: AddressBookChanges; + readonly result: (after: BookView, written: WriteResult) => T; + /** 바꾼 뒤 남기는 info 로그 (개수 · id 만 — 연락처의 내용은 남기지 않는다) */ + readonly log?: string; +} + +/** 읽는 중인 주소록 — token 은 이 읽기의 표 (그사이 바꾸기가 끝나면 빠져서 옛 결과를 기억하지 않는다) */ +interface Loading { + readonly token: object; + readonly promise: Promise; +} + +/** 지운 연락처 — 응답 · 되돌리기 표에 쓴다 */ +interface RemovedContact { + readonly contact: Contact; + readonly card: AddressBookContactCard; + readonly groups: readonly RestorableGroupRef[]; +} + +/** 지운 그룹 */ +interface RemovedGroup { + readonly group: BookGroup; +} + +type Mutable = { -readonly [K in keyof T]?: T[K] }; + +function normalizeUser(email: string): string { + return email.trim().toLowerCase(); +} + +function isEmpty(changes: AddressBookChanges): boolean { + return ( + changes.create.length === 0 && + changes.update.length === 0 && + changes.destroy.length === 0 + ); +} + +function notFound(reason: string): BusinessException { + return new BusinessException('RES_001', { context: { reason } }); +} + +function overLimit( + reason: string, + context: Record = {}, +): BusinessException { + return new BusinessException('CONTACT_002', { + context: { reason, ...context }, + }); +} + +/** 연락처 칸만 꺼낸다 (groupIds 는 그룹 카드의 일) */ +function fieldsOf(input: ContactFields): ContactFields { + return { + name: input.name, + emails: input.emails.map(({ address, label }) => ({ address, label })), + phones: input.phones.map(({ number, label }) => ({ number, label })), + company: input.company, + department: input.department, + title: input.title, + note: input.note, + }; +} + +/** 고치기 본문을 지금 연락처에 합친 칸 — 보내지 않은 칸(undefined)은 지금 값 */ +function mergedFields( + current: ContactFields, + patch: ContactPatch, +): ContactFields { + // null 은 "지운다" — 보내지 않은 칸(undefined)만 지금 값을 쓴다 + const pick = (key: K): ContactFields[K] => { + const sent = patch[key]; + return sent !== undefined ? sent : current[key]; + }; + return fieldsOf({ + name: pick('name'), + emails: pick('emails'), + phones: pick('phones'), + company: pick('company'), + department: pick('department'), + title: pick('title'), + note: pick('note'), + }); +} + +function sameEmails(a: readonly ContactEmail[], b: readonly ContactEmail[]) { + return ( + a.length === b.length && + a.every( + (email, index) => + email.address === b[index]?.address && email.label === b[index]?.label, + ) + ); +} + +function samePhones(a: readonly ContactPhone[], b: readonly ContactPhone[]) { + return ( + a.length === b.length && + a.every( + (phone, index) => + phone.number === b[index]?.number && phone.label === b[index]?.label, + ) + ); +} + +/** + * 바뀐 칸만 — 바꾸지 않은 칸은 엔진에 보내지 않는다(다른 프로그램이 그 칸에 둔 것 · 우리가 읽으며 맞춘 모양을 그대로 둔다). + * 칸 안의 남의 정보는 어댑터가 지킨다(계약 3절 "고치기" — 회사만 바꾸면 부서 · 다른 조직은 그대로) + */ +function changedFields( + current: ContactFields, + next: ContactFields, +): Partial { + const changed: Mutable = {}; + if (current.name !== next.name) changed.name = next.name; + if (!sameEmails(current.emails, next.emails)) changed.emails = next.emails; + if (!samePhones(current.phones, next.phones)) changed.phones = next.phones; + if (current.company !== next.company) changed.company = next.company; + if (current.department !== next.department) { + changed.department = next.department; + } + if (current.title !== next.title) changed.title = next.title; + if (current.note !== next.note) changed.note = next.note; + return changed; +} + +/** 그룹 이름을 견주는 모양 — 앞뒤 공백을 빼고 ASCII 대소문자만 무시한다 (계약 3절 ContactGroup) */ +function groupNameKey(name: string): string { + return name.trim().replace(/[A-Z]+/g, (letters) => letters.toLowerCase()); +} + +/** 그룹의 구성원 uid 를 고친 것 — 더할 uid 는 끝에(이미 있으면 그대로), 뺄 uid 는 모두 */ +function withMembers( + group: BookGroup, + add: readonly string[], + remove: ReadonlySet = new Set(), +): string[] { + const uids = group.card.memberUids.filter((uid) => !remove.has(uid)); + const present = new Set(uids); + for (const uid of add) { + if (!present.has(uid)) { + present.add(uid); + uids.push(uid); + } + } + return uids; +} + +function joinGroups(groups: readonly BookGroup[], uid: string) { + return groups.map( + (group): AddressBookCardUpdate => ({ + kind: 'group', + id: group.card.id, + memberUids: withMembers(group, [uid]), + }), + ); +} + +/** + * 주소록 (계약 3절 "주소록") — 직원마다 엔진의 기본 주소록 하나(연락처 · 그룹). + * + * - 읽기: 요청마다 엔진에 판(상태)만 묻고(가볍다), 기억한 판과 같으면 다시 읽지 않는다 (contact-book-cache.ts — 200명 · 모두 2,400만 · + * 한 사람 800만). 다른 프로그램(CardDAV)에서 바꾼 것도 판이 바뀌어 곧 보인다 + * - 찾기 · 차례 · 커서는 서버가 한다 (contact-search.ts · contact-paging.ts — 엔진의 검색은 낱말 단위 · 이름 정렬 없음) + * - 바꾸기: 같은 직원의 바꾸기는 차례로 하고, 읽은 판 그대로일 때만 엔진에 쓴다 — 그사이 다른 곳에서 바뀌거나(conflict) 엔진이 동시 고치기를 + * 거절하면(busy) 다시 읽어 다시 정한다 (CONTACT_WRITE_ATTEMPTS 번 — 넘으면 MAIL_001). 한도(연락처 3,000 · 그룹 100 · 구성원 500 · 크기 800만 — + * CONTACT_002) · 같은 이름의 그룹(CONTACT_001)도 그 판으로 본다. 쓴 뒤에는 같은 바꾸기를 기억한 주소록에 옮겨 다시 읽지 않는다 (address-book.ts) + * - 지우기는 되돌리기 표를 준다 — 엔진 카드 전체를 5분 기억한다 (contact-restore-store.ts) + * - 로그에는 개수 · id 만 — 이름 · 주소 · 전화번호 · 메모 · 되돌리기 표는 남기지 않는다 + * + * ⚠️ 프로세스 메모리다(기억한 주소록 · 바꾸기 차례 · 되돌리기 표). 인스턴스를 늘려도 주소록은 틀리지 않는다 — 판이 그대로일 때만 쓴다. + * 되돌리기 표는 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404). + */ +@Injectable() +export class ContactsService { + private readonly logger = new Logger(ContactsService.name); + private readonly books = new ContactBookCache({ + maxUsers: CONTACT_BOOK_CACHE_MAX_USERS, + maxTotalWeight: CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT, + maxUserWeight: MAX_ADDRESS_BOOK_WEIGHT, + idleMs: CONTACT_BOOK_CACHE_IDLE_MS, + }); + private readonly restorations: ContactRestoreStore; + /** 읽는 중인 주소록 — 같은 직원의 동시 요청은 한 번만 읽는다. 끝나면 빠진다 */ + private readonly loading = new Map(); + /** 같은 직원의 바꾸기 차례 — 끝나면 빠진다 */ + private readonly writeQueues = new Map>(); + + constructor( + @Inject(CONTACTS_PROVIDER) private readonly provider: ContactsProvider, + private readonly directory: DirectoryService, + @Optional() @Inject(CONTACTS_CLOCK) clock?: () => number, + ) { + this.restorations = new ContactRestoreStore( + { + ttlMs: CONTACT_RESTORE_TTL_MS, + maxPerUser: CONTACT_RESTORE_MAX_PER_USER, + maxCardBytes: CONTACT_RESTORE_MAX_CARD_BYTES, + maxTotalBytes: CONTACT_RESTORE_MAX_TOTAL_BYTES, + tokenBytes: CONTACT_RESTORE_TOKEN_BYTES, + }, + clock ?? (() => Date.now()), + ); + } + + /** + * GET /api/contacts — 이름 차례, 찾는 글 · 그룹(그 구성원만 · 그 구성원을 빼고)으로 거른 한 쪽 (total 포함). + * 커서는 엔진에 묻기 전에 확인한다 + */ + async list( + user: AuthenticatedUser, + query: ContactListQueryDto, + ): Promise> { + const { groupId, excludeGroupId } = query; + if (groupId !== undefined && excludeGroupId !== undefined) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-group-filters-together' }, + }); + } + const q = query.q?.trim() ? query.q.trim() : undefined; + const scope = listScope('contacts', q, groupId, excludeGroupId); + const start = cursorOffset(query.cursor, scope); + + const book = await this.freshBook(user.email); + const only = + groupId === undefined + ? null + : new Set(this.requireGroup(book, groupId).memberIds); + const except = + excludeGroupId === undefined + ? null + : new Set(this.requireGroup(book, excludeGroupId).memberIds); + const search = parseContactSearch(q); + const matched = book.contacts + .filter( + (entry) => + (only === null || only.has(entry.card.id)) && + (except === null || !except.has(entry.card.id)) && + (search === null || matchesContact(entry.search, search)), + ) + .map((entry) => entry.contact); + return pageOf(matched, start, query.limit, scope); + } + + /** GET /api/contacts/{id} */ + async get(user: AuthenticatedUser, id: string): Promise { + const book = await this.freshBook(user.email); + return this.requireContact(book, id).contact; + } + + /** POST /api/contacts — 새 연락처(uid 를 서버가 넣는다) + 고른 그룹에 넣기를 한 번에 */ + async create(user: AuthenticatedUser, input: ContactInput): Promise { + this.assertNameOrEmail(input); + this.assertDistinctEmails(input.emails); + return this.change(user, (book, attempt) => { + const groups = input.groupIds.map((id) => this.requireGroup(book, id)); + const uid = attempt.uid(NEW_CONTACT_KEY); + const landed = attempt.uncertain + ? book.contactsByUid.get(uid)?.[0] + : undefined; + if (landed) { + // 앞 시도가 들어갔다 — 빠진 그룹만 마저 넣는다 + const missing = groups.filter( + (group) => !group.card.memberUids.includes(uid), + ); + for (const group of missing) this.assertRoomFor(group, 1); + return { + changes: { + create: [], + update: joinGroups(missing, uid), + destroy: [], + }, + result: (after) => this.requireContact(after, landed.card.id).contact, + log: `연락처를 만들었습니다(앞 시도를 이어서): 그룹 ${missing.length}개`, + }; + } + + if (book.contacts.length >= MAX_CONTACTS) { + throw overLimit('contacts-limit', { contacts: book.contacts.length }); + } + for (const group of groups) this.assertRoomFor(group, 1); + return { + changes: { + create: [ + { + kind: 'contact', + key: NEW_CONTACT_KEY, + uid, + fields: fieldsOf(input), + }, + ], + update: joinGroups(groups, uid), + destroy: [], + }, + result: (after, written) => + this.requireContact( + after, + written.createdIds.get(NEW_CONTACT_KEY) ?? '', + ).contact, + log: `연락처를 만들었습니다: 그룹 ${groups.length}개`, + }; + }); + } + + /** + * PATCH /api/contacts/{id} — 보낸 칸만 바꾼다(하나 이상 — 비면 400 VAL_001). "이름이나 이메일"은 지금 연락처와 합친 결과로 본다. + * 바뀐 칸만 엔진에 보내고, 칸 안의 남의 정보는 어댑터가 지킨다(계약 3절 "고치기"). groupIds 를 보내면 그룹에 넣고 뺀다 — + * uid 가 없는 카드(다른 프로그램이 만든 것)는 그룹에 넣을 수 없다(400 VAL_001) + */ + async patch( + user: AuthenticatedUser, + id: string, + patch: ContactPatch, + ): Promise { + const sent = Object.values(patch).filter((value) => value !== undefined); + if (sent.length === 0) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-patch-empty' }, + }); + } + if (patch.emails !== undefined) this.assertDistinctEmails(patch.emails); + + return this.change(user, (book) => { + const entry = this.requireContact(book, id); + const next = mergedFields(entry.contact, patch); + this.assertNameOrEmail(next); + + const current = new Set(entry.contact.groupIds); + const wantedIds = patch.groupIds ?? entry.contact.groupIds; + const wanted = wantedIds.map((groupId) => + this.requireGroup(book, groupId), + ); + const wantedSet = new Set(wantedIds); + const joining = wanted.filter((group) => !current.has(group.card.id)); + const leaving = entry.contact.groupIds + .filter((groupId) => !wantedSet.has(groupId)) + .map((groupId) => this.requireGroup(book, groupId)); + + const { uid } = entry.card; + if (joining.length > 0 && uid === null) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-without-uid', id }, + }); + } + for (const group of joining) this.assertRoomFor(group, 1); + + const fields = changedFields(entry.contact, next); + const update: AddressBookCardUpdate[] = []; + if (Object.keys(fields).length > 0) { + update.push({ kind: 'contact', id, fields }); + } + if (uid !== null) { + update.push(...joinGroups(joining, uid)); + for (const group of leaving) { + update.push({ + kind: 'group', + id: group.card.id, + memberUids: withMembers(group, [], new Set([uid])), + }); + } + } + return { + changes: { create: [], update, destroy: [] }, + result: (after) => this.requireContact(after, id).contact, + log: `연락처를 고쳤습니다: id=${id}, 칸 ${Object.keys(fields).length}개, 그룹 +${joining.length} -${leaving.length}`, + }; + }); + } + + /** + * DELETE /api/contacts/{id} — 영구 삭제(엔진 주소록에는 휴지통이 없다). 이 연락처를 가리키는 그룹 구성원도 함께 뺀다 — + * 같은 uid 의 다른 카드가 남아 있으면 그 카드의 구성원 자격은 그대로 둔다. 응답은 지운 연락처와 되돌리기 표 + */ + async remove(actor: AuthenticatedUser, id: string): Promise { + const owner = normalizeUser(actor.email); + let first: RemovedContact | null = null; + return this.change(actor, (book, attempt) => { + const entry = book.contactsById.get(id); + if (!entry) { + // 앞 시도가 들어갔다 — 그때 읽은 것으로 답한다 + if (attempt.uncertain && first) { + const removed = first; + return { + changes: NO_CHANGES, + result: (_after, written) => + this.contactDeleted(owner, removed, written.removed.get(id)), + }; + } + throw notFound('contact-not-found'); + } + const { uid } = entry.card; + const sharedUid = + uid !== null && + book.contacts.some( + (other) => other.card.id !== id && other.card.uid === uid, + ); + const leaving = + uid === null || sharedUid + ? [] + : book.groups.filter((group) => group.card.memberUids.includes(uid)); + const removed: RemovedContact = { + contact: entry.contact, + card: entry.card, + groups: entry.contact.groupIds.flatMap((groupId) => { + const group = book.groupsById.get(groupId); + return group ? [{ id: group.card.id, uid: group.card.uid }] : []; + }), + }; + first = removed; + return { + changes: { + create: [], + update: leaving.map((group) => ({ + kind: 'group', + id: group.card.id, + memberUids: withMembers(group, [], new Set([uid ?? ''])), + })), + destroy: [id], + }, + result: (_after, written) => + this.contactDeleted(owner, removed, written.removed.get(id)), + log: `연락처를 지웠습니다: id=${id}, 그룹에서 뺌 ${leaving.length}개`, + }; + }); + } + + /** GET /api/contact-groups — 이름 차례 (total 포함) */ + async listGroups( + user: AuthenticatedUser, + query: CursorPaginationQueryDto, + ): Promise> { + const scope = listScope('contact-groups'); + const start = cursorOffset(query.cursor, scope); + const book = await this.freshBook(user.email); + return pageOf( + book.groups.map((entry) => entry.group), + start, + query.limit, + scope, + ); + } + + /** POST /api/contact-groups — 그룹 100개 · 같은 이름을 본다 (지운 그룹은 restore 로 되돌린다) */ + async createGroup( + user: AuthenticatedUser, + input: { readonly name: string }, + ): Promise { + return this.change(user, (book, attempt) => { + const uid = attempt.uid(NEW_GROUP_KEY); + const landed = attempt.uncertain + ? book.groups.find((group) => group.card.uid === uid) + : undefined; + if (landed) { + return { changes: NO_CHANGES, result: () => landed.group }; + } + if (book.groups.length >= MAX_CONTACT_GROUPS) { + throw overLimit('contact-groups-limit', { groups: book.groups.length }); + } + this.assertGroupNameFree(book, input.name); + return { + changes: { + create: [ + { + kind: 'group', + key: NEW_GROUP_KEY, + uid, + name: input.name, + memberUids: [], + }, + ], + update: [], + destroy: [], + }, + result: (after, written) => + this.requireGroup(after, written.createdIds.get(NEW_GROUP_KEY) ?? '') + .group, + log: '연락처 그룹을 만들었습니다', + }; + }); + } + + /** PATCH /api/contact-groups/{id} — 이름만 바꾼다 (같은 이름은 그대로 두고 돌려준다) */ + async renameGroup( + user: AuthenticatedUser, + id: string, + name: string, + ): Promise { + return this.change(user, (book) => { + const group = this.requireGroup(book, id); + if (group.card.name === name) { + return { changes: NO_CHANGES, result: () => group.group }; + } + this.assertGroupNameFree(book, name, id); + return { + changes: { + create: [], + update: [{ kind: 'group', id, name }], + destroy: [], + }, + result: (after) => this.requireGroup(after, id).group, + log: `연락처 그룹 이름을 바꿨습니다: id=${id}`, + }; + }); + } + + /** DELETE /api/contact-groups/{id} — 그룹 카드만 지운다(연락처는 그대로). 응답은 이름 · 구성원과 되돌리기 표 */ + async removeGroup( + actor: AuthenticatedUser, + id: string, + ): Promise { + const owner = normalizeUser(actor.email); + let first: RemovedGroup | null = null; + return this.change(actor, (book, attempt) => { + const group = book.groupsById.get(id); + if (!group) { + if (attempt.uncertain && first) { + const removed = first; + return { + changes: NO_CHANGES, + result: (_after, written) => + this.groupDeleted(owner, removed, written.removed.get(id)), + }; + } + throw notFound('contact-group-not-found'); + } + const removed: RemovedGroup = { group }; + first = removed; + return { + changes: { create: [], update: [], destroy: [id] }, + result: (_after, written) => + this.groupDeleted(owner, removed, written.removed.get(id)), + log: `연락처 그룹을 지웠습니다: id=${id}, 구성원 ${group.memberIds.length}명`, + }; + }); + } + + /** + * POST /api/contact-restorations — 지운 연락처 · 그룹을 원본 그대로 다시 만든다 (계약 3절 "되돌리기"). + * 같은 uid(그사이 이 주소록에 같은 uid 의 카드가 생겼으면 새 uid). 연락처는 그때 속했고 지금도 있는 그룹에 다시 넣고(500명이 찬 그룹은 건너뛴다), + * 그룹은 그때의 구성원 가운데 지금도 있는 연락처로 만든다. 표는 성공하면 지운다 — 없거나 · 지났거나 · 다른 직원의 것이면 404 RES_001 + */ + async restore( + actor: AuthenticatedUser, + token: string, + ): Promise { + const owner = normalizeUser(actor.email); + let plannedUid: string | null = null; + return this.change(actor, (book, attempt) => { + const entry = this.restorations.find(owner, token); + if (!entry) throw notFound('contact-restore-token-not-found'); + + if (attempt.uncertain && plannedUid !== null) { + const landed = this.landedRestore(book, entry, plannedUid, token); + if (landed) return landed; + } + const uid = + [entry.card.uid, attempt.uid(RESTORED_KEY)].find( + (candidate): candidate is string => + candidate !== null && !book.uidsInUse.has(candidate), + ) ?? newCardUid(); + plannedUid = uid; + + if (entry.kind === 'contact') { + if (book.contacts.length >= MAX_CONTACTS) { + throw overLimit('contacts-limit', { contacts: book.contacts.length }); + } + const groups = this.restorableGroups(book, entry.groups, uid); + return { + changes: { + create: [ + { + kind: 'restored', + key: RESTORED_KEY, + uid, + original: entry.original, + memberUids: null, + card: { kind: 'contact', card: entry.card }, + }, + ], + update: joinGroups(groups, uid), + destroy: [], + }, + result: (after, written) => { + this.restorations.forget(token); + return { + kind: 'contact', + contact: this.requireContact( + after, + written.createdIds.get(RESTORED_KEY) ?? '', + ).contact, + }; + }, + log: `지운 연락처를 되돌렸습니다: 그룹 ${groups.length}개`, + }; + } + + if (book.groups.length >= MAX_CONTACT_GROUPS) { + throw overLimit('contact-groups-limit', { groups: book.groups.length }); + } + this.assertGroupNameFree(book, entry.card.name); + const memberUids = [ + ...new Set( + entry.card.memberUids.filter((member) => + book.contactsByUid.has(member), + ), + ), + ]; + return { + changes: { + create: [ + { + kind: 'restored', + key: RESTORED_KEY, + uid, + original: entry.original, + memberUids, + card: { kind: 'group', card: entry.card }, + }, + ], + update: [], + destroy: [], + }, + result: (after, written) => { + this.restorations.forget(token); + return { + kind: 'group', + group: this.requireGroup( + after, + written.createdIds.get(RESTORED_KEY) ?? '', + ).group, + }; + }, + log: `지운 연락처 그룹을 되돌렸습니다: 구성원 ${memberUids.length}명`, + }; + }); + } + + /** + * POST /api/contact-groups/{id}/members — 내 연락처(contactIds)와 사내 목록(directoryIds)에서 넣는다 (합쳐 1~100개). + * 사내 목록에서 고른 사람은 같은 주소(대소문자 무시)의 연락처가 있으면 그 연락처를, 없으면 이름 · 주소로 새로 만들어 넣는다. + * 이미 구성원인 연락처 · uid 가 없는 카드(그룹에 넣을 수 없다)는 세지 않는다. 한도(연락처 3,000 · 구성원 500 · 크기)를 넘으면 아무것도 하지 않고 CONTACT_002 + */ + async addGroupMembers( + user: AuthenticatedUser, + id: string, + input: { + readonly contactIds?: readonly string[]; + readonly directoryIds?: readonly string[]; + }, + ): Promise { + const contactIds = input.contactIds ?? []; + const directoryIds = input.directoryIds ?? []; + const requested = contactIds.length + directoryIds.length; + if (requested < 1 || requested > MAX_GROUP_MEMBERS_PER_REQUEST) { + throw new BusinessException('VAL_001', { + context: { reason: 'group-members-count', requested }, + }); + } + // 사내 목록은 바꾸기 차례 밖에서 — 모든 직원이 함께 기억하는 목록이다 (읽지 못하면 MAIL_001, 없는 id 는 RES_001) + const people = await this.directory.findByIds(directoryIds); + + return this.change(user, (book, attempt) => { + const group = this.requireGroup(book, id); + const cards = contactIds.map( + (contactId) => this.requireContact(book, contactId).card, + ); + const create: NewAddressBookCard[] = []; + const planned = new Set(); + for (const person of people) { + const key = emailKey(person.email); + // 같은 주소의 연락처가 여럿이면 그룹에 넣을 수 있는(uid 가 있는) 것부터 — 앞 시도가 만든 연락처도 이렇게 찾는다 + const owners = book.contactsByEmail.get(key) ?? []; + const existing = + owners.find((owner) => owner.card.uid !== null) ?? owners[0]; + if (existing) { + cards.push(existing.card); + } else if (!planned.has(key)) { + planned.add(key); + const memberKey = `${NEW_MEMBER_KEY_PREFIX}${create.length}`; + create.push({ + kind: 'contact', + key: memberKey, + uid: attempt.uid(`${NEW_MEMBER_KEY_PREFIX}${key}`), + fields: { + name: person.name, + emails: [{ address: person.email, label: 'work' }], + phones: [], + company: null, + department: null, + title: null, + note: null, + }, + }); + } + } + + const already = new Set(group.card.memberUids); + const adding = [ + ...new Set([ + ...cards + .map((card) => card.uid) + .filter((uid): uid is string => uid !== null && !already.has(uid)), + ...create.map((card) => card.uid), + ]), + ]; + if (book.contacts.length + create.length > MAX_CONTACTS) { + throw overLimit('contacts-limit', { + contacts: book.contacts.length, + creating: create.length, + }); + } + this.assertRoomFor(group, adding.length); + + const changes: AddressBookChanges = + adding.length === 0 + ? NO_CHANGES + : { + create, + update: [ + { + kind: 'group', + id, + memberUids: withMembers(group, adding), + }, + ], + destroy: [], + }; + return { + changes, + result: (after) => ({ + group: this.requireGroup(after, id).group, + addedCount: adding.length, + createdCount: create.length, + }), + log: `연락처 그룹에 넣었습니다: id=${id}, 넣음 ${adding.length}명, 새 연락처 ${create.length}명`, + }; + }); + } + + /** + * GET /api/directory-entries — 사내 목록(directory.service.ts)에서 excludeGroupId 면 그 그룹(이 직원의 주소록) 구성원이 가진 주소를 뺀다. + * 그룹은 커서를 확인한 뒤에 찾는다 — 없는 그룹이면 404 RES_001 + */ + listDirectory( + user: AuthenticatedUser, + query: DirectoryEntryQueryDto, + ): Promise> { + const { excludeGroupId } = query; + return this.directory.list( + query, + excludeGroupId === undefined + ? undefined + : () => this.groupMemberEmails(user.email, excludeGroupId), + ); + } + + /** + * 받는 사람 추천의 주소록 묶음 (계약 3절 "메일과 함께") — 연락처의 이름 차례, 주소마다 한 줄(대표 주소가 먼저). + * 요청마다 판을 확인한 주소록에서 만든다 — 연락처를 바꾸면 다음 추천에 바로 보인다 + */ + async suggestionLines( + userEmail: string, + ): Promise { + return (await this.freshBook(userEmail)).suggestionLines; + } + + /** 그룹 구성원이 가진 주소 (소문자) — 없는 그룹이면 RES_001 */ + private async groupMemberEmails( + userEmail: string, + groupId: string, + ): Promise> { + const book = await this.freshBook(userEmail); + const group = this.requireGroup(book, groupId); + return new Set( + group.memberIds.flatMap( + (memberId) => + book.contactsById + .get(memberId) + ?.contact.emails.map((email) => emailKey(email.address)) ?? [], + ), + ); + } + + /** 지운 연락처의 응답 — 원본을 읽었으면 되돌리기 표를 준다(너무 크면 null) */ + private contactDeleted( + owner: string, + removed: RemovedContact, + original: RemovedCardOriginal | undefined, + ): ContactDeleted { + return { + contact: removed.contact, + restoreToken: this.rememberRemoved(owner, original, (source) => ({ + kind: 'contact', + original: source, + card: removed.card, + groups: removed.groups, + })), + }; + } + + private groupDeleted( + owner: string, + removed: RemovedGroup, + original: RemovedCardOriginal | undefined, + ): ContactGroupDeleted { + const { group } = removed; + return { + id: group.card.id, + name: group.card.name, + memberIds: group.memberIds, + restoreToken: this.rememberRemoved(owner, original, (source) => ({ + kind: 'group', + original: source, + card: group.card, + })), + }; + } + + private rememberRemoved( + owner: string, + original: RemovedCardOriginal | undefined, + build: (original: RemovedCardOriginal) => RestorableCard, + ): string | null { + if (!original) return null; + const token = this.restorations.remember(owner, build(original)); + if (token === null) { + this.logger.warn( + `${traceTag()} 지운 카드가 커서 되돌리기 표를 주지 않았습니다: user=${maskEmail(owner)}, bytes=${original.bytes}`, + ); + } + return token; + } + + /** 앞 시도의 되돌리기가 들어갔다 — 연락처는 빠진 그룹만 마저 넣는다 */ + private landedRestore( + book: BookView, + entry: RestorableCard, + uid: string, + token: string, + ): ChangePlan | null { + if (entry.kind === 'contact') { + const landed = book.contactsByUid.get(uid)?.[0]; + if (!landed) return null; + const groups = this.restorableGroups(book, entry.groups, uid); + return { + changes: { create: [], update: joinGroups(groups, uid), destroy: [] }, + result: (after) => { + this.restorations.forget(token); + return { + kind: 'contact', + contact: this.requireContact(after, landed.card.id).contact, + }; + }, + log: `지운 연락처를 되돌렸습니다(앞 시도를 이어서): 그룹 ${groups.length}개`, + }; + } + const landed = book.groups.find((group) => group.card.uid === uid); + if (!landed) return null; + return { + changes: NO_CHANGES, + result: () => { + this.restorations.forget(token); + return { kind: 'group', group: landed.group }; + }, + }; + } + + /** 지운 연락처가 다시 들어갈 그룹 — 그때 속했고 지금도 있는 그룹(uid 로, 없으면 id 로), 이미 든 그룹 · 500명이 찬 그룹은 건너뛴다 */ + private restorableGroups( + book: BookView, + refs: readonly RestorableGroupRef[], + uid: string, + ): BookGroup[] { + const found = new Map(); + for (const ref of refs) { + const group = + ref.uid !== null + ? book.groups.find((candidate) => candidate.card.uid === ref.uid) + : book.groupsById.get(ref.id); + if ( + group && + !found.has(group.card.id) && + !group.card.memberUids.includes(uid) && + group.group.memberCount < MAX_CONTACT_GROUP_MEMBERS + ) { + found.set(group.card.id, group); + } + } + return [...found.values()]; + } + + /** 판을 엔진에 물어 기억한 주소록과 견준다 — 같으면 그대로, 다르면 다시 읽는다 */ + private async freshBook(userEmail: string): Promise { + const user = normalizeUser(userEmail); + const version = await this.provider.getAddressBookVersion(user); + return this.books.get(user, version) ?? this.load(user); + } + + private load(user: string): Promise { + const pending = this.loading.get(user); + if (pending) return pending.promise; + + const token = {}; + const promise = this.provider + .readAddressBook(user) + .then((snapshot) => { + const view = buildBookView(snapshot); + // 읽는 사이 바꾸기가 끝났으면(그 결과를 이미 기억했다) 옛 결과는 기억하지 않는다 + if (this.loading.get(user)?.token === token) this.books.set(user, view); + return view; + }) + .finally(() => { + if (this.loading.get(user)?.token === token) this.loading.delete(user); + }); + this.loading.set(user, { token, promise }); + return promise; + } + + /** + * 바꾸기 — 같은 직원의 바꾸기는 차례로, 읽은 판 그대로일 때만 쓴다. 그사이 바뀌거나(conflict) 엔진이 동시 고치기를 거절하면(busy) + * 다시 읽고 다시 정한다 — busy 뒤의 계획은 앞 시도가 들어갔는지(같은 uid 의 카드 · 지운 카드) 보고 이어 한다. + * plan 이 던지면(RES_001 · CONTACT_001 · CONTACT_002 · VAL_001) 아무것도 쓰지 않는다. 크기 한도도 쓰기 전에 본다 + */ + private change( + actor: AuthenticatedUser, + plan: (book: BookView, attempt: WriteAttempt) => ChangePlan, + ): Promise { + const user = normalizeUser(actor.email); + return this.inWriteQueue(user, async () => { + const uids = new Map(); + const removed = new Map(); + let uncertain = false; + let busy = false; + const attempt: WriteAttempt = { + uid: (key) => { + const known = uids.get(key); + if (known !== undefined) return known; + const uid = newCardUid(); + uids.set(key, uid); + return uid; + }, + get uncertain() { + return uncertain; + }, + }; + + for (let tries = 1; tries <= CONTACT_WRITE_ATTEMPTS; tries += 1) { + const book = await this.freshBook(user); + const planned = plan(book, attempt); + if (isEmpty(planned.changes)) { + return planned.result(book, { createdIds: new Map(), removed }); + } + this.assertWeight(book, planned.changes); + + let outcome; + try { + outcome = await this.provider.writeAddressBook( + user, + book.version, + planned.changes, + ); + } catch (error) { + // 일부만 바뀌었을 수 있다 — 기억한 것을 버리고 다음 요청은 다시 읽는다 + this.forget(user); + throw error; + } + if (outcome.kind === 'conflict') { + busy = false; + continue; + } + for (const [id, original] of outcome.removed) removed.set(id, original); + if (outcome.kind === 'busy') { + uncertain = true; + busy = true; + continue; + } + + const after = this.afterWrite(user, book, planned.changes, outcome); + if (planned.log) { + this.logger.log( + `${traceTag()} ${planned.log} (user=${maskEmail(user)})`, + ); + } + return planned.result(after, { + createdIds: outcome.createdIds, + removed, + }); + } + throw new BusinessException('MAIL_001', { + context: { + reason: busy ? 'address-book-busy' : 'address-book-keeps-changing', + attempts: CONTACT_WRITE_ATTEMPTS, + user: maskEmail(user), + }, + }); + }); + } + + /** 쓴 바꾸기를 기억한 주소록에 옮긴다 — 새 판을 모르면 응답에만 쓰고 기억하지 않는다(다음 요청이 다시 읽는다) */ + private afterWrite( + user: string, + book: BookView, + changes: AddressBookChanges, + outcome: WrittenChanges & { readonly version: string | null }, + ): BookView { + const next = applyAddressBookWrite( + book.snapshot, + changes, + outcome, + outcome.version ?? '', + ); + if (next === null) { + this.forget(user); + throw new BusinessException('MAIL_001', { + context: { reason: 'created-card-id-missing' }, + }); + } + const view = buildBookView(next); + // 읽는 중이던 옛 주소록이 이 결과를 덮지 않게 읽기를 놓는다 + this.loading.delete(user); + if (outcome.version === null) this.books.delete(user); + else this.books.set(user, view); + return view; + } + + private forget(user: string): void { + this.loading.delete(user); + this.books.delete(user); + } + + /** 같은 직원의 바꾸기를 차례로 돌린다 (앞 바꾸기가 실패해도 다음 바꾸기는 돈다) */ + private inWriteQueue(user: string, task: () => Promise): Promise { + const previous = this.writeQueues.get(user) ?? Promise.resolve(); + const run = previous.then(task, task); + const tail = run.then( + () => undefined, + () => undefined, + ); + this.writeQueues.set(user, tail); + void tail.then(() => { + if (this.writeQueues.get(user) === tail) this.writeQueues.delete(user); + }); + return run; + } + + /** 이름이나 이메일 가운데 하나 — 엔진에 묻기 전에 본다 */ + private assertNameOrEmail(fields: Pick) { + if (fields.name === null && fields.emails.length === 0) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-name-or-email-required' }, + }); + } + } + + /** 같은 연락처 안의 같은 주소(대소문자 무시) */ + private assertDistinctEmails(emails: readonly ContactEmail[]): void { + const addresses = new Set(emails.map((email) => emailKey(email.address))); + if (addresses.size !== emails.length) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-duplicate-email' }, + }); + } + } + + /** 크기 한도 — 이 바꾸기로 주소록이 800만을 넘게 커지면 CONTACT_002 (줄이는 쓰기 · 지우기는 늘 된다) */ + private assertWeight(book: BookView, changes: AddressBookChanges): void { + const after = weightAfterChanges(book, changes); + if (after > MAX_ADDRESS_BOOK_WEIGHT && after > book.weight) { + throw overLimit('address-book-too-large', { + weight: book.weight, + after, + }); + } + } + + /** 이 그룹에 count 명을 더 넣을 수 있는가 — 넘으면 CONTACT_002 */ + private assertRoomFor(group: BookGroup, count: number): void { + if (group.group.memberCount + count > MAX_CONTACT_GROUP_MEMBERS) { + throw overLimit('contact-group-members-limit', { + groupId: group.card.id, + members: group.group.memberCount, + adding: count, + }); + } + } + + /** 같은 이름의 다른 그룹이 있으면 CONTACT_001 */ + private assertGroupNameFree( + book: BookView, + name: string, + exceptId?: string, + ): void { + const key = groupNameKey(name); + const taken = book.groups.some( + (group) => + group.card.id !== exceptId && groupNameKey(group.card.name) === key, + ); + if (taken) { + throw new BusinessException('CONTACT_001', { + context: { reason: 'contact-group-name-taken' }, + }); + } + } + + private requireContact(book: BookView, id: string): BookContact { + const entry = book.contactsById.get(id); + if (!entry) throw notFound('contact-not-found'); + return entry; + } + + private requireGroup(book: BookView, id: string): BookGroup { + const entry = book.groupsById.get(id); + if (!entry) throw notFound('contact-group-not-found'); + return entry; + } +} diff --git a/backend/src/modules/contacts/contacts.types.ts b/backend/src/modules/contacts/contacts.types.ts new file mode 100644 index 0000000..043708f --- /dev/null +++ b/backend/src/modules/contacts/contacts.types.ts @@ -0,0 +1,131 @@ +/** + * 주소록 도메인 타입 — API 계약(docs/mail/api-contract.md 3절 "주소록")과 같은 모양이다. + * + * 메일 엔진(JMAP Contacts · Principal)의 타입은 어댑터(modules/mail/jmap/) 밖으로 나오지 않는다. 엔진을 바꿔도 이 타입은 그대로다. + */ + +/** 이메일의 종류 — 회사 · 개인 · 기타 */ +export type ContactEmailLabel = 'work' | 'personal' | 'other'; + +/** 전화번호의 종류 — 휴대전화 · 회사 · 집 · 기타 */ +export type ContactPhoneLabel = 'mobile' | 'work' | 'home' | 'other'; + +export const CONTACT_EMAIL_LABELS: readonly ContactEmailLabel[] = [ + 'work', + 'personal', + 'other', +]; + +export const CONTACT_PHONE_LABELS: readonly ContactPhoneLabel[] = [ + 'mobile', + 'work', + 'home', + 'other', +]; + +export interface ContactEmail { + readonly address: string; + readonly label: ContactEmailLabel; +} + +export interface ContactPhone { + readonly number: string; + readonly label: ContactPhoneLabel; +} + +/** 우리가 다루는 연락처 칸 — 그룹은 따로 (그룹 카드의 구성원으로 잇는다) */ +export interface ContactFields { + /** 표시 이름 — 없으면 null */ + readonly name: string | null; + /** 앞의 것이 대표 주소 — 받는 사람 추천의 첫 줄 · 그룹으로 보낼 때 쓴다 */ + readonly emails: readonly ContactEmail[]; + readonly phones: readonly ContactPhone[]; + readonly company: string | null; + readonly department: string | null; + /** 직책 */ + readonly title: string | null; + /** 메모 (줄바꿈 있음) */ + readonly note: string | null; +} + +/** 연락처 (계약 3절 Contact) */ +export interface Contact extends ContactFields { + readonly id: string; + /** 속한 그룹 — 이 주소록의 그룹만 */ + readonly groupIds: readonly string[]; +} + +/** POST 본문 (계약 3절 ContactInput) — 요청 검증이 앞뒤 공백을 지운 값(빈 글은 null) */ +export interface ContactInput extends ContactFields { + readonly groupIds: readonly string[]; +} + +/** + * PATCH 본문 (계약 3절 ContactPatch) — 바꾼 칸만(하나 이상). 보내지 않은 칸(undefined)은 검사하지도 바꾸지도 않는다. + * emails · phones · groupIds 는 보내면 그 칸이 통째로 바뀐다 + */ +export type ContactPatch = Partial; + +/** 연락처 지우기의 응답 (계약 3절 ContactDeleted) */ +export interface ContactDeleted { + /** 지운 연락처 */ + readonly contact: Contact; + /** [되돌리기] 표 — 서버가 기억하지 못했으면 null (카드가 256KB 를 넘음 등) */ + readonly restoreToken: string | null; +} + +/** 그룹 (계약 3절 ContactGroup) */ +export interface ContactGroup { + readonly id: string; + readonly name: string; + /** 이 주소록에 있는 연락처 가운데 구성원 수 */ + readonly memberCount: number; +} + +/** 그룹 지우기의 응답 (계약 3절 ContactGroupDeleted) */ +export interface ContactGroupDeleted { + readonly id: string; + readonly name: string; + readonly memberIds: readonly string[]; + /** [되돌리기] 표 — 서버가 기억하지 못했으면 null */ + readonly restoreToken: string | null; +} + +/** 되돌리기의 응답 (계약 3절 ContactRestored) — 다시 만든 연락처 · 그룹 (id 는 새로 받는다) */ +export type ContactRestored = + | { readonly kind: 'contact'; readonly contact: Contact } + | { readonly kind: 'group'; readonly group: ContactGroup }; + +export interface ContactGroupMembersAdded { + readonly group: ContactGroup; + /** 이번에 그룹에 들어간 수 (이미 구성원이던 것은 세지 않는다) */ + readonly addedCount: number; + /** 사내 목록에서 골라 내 주소록에 새로 만든 연락처 수 */ + readonly createdCount: number; +} + +/** 사내 목록의 종류 — 직원(개인 계정) · 공용 주소(그룹 계정) */ +export type DirectoryEntryKind = 'person' | 'shared'; + +export const DIRECTORY_ENTRY_KINDS: readonly DirectoryEntryKind[] = [ + 'person', + 'shared', +]; + +/** 사내 목록 한 줄 (계약 3절 DirectoryEntry) */ +export interface DirectoryEntry { + /** 엔진의 계정 id */ + readonly id: string; + /** 엔진의 표시 이름 — 없으면 주소의 @ 앞 */ + readonly name: string; + readonly email: string; + readonly kind: DirectoryEntryKind; +} + +/** 받는 사람 추천에 넣는 주소록의 한 줄 — 연락처의 주소 하나 (계약 3절 "메일과 함께") */ +export interface ContactSuggestionLine { + readonly name: string | null; + readonly email: string; + readonly company: string | null; + readonly emailLabel: ContactEmailLabel; +} diff --git a/backend/src/modules/contacts/directory-entries.controller.ts b/backend/src/modules/contacts/directory-entries.controller.ts new file mode 100644 index 0000000..d419d67 --- /dev/null +++ b/backend/src/modules/contacts/directory-entries.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Get, + Header, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { ContactIdPipe } from './contact-id.pipe'; +import { + DIRECTORY_ENTRIES_ROUTE, + MAX_CONTACT_QUERY_LENGTH, +} from './contacts.constants'; +import { ContactsService } from './contacts.service'; +import { DirectoryService } from './directory.service'; +import { + DirectoryEntryDto, + DirectoryEntryPageDto, + DirectoryEntryQueryDto, +} from './dto/directory-entry.dto'; + +const NO_STORE = 'private, no-store'; +const UNREADABLE = + '사내 목록을 읽지 못함 — 사내 목록 계정이 없음(메일 엔진 설정 스크립트를 복구 관리자로 실행해야 만든다) · 권한 · 엔진 장애'; + +// 사내 목록 (계약 3절 "사내 목록", 8단계) — 엔진의 계정 목록에서 직원 · 공용 주소의 이름 · 주소만. 모든 직원이 같은 목록을 본다 +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(DIRECTORY_ENTRIES_ROUTE) +export class DirectoryEntriesController { + constructor( + private readonly directory: DirectoryService, + private readonly contacts: ContactsService, + ) {} + + @Get() + @Header('Cache-Control', NO_STORE) + @ApiOperation({ + summary: '사내 목록 (커서 페이지네이션 · total 포함)', + description: + '엔진의 개인 계정은 직원(person), 그룹 계정은 공용 주소(shared). 이름은 엔진의 표시 이름(없으면 주소의 @ 앞). ' + + '엔진 관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정(MAIL_BLOCKED_ACCOUNTS) · 메일 엔진 설정 스크립트가 만든 계정과 사내 도메인이 아닌 · 주소가 없는 계정은 뺀다. ' + + '이름 차례(한국어 사전 차례 → 주소 → id). q 는 초성만 친 글이면 이름의 초성과, 그 밖에는 이름 · 주소에 든 것(ASCII 대소문자 무시). ' + + 'excludeGroupId 면 내 주소록의 그 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다(구성원 넣기 창). ' + + '커서는 받은 그 q · kind · excludeGroupId 로만 쓴다. 서버는 목록을 10분 기억한다(모든 직원이 함께). Cache-Control: private, no-store.', + }) + @ApiStandardResponse(DirectoryEntryPageDto, { description: '사내 목록' }) + @ApiErrorResponse( + 'VAL_001', + `q ${MAX_CONTACT_QUERY_LENGTH}자 초과 · kind 가 person · shared 가 아님 · 잘못된 커서 · 다른 q · kind · excludeGroupId 의 커서 · limit 범위 밖`, + ) + @ApiErrorResponse('RES_001', '없는 excludeGroupId (내 주소록의 그룹)') + @ApiErrorResponse('MAIL_001', UNREADABLE) + list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: DirectoryEntryQueryDto, + ) { + return this.contacts.listDirectory(user, query); + } + + @Get(':id') + @Header('Cache-Control', NO_STORE) + @ApiOperation({ + summary: '사내 목록 한 항목', + description: + '기억한 사내 목록(10분)에서 찾는다 — 화면이 주소로 연 사내 항목. 빼는 계정(관리자 · 게이트웨이 · 막은 계정 등)은 없는 것과 같다.', + }) + @ApiStandardResponse(DirectoryEntryDto, { description: '사내 목록 한 항목' }) + @ApiErrorResponse('RES_001', '없거나 빼는 계정 (형식이 틀린 id 포함)') + @ApiErrorResponse('MAIL_001', UNREADABLE) + get(@Param('id', ContactIdPipe) id: string) { + return this.directory.findById(id); + } +} diff --git a/backend/src/modules/contacts/directory-policy.spec.ts b/backend/src/modules/contacts/directory-policy.spec.ts new file mode 100644 index 0000000..5704fd9 --- /dev/null +++ b/backend/src/modules/contacts/directory-policy.spec.ts @@ -0,0 +1,123 @@ +import type { DirectoryAccount } from './contacts.provider'; +import { + SETUP_MANAGED_MARK, + toDirectoryEntries, + type DirectoryPolicy, +} from './directory-policy'; + +const policy: DirectoryPolicy = { + domain: 'arasystem.test', + gatewayUser: 'mail-gateway@arasystem.test', + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + 'ops@arasystem.test', + ]), +}; + +const account = ( + id: string, + email: string | null, + displayName: string | null = null, + kind: DirectoryAccount['kind'] = 'person', +): DirectoryAccount => ({ id, kind, displayName, email }); + +describe('사내 목록에 넣는 계정 (toDirectoryEntries)', () => { + it('엔진 관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정은 뺀다 — 웹메일 로그인을 막는 것과 같은 목록', () => { + const entries = toDirectoryEntries( + [ + account('a', 'admin@arasystem.test', 'System administrator'), + account( + 'g', + 'mail-gateway@arasystem.test', + `메일 게이트웨이 (백엔드 대리 접근 전용) ${SETUP_MANAGED_MARK}`, + ), + account( + 'm', + 'mail-directory@arasystem.test', + `사내 목록 (백엔드 계정 목록 읽기 전용) ${SETUP_MANAGED_MARK}`, + ), + account('o', 'Ops@AraSystem.test', '운영'), + account('k', 'ara.kim@arasystem.test', '김아라'), + ], + policy, + ); + + expect(entries).toEqual([ + { + id: 'k', + name: '김아라', + email: 'ara.kim@arasystem.test', + kind: 'person', + }, + ]); + }); + + it('메일 엔진 설정 스크립트가 만든 계정(설명에 [setup.mjs])도 뺀다 — 이름이 정적 목록과 달라도', () => { + expect( + toDirectoryEntries( + [ + account( + 'x', + 'renamed-service@arasystem.test', + `무엇 ${SETUP_MANAGED_MARK}`, + ), + ], + policy, + ), + ).toEqual([]); + }); + + it('사내 도메인이 아닌 주소 · 주소 없음 · 직원 주소 형식이 아닌 주소는 뺀다', () => { + expect( + toDirectoryEntries( + [ + account('1', 'partner@vendor.example', '바깥'), + account('2', 'ara.kim@mail.arasystem.test', '하위 도메인'), + account('3', null, '주소 없음'), + account('4', ' ', '빈 주소'), + account('5', 'ara%gw@arasystem.test', '대리 접근 구분자'), + ], + policy, + ), + ).toEqual([]); + }); + + it('주소는 소문자로, 같은 주소는 처음 것 하나만 — 이름은 표시 이름(제어 문자는 띄어쓰기), 없으면 주소의 @ 앞', () => { + const entries = toDirectoryEntries( + [ + account('d1', 'Doyun.Lee@AraSystem.Test', ' 이도윤\t(영업) '), + account('d2', 'doyun.lee@arasystem.test', '중복'), + account('s', 'sales@arasystem.test', null, 'shared'), + account('e', 'seojun.park@arasystem.test', ' '), + ], + policy, + ); + + expect(entries).toEqual([ + // 이름 차례 — 한글이 영문보다 앞이다 + { + id: 'd1', + name: '이도윤 (영업)', + email: 'doyun.lee@arasystem.test', + kind: 'person', + }, + { id: 's', name: 'sales', email: 'sales@arasystem.test', kind: 'shared' }, + { + id: 'e', + name: 'seojun.park', + email: 'seojun.park@arasystem.test', + kind: 'person', + }, + ]); + }); + + it('메일 엔진 설정이 없으면(도메인 없음) 아무도 넣지 않는다', () => { + expect( + toDirectoryEntries([account('k', 'ara.kim@arasystem.test', '김아라')], { + ...policy, + domain: null, + }), + ).toEqual([]); + }); +}); diff --git a/backend/src/modules/contacts/directory-policy.ts b/backend/src/modules/contacts/directory-policy.ts new file mode 100644 index 0000000..4f979fa --- /dev/null +++ b/backend/src/modules/contacts/directory-policy.ts @@ -0,0 +1,66 @@ +import { isEmployeeAddress } from '@/modules/auth/employee-address'; +import { compareDirectoryEntries } from './contact-search'; +import type { DirectoryAccount } from './contacts.provider'; +import type { DirectoryEntry } from './contacts.types'; + +/** + * 사내 목록에 넣는 계정 (순수 함수) — 계약 3절 "사내 목록". + * + * 빼는 계정은 웹메일 로그인에서 막는 것과 **같은 함수**(auth/employee-address.ts 의 isEmployeeAddress)로 고른다: + * 사내 도메인(MAIL_DOMAIN)이 아닌 주소 · 주소 없음 · 게이트웨이 · 막은 계정(엔진 관리자 · 사내 목록 계정 · MAIL_BLOCKED_ACCOUNTS — + * mail.settings.ts 의 blockedAccounts). 거기에 더해 setup 이 만든 계정(설명에 SETUP_MANAGED_MARK)도 뺀다. + */ + +/** 메일 엔진 설정 스크립트가 자기가 만든 객체의 설명에 붙이는 표시 — mail/scripts/setup.mjs 의 MANAGED_MARK 와 같다 */ +export const SETUP_MANAGED_MARK = '[setup.mjs]'; + +export interface DirectoryPolicy { + /** 사내 메일 도메인 (소문자) — 없으면(메일 엔진 설정 없음) 아무도 넣지 않는다 */ + readonly domain: string | null; + readonly gatewayUser: string | null; + readonly blockedAccounts: ReadonlySet; +} + +/** 표시 이름 — 제어 문자는 띄어쓰기로, 앞뒤 공백은 뺀다. 없으면 주소의 @ 앞 */ +function displayNameOf(account: DirectoryAccount, email: string): string { + const name = account.displayName + // eslint-disable-next-line no-control-regex + ?.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ') + .trim(); + return name ? name : email.slice(0, email.lastIndexOf('@')); +} + +/** 엔진의 계정 목록 → 사내 목록 (이름 차례) — 같은 주소는 처음 것 하나만 */ +export function toDirectoryEntries( + accounts: readonly DirectoryAccount[], + policy: DirectoryPolicy, +): DirectoryEntry[] { + const { domain } = policy; + if (domain === null) return []; + + const seen = new Set(); + const entries: DirectoryEntry[] = []; + for (const account of accounts) { + const email = account.email?.trim().toLowerCase() ?? ''; + if ( + !isEmployeeAddress( + email, + domain, + policy.gatewayUser, + policy.blockedAccounts, + ) || + (account.displayName ?? '').includes(SETUP_MANAGED_MARK) || + seen.has(email) + ) { + continue; + } + seen.add(email); + entries.push({ + id: account.id, + name: displayNameOf(account, email), + email, + kind: account.kind, + }); + } + return entries.sort(compareDirectoryEntries); +} diff --git a/backend/src/modules/contacts/directory.service.spec.ts b/backend/src/modules/contacts/directory.service.spec.ts new file mode 100644 index 0000000..e5abaaf --- /dev/null +++ b/backend/src/modules/contacts/directory.service.spec.ts @@ -0,0 +1,257 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { MailSettings } from '@/modules/mail/mail.settings'; +import { DIRECTORY_CACHE_TTL_MS } from './contacts.constants'; +import type { DirectoryAccount } from './contacts.provider'; +import { DirectoryService } from './directory.service'; +import type { DirectoryEntryQueryDto } from './dto/directory-entry.dto'; + +const settings: MailSettings = { + jmapBaseUrl: 'http://mail:8080', + domain: 'arasystem.test', + gatewayUser: 'mail-gateway@arasystem.test', + gatewayPassword: 'secret', + attachmentMaxBytes: 20 * 1024 * 1024, + attachmentTotalMaxBytes: 25 * 1024 * 1024, + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', +}; + +const ACCOUNTS: DirectoryAccount[] = [ + { + id: 'a', + kind: 'person', + displayName: 'System administrator', + email: 'admin@arasystem.test', + }, + { + id: 'm', + kind: 'person', + displayName: '사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs]', + email: 'mail-directory@arasystem.test', + }, + { + id: 's', + kind: 'shared', + displayName: '영업팀', + email: 'sales@arasystem.test', + }, + { + id: 'p', + kind: 'person', + displayName: '박서준', + email: 'seojun.park@arasystem.test', + }, + { + id: 'd', + kind: 'person', + displayName: '이도윤', + email: 'doyun.lee@arasystem.test', + }, + { + id: 'k', + kind: 'person', + displayName: '김아라', + email: 'ara.kim@arasystem.test', + }, +]; + +const query = ( + overrides: Partial = {}, +): DirectoryEntryQueryDto => ({ limit: 20, ...overrides }); + +async function failureOf( + promise: Promise, +): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught, + ); + if (!(error instanceof BusinessException)) { + throw new Error('BusinessException 이 아니다'); + } + return error; +} + +describe('DirectoryService (사내 목록)', () => { + const NOW = 1_800_000_000_000; + let listAccounts: jest.Mock, []>; + let service: DirectoryService; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + listAccounts = jest + .fn, []>() + .mockResolvedValue(ACCOUNTS); + service = new DirectoryService({ listAccounts }, settings); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('이름 차례 · total 포함 — 관리자 · 사내 목록 계정은 빠진다', async () => { + const page = await service.list(query()); + + expect(page.items.map((entry) => entry.name)).toEqual([ + '김아라', + '박서준', + '영업팀', + '이도윤', + ]); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 4, + }); + }); + + it('q 는 초성 · 이름 · 주소 — kind 는 직원 · 공용 주소만', async () => { + expect( + (await service.list(query({ q: 'ㄷㅇ' }))).items.map((e) => e.id), + ).toEqual(['d']); + expect( + (await service.list(query({ q: ' SEOJUN ' }))).items.map((e) => e.id), + ).toEqual(['p']); + expect( + (await service.list(query({ kind: 'shared' }))).items.map((e) => e.id), + ).toEqual(['s']); + expect( + (await service.list(query({ kind: 'person', q: '영업' }))).items, + ).toEqual([]); + }); + + it('커서 — 다음 쪽으로 이어지고, 받은 그 q · kind 로만 쓴다 (다르면 엔진에 묻기 전에 VAL_001)', async () => { + const first = await service.list(query({ limit: 3 })); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 }); + const cursor = first.pageInfo.nextCursor ?? ''; + + const second = await service.list(query({ limit: 3, cursor })); + expect(second.items.map((entry) => entry.id)).toEqual(['d']); + expect(second.pageInfo.hasNext).toBe(false); + + listAccounts.mockClear(); + for (const other of [ + query({ cursor, kind: 'person' }), + query({ cursor, q: '김' }), + query({ cursor: 'not-a-cursor' }), + ]) { + const error = await failureOf(service.list(other)); + expect(error.code).toBe('VAL_001'); + expect(error.context?.reason).toMatch(/^invalid-contact-cursor:/); + } + expect(listAccounts).not.toHaveBeenCalled(); + }); + + it('모든 직원이 함께 10분 기억한다 — 같은 때의 여러 요청은 한 번만 읽는다', async () => { + await Promise.all([ + service.entries(), + service.entries(), + service.list(query()), + ]); + expect(listAccounts).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockReturnValue(NOW + DIRECTORY_CACHE_TTL_MS - 1); + await service.entries(); + expect(listAccounts).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockReturnValue(NOW + DIRECTORY_CACHE_TTL_MS + 1); + await service.entries(); + expect(listAccounts).toHaveBeenCalledTimes(2); + }); + + it('읽지 못하면 MAIL_001 이고 기억하지 않는다 — 엔진 한도(REQ_001) 등도 MAIL_001 로 (사유는 로그용으로 남긴다)', async () => { + listAccounts.mockRejectedValueOnce( + new BusinessException('MAIL_001', { + context: { reason: 'directory-account-missing' }, + }), + ); + expect((await failureOf(service.entries())).context?.reason).toBe( + 'directory-account-missing', + ); + + listAccounts.mockRejectedValueOnce( + new BusinessException('REQ_001', { + context: { reason: 'mail-engine-rate-limit' }, + }), + ); + const limited = await failureOf(service.entries()); + expect(limited.code).toBe('MAIL_001'); + expect(limited.context).toMatchObject({ + reason: 'mail-engine-rate-limit', + code: 'REQ_001', + }); + + // 다음 요청은 다시 읽는다 + await expect(service.entries()).resolves.toHaveLength(4); + expect(listAccounts).toHaveBeenCalledTimes(3); + }); + + it('id 로 찾는다 — 넘긴 차례대로, 사내 목록에 없는 id(빠진 관리자 포함)는 RES_001', async () => { + expect( + (await service.findByIds(['k', 's'])).map((entry) => entry.email), + ).toEqual(['ara.kim@arasystem.test', 'sales@arasystem.test']); + expect((await failureOf(service.findByIds(['k', 'a']))).code).toBe( + 'RES_001', + ); + // 찾을 것이 없으면 엔진에 묻지 않는다 + listAccounts.mockClear(); + await expect(service.findByIds([])).resolves.toEqual([]); + expect(listAccounts).not.toHaveBeenCalled(); + }); + + it('한 항목(GET /api/directory-entries/{id}) — 기억한 목록에서, 없거나 빼는 계정이면 RES_001', async () => { + await expect(service.findById('d')).resolves.toEqual({ + id: 'd', + name: '이도윤', + email: 'doyun.lee@arasystem.test', + kind: 'person', + }); + for (const id of ['a', 'm', 'nope']) { + expect((await failureOf(service.findById(id))).code).toBe('RES_001'); + } + expect(listAccounts).toHaveBeenCalledTimes(1); + }); + + it('excludeGroupId — 받은 주소(소문자)를 뺀다 · 커서는 그 값으로만 쓰고, 커서를 먼저 확인한다(빼기 목록을 묻기 전에)', async () => { + const excluded = jest + .fn>, []>() + .mockResolvedValue( + new Set(['doyun.lee@arasystem.test', 'other@x.example']), + ); + const page = await service.list( + query({ excludeGroupId: 'g', limit: 2 }), + excluded, + ); + expect(page.items.map((entry) => entry.id)).toEqual(['k', 'p']); + expect(page.pageInfo).toMatchObject({ hasNext: true, total: 3 }); + + const cursor = page.pageInfo.nextCursor ?? ''; + const next = await service.list( + query({ excludeGroupId: 'g', limit: 2, cursor }), + excluded, + ); + expect(next.items.map((entry) => entry.id)).toEqual(['s']); + + excluded.mockClear(); + for (const other of [ + query({ cursor }), + query({ cursor, excludeGroupId: 'h' }), + ]) { + expect((await failureOf(service.list(other, excluded))).code).toBe( + 'VAL_001', + ); + } + expect(excluded).not.toHaveBeenCalled(); + + // 그룹이 없으면(주소록 서비스가 RES_001) 그대로 + excluded.mockRejectedValueOnce(new BusinessException('RES_001')); + expect( + ( + await failureOf( + service.list(query({ excludeGroupId: 'gone' }), excluded), + ) + ).code, + ).toBe('RES_001'); + }); +}); diff --git a/backend/src/modules/contacts/directory.service.ts b/backend/src/modules/contacts/directory.service.ts new file mode 100644 index 0000000..ddba1f5 --- /dev/null +++ b/backend/src/modules/contacts/directory.service.ts @@ -0,0 +1,149 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { PaginatedResult } from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { cursorOffset, listScope, pageOf } from './contact-paging'; +import { matchesDirectoryEntry, parseContactSearch } from './contact-search'; +import { DIRECTORY_CACHE_TTL_MS } from './contacts.constants'; +import { + DIRECTORY_PROVIDER, + type DirectoryProvider, +} from './contacts.provider'; +import type { DirectoryEntry } from './contacts.types'; +import type { DirectoryEntryQueryDto } from './dto/directory-entry.dto'; +import { toDirectoryEntries, type DirectoryPolicy } from './directory-policy'; + +/** 기억한 사내 목록 */ +interface DirectoryListing { + readonly entries: readonly DirectoryEntry[]; + readonly byId: ReadonlyMap; + readonly expiresAt: number; +} + +function directoryEntryNotFound(): BusinessException { + return new BusinessException('RES_001', { + context: { reason: 'directory-entry-not-found' }, + }); +} + +/** 사내 목록을 읽지 못했다 — 받는 사람 추천은 이것을 보고 로그인 기록으로 대신한다. 503 MAIL_001 로 답한다 */ +function directoryFailure(error: unknown): unknown { + if (!(error instanceof BusinessException) || error.code === 'MAIL_001') { + return error; + } + // 엔진 한도(REQ_001) 등 — 직원의 요청 때문이 아니라 서버가 쓰는 계정의 일이라 엔진 장애로 답한다 + return new BusinessException('MAIL_001', { + context: { + reason: 'directory-unavailable', + code: error.code, + ...(error.context ?? {}), + }, + }); +} + +/** + * 사내 목록 (계약 3절 "사내 목록") — 엔진의 계정 목록에서 직원 · 공용 주소의 이름 · 주소만. + * - 모든 직원이 함께 쓰는 목록을 10분 기억한다(서버 메모리). 같은 때의 여러 요청은 한 번만 읽는다 + * - 읽지 못하면(사내 목록 계정이 없음 · 권한 · 엔진 장애) 기억하지 않고 MAIL_001 — 다음 요청이 다시 읽는다 + * - 빼는 계정은 웹메일 로그인에서 막는 것과 같다 (directory-policy.ts) + * + * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 10분씩 따로 기억한다 (틀리지는 않고, 계정을 바꾼 뒤 보이는 때가 인스턴스마다 다를 뿐이다). + */ +@Injectable() +export class DirectoryService { + private cached: DirectoryListing | null = null; + private loading: Promise | null = null; + private readonly policy: DirectoryPolicy; + + constructor( + @Inject(DIRECTORY_PROVIDER) private readonly provider: DirectoryProvider, + @Inject(MAIL_SETTINGS) settings: MailSettings, + ) { + this.policy = { + domain: settings.domain, + gatewayUser: settings.gatewayUser, + blockedAccounts: settings.blockedAccounts, + }; + } + + /** 사내 목록 전체 (이름 차례) — 읽지 못하면 MAIL_001 */ + async entries(): Promise { + return (await this.listing()).entries; + } + + /** + * GET /api/directory-entries — 찾는 글 · 종류로 거른 한 쪽. 커서는 엔진에 묻기 전에 확인한다. + * excluded 가 있으면(excludeGroupId — 주소록 서비스가 그 그룹 구성원의 주소를 준다) 그 주소(소문자)를 뺀다 + */ + async list( + query: DirectoryEntryQueryDto, + excluded?: () => Promise>, + ): Promise> { + const q = query.q?.trim() ? query.q.trim() : undefined; + const scope = listScope( + 'directory-entries', + q, + query.kind, + query.excludeGroupId, + ); + const start = cursorOffset(query.cursor, scope); + + const except = excluded ? await excluded() : null; + const { entries } = await this.listing(); + const search = parseContactSearch(q); + const matched = entries.filter( + (entry) => + (query.kind === undefined || entry.kind === query.kind) && + (except === null || !except.has(entry.email.toLowerCase())) && + (search === null || matchesDirectoryEntry(entry, search)), + ); + return pageOf(matched, start, query.limit, scope); + } + + /** GET /api/directory-entries/{id} — 기억한 목록에서 (없거나 빼는 계정이면 RES_001) */ + async findById(id: string): Promise { + const [entry] = await this.findByIds([id]); + if (!entry) throw directoryEntryNotFound(); + return entry; + } + + /** id 로 찾는다 (넘긴 차례대로) — 사내 목록에 없는 id 가 하나라도 있으면 RES_001 */ + async findByIds(ids: readonly string[]): Promise { + if (ids.length === 0) return []; + const { byId } = await this.listing(); + return ids.map((id) => { + const entry = byId.get(id); + if (!entry) throw directoryEntryNotFound(); + return entry; + }); + } + + private listing(): Promise { + if (this.cached && this.cached.expiresAt > Date.now()) { + return Promise.resolve(this.cached); + } + if (this.loading) return this.loading; + + const loading = this.provider + .listAccounts() + .then((accounts) => { + const entries = toDirectoryEntries(accounts, this.policy); + const listing: DirectoryListing = { + entries, + byId: new Map(entries.map((entry) => [entry.id, entry])), + expiresAt: Date.now() + DIRECTORY_CACHE_TTL_MS, + }; + this.cached = listing; + return listing; + }) + .catch((error: unknown) => { + throw directoryFailure(error); + }) + .finally(() => { + if (this.loading === loading) this.loading = null; + }); + this.loading = loading; + return loading; + } +} diff --git a/backend/src/modules/contacts/dto/contact-group.dto.spec.ts b/backend/src/modules/contacts/dto/contact-group.dto.spec.ts new file mode 100644 index 0000000..745b72b --- /dev/null +++ b/backend/src/modules/contacts/dto/contact-group.dto.spec.ts @@ -0,0 +1,174 @@ +import { plainToInstance } from 'class-transformer'; +import { validate, type ValidationError } from 'class-validator'; + +import { + MAX_CONTACT_GROUP_NAME_LENGTH, + MAX_CONTACT_QUERY_LENGTH, + MAX_GROUP_MEMBERS_PER_REQUEST, +} from '@/modules/contacts/contacts.constants'; +import { DirectoryEntryQueryDto } from './directory-entry.dto'; +import { + ContactGroupCreateDto, + ContactGroupMembersDto, + ContactGroupRenameDto, +} from './contact-group.dto'; +import { ContactRestorationDto } from './contact-restoration.dto'; + +function constraintNames(errors: ValidationError[]): string[] { + return errors.flatMap((error) => [ + ...Object.keys(error.constraints ?? {}), + ...constraintNames(error.children ?? []), + ]); +} + +/** 전역 ValidationPipe 와 같은 옵션 (stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다) */ +async function check( + type: new () => T, + body: object, +): Promise<{ names: string[]; dto: T }> { + const dto = plainToInstance(type, body); + const errors = await validate(dto, { + whitelist: true, + forbidNonWhitelisted: true, + }); + return { names: constraintNames(errors), dto }; +} + +const ids = (count: number, prefix = 'c') => + Array.from({ length: count }, (_, index) => `${prefix}${index}`); + +describe('그룹 본문 (POST · PATCH /api/contact-groups)', () => { + it(`이름 — 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LENGTH}자, 제어 문자 없음 — 지운 값으로 저장한다`, async () => { + const { names, dto } = await check(ContactGroupRenameDto, { + name: ` ${'가'.repeat(MAX_CONTACT_GROUP_NAME_LENGTH)} `, + }); + expect(names).toEqual([]); + expect(dto.name).toBe('가'.repeat(MAX_CONTACT_GROUP_NAME_LENGTH)); + + for (const name of ['', ' ']) { + expect((await check(ContactGroupRenameDto, { name })).names).toEqual([ + 'minCodeUnits', + ]); + } + expect( + ( + await check(ContactGroupRenameDto, { + name: 'x'.repeat(MAX_CONTACT_GROUP_NAME_LENGTH + 1), + }) + ).names, + ).toEqual(['maxCodeUnits']); + expect( + (await check(ContactGroupRenameDto, { name: '거래\n처' })).names, + ).toEqual(['matches']); + expect((await check(ContactGroupRenameDto, {})).names).toContain( + 'isString', + ); + expect( + (await check(ContactGroupRenameDto, { name: '거래처', memberIds: [] })) + .names, + ).toEqual(['whitelistValidation']); + }); + + it('만들기는 { name } 만 — memberIds 는 받지 않는다(지운 그룹은 POST /api/contact-restorations 로 되돌린다)', async () => { + expect( + (await check(ContactGroupCreateDto, { name: '거래처' })).names, + ).toEqual([]); + expect( + (await check(ContactGroupCreateDto, { name: '거래처', memberIds: ['a'] })) + .names, + ).toEqual(['whitelistValidation']); + }); +}); + +describe('구성원 넣기 본문 (POST /api/contact-groups/{id}/members)', () => { + it(`contactIds · directoryIds — 둘 다 없어도 형식은 맞다(합쳐 1~${MAX_GROUP_MEMBERS_PER_REQUEST}개는 서비스가 본다), 각각 ${MAX_GROUP_MEMBERS_PER_REQUEST}개까지`, async () => { + expect((await check(ContactGroupMembersDto, {})).names).toEqual([]); + expect( + ( + await check(ContactGroupMembersDto, { + contactIds: ids(MAX_GROUP_MEMBERS_PER_REQUEST), + directoryIds: ids(1, 'd'), + }) + ).names, + ).toEqual([]); + for (const key of ['contactIds', 'directoryIds']) { + expect( + ( + await check(ContactGroupMembersDto, { + [key]: ids(MAX_GROUP_MEMBERS_PER_REQUEST + 1), + }) + ).names, + ).toEqual(['arrayMaxSize']); + expect( + (await check(ContactGroupMembersDto, { [key]: ['x', 'x'] })).names, + ).toEqual(['arrayDistinct']); + } + expect( + (await check(ContactGroupMembersDto, { members: ['x'] })).names, + ).toEqual(['whitelistValidation']); + }); +}); + +describe('되돌리기 본문 (POST /api/contact-restorations)', () => { + it('token — 지우기 응답의 restoreToken 모양(base64url 22자)만, 모르는 칸은 받지 않는다', async () => { + expect( + (await check(ContactRestorationDto, { token: 'q3Jd0Vb1Lw9xA2mYk7Pz4g' })) + .names, + ).toEqual([]); + for (const token of [ + '', + 'short', + 'q3Jd0Vb1Lw9xA2mYk7Pz4g=', + 'q3Jd0Vb1Lw9xA2mYk7Pz4/', + 'x'.repeat(100_000), + ]) { + expect((await check(ContactRestorationDto, { token })).names).toEqual([ + 'matches', + ]); + } + expect((await check(ContactRestorationDto, { token: 7 })).names).toContain( + 'isString', + ); + expect( + ( + await check(ContactRestorationDto, { + token: 'q3Jd0Vb1Lw9xA2mYk7Pz4g', + kind: 'contact', + }) + ).names, + ).toEqual(['whitelistValidation']); + }); +}); + +describe('사내 목록 쿼리 (GET /api/directory-entries)', () => { + it(`kind 는 person · shared 만 · q 는 ${MAX_CONTACT_QUERY_LENGTH}자까지 · excludeGroupId 는 id 형식`, async () => { + expect( + (await check(DirectoryEntryQueryDto, { kind: 'person', q: '도윤' })) + .names, + ).toEqual([]); + expect( + (await check(DirectoryEntryQueryDto, { kind: 'group' })).names, + ).toEqual(['isIn']); + expect( + ( + await check(DirectoryEntryQueryDto, { + q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH), + excludeGroupId: 'jy', + }) + ).names, + ).toEqual([]); + expect( + ( + await check(DirectoryEntryQueryDto, { + q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH + 1), + }) + ).names, + ).toEqual(['maxCodeUnits']); + expect( + (await check(DirectoryEntryQueryDto, { excludeGroupId: 'a b' })).names, + ).toEqual(['matches']); + expect( + (await check(DirectoryEntryQueryDto, { groupId: 'g' })).names, + ).toEqual(['whitelistValidation']); + }); +}); diff --git a/backend/src/modules/contacts/dto/contact-group.dto.ts b/backend/src/modules/contacts/dto/contact-group.dto.ts new file mode 100644 index 0000000..212f950 --- /dev/null +++ b/backend/src/modules/contacts/dto/contact-group.dto.ts @@ -0,0 +1,178 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsOptional, + IsString, + Matches, +} from 'class-validator'; + +import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator'; +import { + MaxCodeUnits, + MinCodeUnits, +} from '@/common/decorators/code-units.decorator'; +import { TrimString } from '@/common/decorators/trim-string.decorator'; +import { PageInfoDto } from '@/common/dto/pagination.dto'; +import { + CONTACT_ID_PATTERN, + MAX_CONTACT_GROUP_NAME_LENGTH, + MAX_GROUP_MEMBERS_PER_REQUEST, +} from '@/modules/contacts/contacts.constants'; +import type { + ContactGroup, + ContactGroupDeleted, + ContactGroupMembersAdded, +} from '@/modules/contacts/contacts.types'; +import { SINGLE_LINE_TEXT_PATTERN } from '@/modules/mail/mail.constants'; + +/** + * 그룹 — 만들기 · 이름 바꾸기 · 구성원 넣기 본문과 응답 (계약 3절 "주소록"). + * 이름은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다. 같은 이름(앞뒤 공백 · ASCII 대소문자 무시)은 서비스가 본다(409 CONTACT_001). + * 칸의 검사는 applyDecorators 로 묶어 적은 차례대로 돈다 — 형식 → 길이 · 개수 → 모양 (contact.dto.ts 머리말 — stopAtFirstError 라 길이에서 걸리면 정규식을 돌리지 않는다) + */ + +const NAME_DESCRIPTION = `그룹 이름 — 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LENGTH}자(JS 문자열 길이) · 제어 문자 없음. 이 주소록 안에서 겹치지 않는다(ASCII 대소문자 무시 — 아니면 409 CONTACT_001)`; + +/** 그룹 이름 — 앞뒤 공백을 뺀 1~50자 · 제어 문자 없음 */ +function GroupName(): PropertyDecorator { + return applyDecorators( + TrimString(), + IsString({ message: '그룹 이름 형식이 올바르지 않습니다.' }), + MinCodeUnits(1, { message: '그룹 이름을 입력해 주세요.' }), + MaxCodeUnits(MAX_CONTACT_GROUP_NAME_LENGTH, { + message: `그룹 이름은 ${MAX_CONTACT_GROUP_NAME_LENGTH}자 이하로 입력해 주세요.`, + }), + Matches(SINGLE_LINE_TEXT_PATTERN, { + message: '그룹 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.', + }), + ); +} + +/** 구성원 넣기의 id 목록 — 없어도 되고, 100개까지 · id 형식 · 겹치지 않게 */ +function MemberIdList(duplicateMessage: string): PropertyDecorator { + return applyDecorators( + IsOptional(), + IsArray({ message: '구성원 목록 형식이 올바르지 않습니다.' }), + ArrayMaxSize(MAX_GROUP_MEMBERS_PER_REQUEST, { + message: `한 번에 ${MAX_GROUP_MEMBERS_PER_REQUEST}명까지 넣을 수 있습니다.`, + }), + Matches(CONTACT_ID_PATTERN, { + each: true, + message: '구성원을 다시 골라 주세요.', + }), + ArrayDistinct({ message: duplicateMessage }), + ); +} + +/** 이름 칸 — 만들기 · 이름 바꾸기가 같은 규칙 */ +export class ContactGroupNameDto { + @ApiProperty({ + description: NAME_DESCRIPTION, + example: '거래처', + minLength: 1, + maxLength: MAX_CONTACT_GROUP_NAME_LENGTH, + }) + @GroupName() + name!: string; +} + +/** POST /api/contact-groups — `{ name }` 만 (지운 그룹은 POST /api/contact-restorations 로 되돌린다) */ +export class ContactGroupCreateDto extends ContactGroupNameDto {} + +/** PATCH /api/contact-groups/{id} */ +export class ContactGroupRenameDto extends ContactGroupNameDto {} + +/** POST /api/contact-groups/{id}/members — 합쳐 1~100개 (서비스가 본다 — 아니면 400 VAL_001) */ +export class ContactGroupMembersDto { + @ApiPropertyOptional({ + description: `넣을 내 연락처 id — 겹치지 않게. contactIds 와 directoryIds 를 합쳐 1~${MAX_GROUP_MEMBERS_PER_REQUEST}개. 이 주소록에 없는 연락처면 404 RES_001`, + type: [String], + maxItems: MAX_GROUP_MEMBERS_PER_REQUEST, + example: ['jw'], + }) + @MemberIdList('같은 연락처를 두 번 넣을 수 없습니다.') + contactIds?: string[]; + + @ApiPropertyOptional({ + description: + '넣을 사내 목록 id(엔진의 계정 id) — 겹치지 않게. 같은 주소(대소문자 무시)의 연락처가 내 주소록에 있으면 그 연락처를 넣고, ' + + '없으면 이름 · 주소(종류는 회사)로 연락처를 새로 만들어 넣는다. 사내 목록에 없는 id 면 404 RES_001, 사내 목록을 읽지 못하면 503 MAIL_001', + type: [String], + maxItems: MAX_GROUP_MEMBERS_PER_REQUEST, + example: ['b'], + }) + @MemberIdList('같은 사람을 두 번 넣을 수 없습니다.') + directoryIds?: string[]; +} + +/** 응답 — 계약 3절 ContactGroup */ +export class ContactGroupDto implements ContactGroup { + @ApiProperty({ description: '그룹 id (엔진의 카드 id)', example: 'jy' }) + id!: string; + + @ApiProperty({ description: '그룹 이름', example: '거래처' }) + name!: string; + + @ApiProperty({ + description: + '이 주소록에 있는 연락처 가운데 구성원 수 (지워진 카드를 가리키는 구성원은 세지 않는다)', + example: 4, + }) + memberCount!: number; +} + +export class ContactGroupPageDto { + @ApiProperty({ + type: [ContactGroupDto], + description: '이름 차례(한국어 사전 차례 → id)', + }) + items!: ContactGroupDto[]; + + @ApiProperty({ type: PageInfoDto, description: 'total 포함' }) + pageInfo!: PageInfoDto; +} + +/** 그룹 지우기의 응답 — 계약 3절 ContactGroupDeleted */ +export class ContactGroupDeletedDto implements ContactGroupDeleted { + @ApiProperty({ description: '지운 그룹 id', example: 'jy' }) + id!: string; + + @ApiProperty({ description: '지운 그룹 이름', example: '거래처' }) + name!: string; + + @ApiProperty({ + description: '지우기 전 구성원(이 주소록의 연락처 id, 이름 차례)', + type: [String], + example: ['jw', 'jx'], + }) + memberIds!: string[]; + + @ApiProperty({ + description: + '[되돌리기] 표 — POST /api/contact-restorations 의 token. 5분 · 한 번만 쓴다. 서버가 기억하지 못했으면 null', + nullable: true, + type: String, + example: 'q3Jd0Vb1Lw9xA2mYk7Pz4g', + }) + restoreToken!: string | null; +} + +export class ContactGroupMembersAddedDto implements ContactGroupMembersAdded { + @ApiProperty({ type: ContactGroupDto, description: '넣은 뒤의 그룹' }) + group!: ContactGroupDto; + + @ApiProperty({ + description: + '이번에 그룹에 들어간 수 (이미 구성원이던 것 · 그룹에 넣을 수 없는 카드는 세지 않는다)', + example: 2, + }) + addedCount!: number; + + @ApiProperty({ + description: '사내 목록에서 골라 내 주소록에 새로 만든 연락처 수', + example: 1, + }) + createdCount!: number; +} diff --git a/backend/src/modules/contacts/dto/contact-restoration.dto.ts b/backend/src/modules/contacts/dto/contact-restoration.dto.ts new file mode 100644 index 0000000..dfc236e --- /dev/null +++ b/backend/src/modules/contacts/dto/contact-restoration.dto.ts @@ -0,0 +1,56 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, Matches } from 'class-validator'; + +import { CONTACT_RESTORE_TOKEN_PATTERN } from '@/modules/contacts/contacts.constants'; +import { ContactGroupDto } from './contact-group.dto'; +import { ContactDto } from './contact.dto'; + +/** + * 되돌리기 — POST /api/contact-restorations 본문과 응답 (계약 3절 "되돌리기"). + * 표는 지우기 응답의 restoreToken 그대로다(무작위 128비트 — base64url 22자). 모양이 틀리면 400 VAL_001, 모양은 맞는데 없는 표 · 지난 표 · + * 이미 쓴 표 · 다른 직원의 표는 404 RES_001 (서비스가 본다). 표는 로그에 남기지 않는다 + */ + +function RestoreToken(): PropertyDecorator { + return applyDecorators( + IsString({ message: '되돌리기 표 형식이 올바르지 않습니다.' }), + Matches(CONTACT_RESTORE_TOKEN_PATTERN, { + message: '되돌리기 표 형식이 올바르지 않습니다.', + }), + ); +} + +export class ContactRestorationDto { + @ApiProperty({ + description: + '지우기 응답(DELETE /api/contacts/{id} · /api/contact-groups/{id})의 restoreToken — 5분 · 한 번만 (성공하면 지운다. 실패하면 5분 안에 다시 쓸 수 있다)', + example: 'q3Jd0Vb1Lw9xA2mYk7Pz4g', + }) + @RestoreToken() + token!: string; +} + +/** 응답 — 계약 3절 ContactRestored (kind 에 따라 contact 나 group 하나만 있다) */ +export class ContactRestoredDto { + @ApiProperty({ + description: '다시 만든 것 — contact 연락처 · group 그룹', + enum: ['contact', 'group'], + example: 'contact', + }) + kind!: 'contact' | 'group'; + + @ApiPropertyOptional({ + type: ContactDto, + description: + 'kind 가 contact 일 때 — 다시 만든 연락처(새 id). 그때 속했고 지금도 있는 그룹에 다시 들어 있다(구성원 500명이 찬 그룹은 건너뛴다)', + }) + contact?: ContactDto; + + @ApiPropertyOptional({ + type: ContactGroupDto, + description: + 'kind 가 group 일 때 — 다시 만든 그룹(새 id). 구성원은 그때의 구성원 가운데 지금도 있는 연락처', + }) + group?: ContactGroupDto; +} diff --git a/backend/src/modules/contacts/dto/contact.dto.spec.ts b/backend/src/modules/contacts/dto/contact.dto.spec.ts new file mode 100644 index 0000000..3b00b27 --- /dev/null +++ b/backend/src/modules/contacts/dto/contact.dto.spec.ts @@ -0,0 +1,462 @@ +import { BadRequestException } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validate, type ValidationError } from 'class-validator'; + +import { startCpuTimer } from '@/../test/cpu-time'; +import { createValidationPipe } from '@/app.setup'; +import { + MAX_CONTACT_EMAIL_LENGTH, + MAX_CONTACT_EMAILS, + MAX_CONTACT_GROUP_IDS, + MAX_CONTACT_NOTE_LENGTH, + MAX_CONTACT_PHONE_LENGTH, + MAX_CONTACT_PHONES, + MAX_CONTACT_QUERY_LENGTH, + MAX_CONTACT_TEXT_LENGTH, +} from '@/modules/contacts/contacts.constants'; +import { ContactGroupNameDto } from './contact-group.dto'; +import { + ContactInputDto, + ContactListQueryDto, + ContactPatchDto, +} from './contact.dto'; +import { DirectoryEntryQueryDto } from './directory-entry.dto'; + +/** 걸린 규칙 이름 — 중첩된 칸(emails.0.address 등)까지 */ +function constraintNames(errors: ValidationError[]): string[] { + return errors.flatMap((error) => [ + ...Object.keys(error.constraints ?? {}), + ...constraintNames(error.children ?? []), + ]); +} + +/** 전역 ValidationPipe 와 같은 옵션으로 변환 · 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */ +async function check( + type: new () => T, + body: object, + stopAtFirstError = false, +): Promise<{ names: string[]; dto: T }> { + const dto = plainToInstance(type, body); + const errors = await validate(dto, { + whitelist: true, + forbidNonWhitelisted: true, + stopAtFirstError, + }); + return { names: constraintNames(errors), dto }; +} + +const body = (overrides: object = {}) => ({ + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '견적 담당\n월요일 오전', + groupIds: ['jy'], + ...overrides, +}); + +const namesOf = async (overrides: object) => + (await check(ContactInputDto, body(overrides))).names; + +describe('연락처 본문 (POST /api/contacts)', () => { + it('맞는 본문 — 이메일 · 전화 · 그룹이 없어도 형식은 맞다 (이름이나 이메일 가운데 하나는 서비스가 본다)', async () => { + expect(await namesOf({})).toEqual([]); + expect( + await namesOf({ + name: null, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + groupIds: [], + }), + ).toEqual([]); + }); + + it('글은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다 — 빈 글은 null', async () => { + const { names, dto } = await check( + ContactInputDto, + body({ + name: ` ${'가'.repeat(MAX_CONTACT_TEXT_LENGTH)} `, + company: ' ', + department: '\u3000구매팀\u3000', + title: '', + note: '\n 메모 \n', + emails: [{ address: ' a@x.example ', label: 'other' }], + phones: [{ number: ' 02-333-4444 ', label: 'work' }], + }), + ); + + expect(names).toEqual([]); + expect(dto.name).toBe('가'.repeat(MAX_CONTACT_TEXT_LENGTH)); + expect(dto.company).toBeNull(); + expect(dto.department).toBe('구매팀'); + expect(dto.title).toBeNull(); + expect(dto.note).toBe('메모'); + expect(dto.emails[0]?.address).toBe('a@x.example'); + expect(dto.phones[0]?.number).toBe('02-333-4444'); + }); + + it('모든 칸을 보낸다 — 키가 빠지면 형식 오류 · 모르는 칸은 받지 않는다', async () => { + for (const key of [ + 'name', + 'emails', + 'phones', + 'company', + 'department', + 'title', + 'note', + 'groupIds', + ]) { + const partial: Record = body(); + delete partial[key]; + expect( + (await check(ContactInputDto, partial)).names.length, + ).toBeGreaterThan(0); + } + expect(await namesOf({ birthday: '1990-05-01' })).toEqual([ + 'whitelistValidation', + ]); + expect( + await namesOf({ + emails: [{ address: 'a@x.example', label: 'work', pref: 1 }], + }), + ).toEqual(['whitelistValidation']); + }); + + it(`한 줄 칸(이름 · 회사 · 부서 · 직책) — ${MAX_CONTACT_TEXT_LENGTH}자까지(JS 문자열 길이), 제어 문자 없음`, async () => { + for (const key of ['name', 'company', 'department', 'title']) { + expect( + await namesOf({ [key]: 'x'.repeat(MAX_CONTACT_TEXT_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + // 이모지는 두 자로 센다 (웹의 .length 와 같다) + expect( + await namesOf({ + [key]: `${'😀'.repeat(MAX_CONTACT_TEXT_LENGTH / 2)}x`, + }), + ).toEqual(['maxCodeUnits']); + expect(await namesOf({ [key]: '가\t나' })).toEqual(['matches']); + expect(await namesOf({ [key]: 7 })).toContain('isString'); + } + }); + + it(`메모 — ${MAX_CONTACT_NOTE_LENGTH}자까지, 줄바꿈(\\n)만 되고 탭 · \\r 등은 안 된다`, async () => { + expect( + await namesOf({ note: 'x'.repeat(MAX_CONTACT_NOTE_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + expect( + await namesOf({ note: `줄\n${'x'.repeat(MAX_CONTACT_NOTE_LENGTH - 2)}` }), + ).toEqual([]); + for (const note of ['a\tb', 'a\r\nb', 'a\u0000b']) { + expect(await namesOf({ note })).toEqual(['matches']); + } + }); + + it(`이메일 — ${MAX_CONTACT_EMAILS}개까지 · 메일 주소 형식 · 254자 · 종류는 work · personal · other (같은 주소 두 번은 서비스가 본다)`, async () => { + const email = { address: 'a@x.example', label: 'work' }; + expect( + await namesOf({ + emails: Array.from({ length: MAX_CONTACT_EMAILS + 1 }, (_, i) => ({ + ...email, + address: `a${i}@x.example`, + })), + }), + ).toEqual(['arrayMaxSize']); + expect( + await namesOf({ + emails: Array.from({ length: MAX_CONTACT_EMAILS }, (_, i) => ({ + ...email, + address: `a${i}@x.example`, + })), + }), + ).toEqual([]); + expect(await namesOf({ emails: [{ ...email, address: 'a@' }] })).toContain( + 'isEmail', + ); + expect( + await namesOf({ + emails: [ + { ...email, address: `${'a'.repeat(64)}@${'b'.repeat(190)}.example` }, + ], + }), + ).toContain('maxCodeUnits'); + expect(await namesOf({ emails: [{ ...email, label: 'home' }] })).toEqual([ + 'isIn', + ]); + expect(await namesOf({ emails: email })).toContain('isArray'); + expect(await namesOf({ emails: [null] })).not.toEqual([]); + expect(await namesOf({ emails: [email, email] })).toEqual([]); + }); + + it(`전화번호 — ${MAX_CONTACT_PHONES}개까지 · ${MAX_CONTACT_PHONE_LENGTH}자 · 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만 · 종류는 mobile · work · home · other`, async () => { + const phone = { number: '010-3192-5540', label: 'mobile' }; + expect( + await namesOf({ + phones: Array.from({ length: MAX_CONTACT_PHONES + 1 }, () => phone), + }), + ).toEqual(['arrayMaxSize']); + expect( + await namesOf({ + phones: [ + { ...phone, number: '1'.repeat(MAX_CONTACT_PHONE_LENGTH + 1) }, + ], + }), + ).toEqual(['maxCodeUnits']); + // 글자가 틀림(내선 · tel:) · 숫자가 없음(+-) · 빈 글 + for (const number of ['내선 12', 'tel:010', '+-', '(#*)', '', ' ']) { + expect(await namesOf({ phones: [{ ...phone, number }] })).toEqual([ + 'matches', + ]); + } + for (const number of ['7', '+82 10-3192-5540', '(02) 333.4444 #2']) { + expect(await namesOf({ phones: [{ ...phone, number }] })).toEqual([]); + } + expect(await namesOf({ phones: [{ ...phone, label: 'fax' }] })).toEqual([ + 'isIn', + ]); + }); + + it(`그룹 — ${MAX_CONTACT_GROUP_IDS}개까지 · id 형식 · 겹치지 않게(ArrayDistinct)`, async () => { + expect( + await namesOf({ + groupIds: Array.from( + { length: MAX_CONTACT_GROUP_IDS + 1 }, + (_, i) => `g${i}`, + ), + }), + ).toEqual(['arrayMaxSize']); + expect(await namesOf({ groupIds: ['a/b'] })).toEqual(['matches']); + expect(await namesOf({ groupIds: ['g1', 'g1'] })).toEqual([ + 'arrayDistinct', + ]); + expect(await namesOf({ groupIds: 'g1' })).toContain('isArray'); + }); +}); + +describe('연락처 목록 쿼리 (GET /api/contacts)', () => { + const queryNames = async (query: object) => + (await check(ContactListQueryDto, query)).names; + + it(`q 는 앞뒤 공백을 지운 ${MAX_CONTACT_QUERY_LENGTH}자까지 · groupId · excludeGroupId 는 id 형식 · limit 1~100 · 모르는 쿼리는 받지 않는다`, async () => { + expect(await queryNames({})).toEqual([]); + expect( + await queryNames({ q: 'ㅂㅈㅎ', groupId: 'jy', limit: '100' }), + ).toEqual([]); + expect( + ( + await check(ContactListQueryDto, { + q: ` ${'x'.repeat(MAX_CONTACT_QUERY_LENGTH)} `, + }) + ).names, + ).toEqual([]); + expect( + await queryNames({ q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + expect(await queryNames({ groupId: 'a b' })).toEqual(['matches']); + // excludeGroupId 도 id 형식 (groupId 와 함께 쓰는 것은 서비스가 400) + expect(await queryNames({ excludeGroupId: 'jy' })).toEqual([]); + expect(await queryNames({ excludeGroupId: 'a/b' })).toEqual(['matches']); + expect(await queryNames({ limit: '101' })).toEqual(['max']); + expect(await queryNames({ sort: 'name' })).toEqual(['whitelistValidation']); + }); +}); + +describe('연락처 고치기 본문 (PATCH /api/contacts/{id} — 바꾼 칸만)', () => { + const patchNames = async (body: object) => + (await check(ContactPatchDto, body)).names; + + it('보낸 칸만 검사한다 — 빈 본문 · 칸 하나도 형식은 맞다(빈 본문은 서비스가 400), 보내지 않은 칸은 undefined', async () => { + expect(await patchNames({})).toEqual([]); + const { names, dto } = await check(ContactPatchDto, { + department: ' 영업팀 ', + }); + expect(names).toEqual([]); + expect(dto.department).toBe('영업팀'); + expect(dto.name).toBeUndefined(); + expect(dto.emails).toBeUndefined(); + expect(dto.groupIds).toBeUndefined(); + }); + + it('글 칸의 null · 빈 글은 "지운다"(null) — 배열 칸의 null 은 형식 오류', async () => { + const { names, dto } = await check(ContactPatchDto, { + name: null, + company: ' ', + note: '', + }); + expect(names).toEqual([]); + expect(dto.name).toBeNull(); + expect(dto.company).toBeNull(); + expect(dto.note).toBeNull(); + for (const key of ['emails', 'phones', 'groupIds']) { + expect(await patchNames({ [key]: null })).toContain('isArray'); + } + }); + + it('보낸 칸은 POST 와 같은 규칙 — 길이 · 모양 · 개수 · 모르는 칸', async () => { + expect( + await patchNames({ title: 'x'.repeat(MAX_CONTACT_TEXT_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + expect(await patchNames({ name: '가\t나' })).toEqual(['matches']); + expect(await patchNames({ note: 'a\tb' })).toEqual(['matches']); + expect( + await patchNames({ phones: [{ number: '내선', label: 'work' }] }), + ).toEqual(['matches']); + expect( + await patchNames({ emails: [{ address: 'a@', label: 'work' }] }), + ).toContain('isEmail'); + expect( + await patchNames({ + groupIds: Array.from( + { length: MAX_CONTACT_GROUP_IDS + 1 }, + (_, i) => `g${i}`, + ), + }), + ).toEqual(['arrayMaxSize']); + expect(await patchNames({ groupIds: ['g1', 'g1'] })).toEqual([ + 'arrayDistinct', + ]); + expect(await patchNames({ birthday: '1990-05-01' })).toEqual([ + 'whitelistValidation', + ]); + }); +}); + +describe('검사 차례 — 형식 → 길이 · 개수 → 모양 (stopAtFirstError 로 길이에서 멈춘다, 2026-10-29 리뷰 H1)', () => { + /** 전역 파이프처럼 첫 실패에서 멈춘다 — 길이와 모양이 함께 틀린 값은 길이만 걸려야 한다(정규식 · 주소 검사를 돌리지 않는다) */ + const firstNames = async (type: new () => object, body: object) => + (await check(type, body, true)).names; + /** 모양이 틀린 글자를 가운데에 둔다 — 앞뒤에 두면 앞뒤 공백 지우기가 먼저 지운다 */ + const tooLong = (length: number, bad: string) => + `x${bad}${'x'.repeat(length)}`; + + it.each([ + ['name', MAX_CONTACT_TEXT_LENGTH, '\t'], + ['company', MAX_CONTACT_TEXT_LENGTH, '\t'], + ['department', MAX_CONTACT_TEXT_LENGTH, '\t'], + ['title', MAX_CONTACT_TEXT_LENGTH, '\t'], + ['note', MAX_CONTACT_NOTE_LENGTH, '\t'], + ])('%s — 길고 제어 문자도 든 글은 maxCodeUnits 만', async (key, max, bad) => { + for (const type of [ContactInputDto, ContactPatchDto]) { + expect( + await firstNames(type, body({ [key]: tooLong(max, bad) })), + ).toEqual(['maxCodeUnits']); + } + }); + + it('전화번호 · 주소 — 길고 모양도 틀린 값은 maxCodeUnits 만, 문자열이 아니면 isString 만', async () => { + expect( + await firstNames( + ContactInputDto, + body({ + phones: [ + { + number: tooLong(MAX_CONTACT_PHONE_LENGTH, '내선'), + label: 'work', + }, + ], + }), + ), + ).toEqual(['maxCodeUnits']); + expect( + await firstNames( + ContactInputDto, + body({ + emails: [ + { + address: tooLong(MAX_CONTACT_EMAIL_LENGTH, '@@ '), + label: 'work', + }, + ], + }), + ), + ).toEqual(['maxCodeUnits']); + expect( + await firstNames( + ContactInputDto, + body({ phones: [{ number: 7, label: 'work' }] }), + ), + ).toEqual(['isString']); + }); + + it('그룹 이름 · 찾는 글(q) — 길이가 먼저', async () => { + expect( + await firstNames(ContactGroupNameDto, { name: tooLong(50, '\n') }), + ).toEqual(['maxCodeUnits']); + for (const type of [ContactListQueryDto, DirectoryEntryQueryDto]) { + expect( + await firstNames(type, { q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + } + }); + + it('그룹 id 목록 — 배열이 아니면 isArray 만 · 개수가 넘으면 arrayMaxSize 만(항목 모양 · 겹침은 보지 않는다)', async () => { + expect(await firstNames(ContactInputDto, body({ groupIds: 'g1' }))).toEqual( + ['isArray'], + ); + expect( + await firstNames( + ContactInputDto, + body({ + groupIds: Array.from( + { length: MAX_CONTACT_GROUP_IDS + 1 }, + () => 'a b', + ), + }), + ), + ).toEqual(['arrayMaxSize']); + }); +}); + +describe('전역 DTO 검증의 비용 (createValidationPipe — 한 칸에 100만 자, CPU 시간)', () => { + /** + * 본문 1MB 안에 한 칸을 100만 자로 채워도 길이에서 싸게 400 이다 (2026-10-29 리뷰 H1 — 전에는 전화번호 정규식이 길이 검사보다 먼저 돌았고 + * 그 정규식이 길이의 제곱이라 4만 자 한 칸에 CPU 1.7초, 100만 자면 수십 분이었다). + * 지금 한 칸에 수 ms(Windows 틱 15.6ms 아래) — 한도 1초는 부하(CI 의 4~7배)를 견디고, 옛 경로(4만 자에 1.7초)는 확실히 넘는다 + */ + const BUDGET_MS = 1_000; + const MILLION = 1_000_000; + + it.each<[string, new () => object, object]>([ + [ + '전화번호 (숫자 뒤에 틀린 글자 하나 — 옛 정규식이 가장 느렸던 모양)', + ContactInputDto, + body({ phones: [{ number: `${'1'.repeat(MILLION)}x`, label: 'work' }] }), + ], + [ + '전화번호 (고치기)', + ContactPatchDto, + { phones: [{ number: `${'1'.repeat(MILLION)}x`, label: 'work' }] }, + ], + [ + '주소', + ContactInputDto, + body({ + emails: [ + { address: `${'a'.repeat(MILLION)}@x.example`, label: 'work' }, + ], + }), + ], + [ + '메모 (끝에 탭)', + ContactInputDto, + body({ note: `${'가'.repeat(MILLION)}\t` }), + ], + ['이름 (고치기)', ContactPatchDto, { name: `${'가'.repeat(MILLION)}\t` }], + [ + '찾는 글', + ContactListQueryDto, + { q: `${'ㄱ'.repeat(MILLION)}`, limit: '20' }, + ], + ])('%s — 1초 안에 400', async (_label, metatype, value) => { + const pipe = createValidationPipe(); + const cpuMs = startCpuTimer(); + await expect( + pipe.transform(value, { type: 'body', metatype }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(cpuMs()).toBeLessThan(BUDGET_MS); + }); +}); diff --git a/backend/src/modules/contacts/dto/contact.dto.ts b/backend/src/modules/contacts/dto/contact.dto.ts new file mode 100644 index 0000000..e380bee --- /dev/null +++ b/backend/src/modules/contacts/dto/contact.dto.ts @@ -0,0 +1,525 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsEmail, + IsIn, + IsOptional, + IsString, + Matches, + ValidateIf, + ValidateNested, +} from 'class-validator'; + +import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator'; +import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; +import { + TrimString, + TrimStringOrNull, +} from '@/common/decorators/trim-string.decorator'; +import { + CursorPaginationQueryDto, + PageInfoDto, +} from '@/common/dto/pagination.dto'; +import { + CONTACT_ID_PATTERN, + CONTACT_NOTE_PATTERN, + CONTACT_PHONE_DIGIT, + CONTACT_PHONE_PATTERN, + MAX_CONTACT_EMAIL_LENGTH, + MAX_CONTACT_EMAILS, + MAX_CONTACT_GROUP_IDS, + MAX_CONTACT_NOTE_LENGTH, + MAX_CONTACT_PHONE_LENGTH, + MAX_CONTACT_PHONES, + MAX_CONTACT_QUERY_LENGTH, + MAX_CONTACT_TEXT_LENGTH, +} from '@/modules/contacts/contacts.constants'; +import { + CONTACT_EMAIL_LABELS, + CONTACT_PHONE_LABELS, + type Contact, + type ContactDeleted, + type ContactEmail, + type ContactEmailLabel, + type ContactInput, + type ContactPatch, + type ContactPhone, + type ContactPhoneLabel, +} from '@/modules/contacts/contacts.types'; +import { SINGLE_LINE_TEXT_PATTERN } from '@/modules/mail/mail.constants'; + +/** + * 연락처 — POST 본문(ContactInput) · PATCH 본문(ContactPatch)과 응답(Contact · ContactDeleted) (계약 3절 "주소록"). + * 여기서는 칸마다 형식을 본다. 이름이나 이메일 가운데 하나는 있어야 함 · 같은 연락처 안의 같은 주소(대소문자 무시) · 빈 PATCH 는 서비스가 본다 (모두 400 VAL_001). + * 글은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다 — 빈 글은 null. + * - POST 는 모든 칸을 보낸다(값 없음은 null · 빈 배열, 키가 빠지면 형식 오류) + * - PATCH 는 바꾼 칸만 보낸다 — 보내지 않은 칸은 검사하지 않는다(다른 프로그램이 우리 규칙 밖의 값을 넣었어도 다른 칸은 고칠 수 있다) + * + * **검사 차례**: 칸의 검사는 applyDecorators 로 묶어 **적은 차례대로** 돈다 — 형식(IsString · IsArray) → 길이 · 개수 → 모양(정규식 · 메일 주소). + * 전역 파이프는 stopAtFirstError 라(app.setup.ts) 길이에서 걸리면 그 뒤의 정규식 · 주소 검사를 하지 않는다 — 1MB 짜리 한 칸이 와도 싸게 400 이다 + * (2026-10-29 리뷰 H1 — 데코레이터를 그냥 쌓으면 아래 것부터 돌아, 정규식이 길이 검사보다 먼저 돌았다) + */ + +/** POST — null 은 "없음"으로 받는다. 키가 없으면(undefined) 형식 오류다 (모든 칸을 보낸다) */ +const unlessNull = (_object: object, value: unknown) => value !== null; +/** PATCH — 보내지 않은 칸(undefined)과 "없음"(null)은 검사하지 않는다 */ +const whenSentText = (_object: object, value: unknown) => + value !== undefined && value !== null; +/** PATCH — 보내지 않은 배열 칸은 검사하지 않는다 (null 은 형식 오류) */ +const whenSent = (_object: object, value: unknown) => value !== undefined; + +type Sent = 'always' | 'patch'; + +/** 한 줄 글 칸(이름 · 회사 · 부서 · 직책) — 앞뒤 공백을 빼고 100자, 제어 문자 없음, 빈 글은 null */ +function SingleLineText( + label: string, + topic: string, + sent: Sent, +): PropertyDecorator { + return applyDecorators( + TrimStringOrNull(), + ValidateIf(sent === 'always' ? unlessNull : whenSentText), + IsString({ message: `${label} 형식이 올바르지 않습니다.` }), + MaxCodeUnits(MAX_CONTACT_TEXT_LENGTH, { + message: `${topic} ${MAX_CONTACT_TEXT_LENGTH}자 이하로 입력해 주세요.`, + }), + Matches(SINGLE_LINE_TEXT_PATTERN, { + message: `${label}에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.`, + }), + ); +} + +function NoteText(sent: Sent): PropertyDecorator { + return applyDecorators( + TrimStringOrNull(), + ValidateIf(sent === 'always' ? unlessNull : whenSentText), + IsString({ message: '메모 형식이 올바르지 않습니다.' }), + MaxCodeUnits(MAX_CONTACT_NOTE_LENGTH, { + message: `메모는 ${MAX_CONTACT_NOTE_LENGTH.toLocaleString('en-US')}자 이하로 입력해 주세요.`, + }), + Matches(CONTACT_NOTE_PATTERN, { + message: '메모에는 줄바꿈 말고 다른 제어 문자를 쓸 수 없습니다.', + }), + ); +} + +/** 메일 주소 — 254자 · 메일 주소 형식 (앞뒤 공백을 지운 값) */ +function EmailAddress(): PropertyDecorator { + return applyDecorators( + TrimString(), + IsString({ message: '이메일 형식이 올바르지 않습니다.' }), + MaxCodeUnits(MAX_CONTACT_EMAIL_LENGTH, { + message: `이메일 주소는 ${MAX_CONTACT_EMAIL_LENGTH}자 이하로 입력해 주세요.`, + }), + IsEmail({}, { message: '이메일 형식이 올바르지 않습니다.' }), + ); +} + +/** 전화번호 — 40자 · 숫자 · 띄어쓰기 · + - ( ) . # * 만 · 숫자가 하나 이상 (앞뒤 공백을 지운 값) */ +function PhoneNumber(): PropertyDecorator { + return applyDecorators( + TrimString(), + IsString({ message: '전화번호 형식이 올바르지 않습니다.' }), + MaxCodeUnits(MAX_CONTACT_PHONE_LENGTH, { + message: `전화번호는 ${MAX_CONTACT_PHONE_LENGTH}자 이하로 입력해 주세요.`, + }), + Matches(CONTACT_PHONE_PATTERN, { + message: + '전화번호에는 숫자와 띄어쓰기 · + - ( ) . # * 만 쓸 수 있습니다.', + }), + Matches(CONTACT_PHONE_DIGIT, { + message: '전화번호에는 숫자가 하나 이상 있어야 합니다.', + }), + ); +} + +export class ContactEmailDto implements ContactEmail { + @ApiProperty({ + description: `메일 주소 — ${MAX_CONTACT_EMAIL_LENGTH}자까지 · 메일 주소 형식 (앞뒤 공백은 지워 저장한다)`, + example: 'jihoon.park@hanbit.co.kr', + maxLength: MAX_CONTACT_EMAIL_LENGTH, + }) + @EmailAddress() + address!: string; + + @ApiProperty({ + description: '종류 — work 회사 · personal 개인 · other 기타', + enum: [...CONTACT_EMAIL_LABELS], + example: 'work', + }) + @IsIn(CONTACT_EMAIL_LABELS, { message: '이메일 종류를 다시 골라 주세요.' }) + label!: ContactEmailLabel; +} + +export class ContactPhoneDto implements ContactPhone { + @ApiProperty({ + description: `전화번호 — ${MAX_CONTACT_PHONE_LENGTH}자까지, 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만 (앞뒤 공백은 지워 저장한다)`, + example: '010-3192-5540', + maxLength: MAX_CONTACT_PHONE_LENGTH, + }) + @PhoneNumber() + number!: string; + + @ApiProperty({ + description: '종류 — mobile 휴대전화 · work 회사 · home 집 · other 기타', + enum: [...CONTACT_PHONE_LABELS], + example: 'mobile', + }) + @IsIn(CONTACT_PHONE_LABELS, { message: '전화번호 종류를 다시 골라 주세요.' }) + label!: ContactPhoneLabel; +} + +function EmailList(sent: Sent): PropertyDecorator { + return applyDecorators( + ...(sent === 'patch' ? [ValidateIf(whenSent)] : []), + IsArray({ message: '이메일 목록 형식이 올바르지 않습니다.' }), + ArrayMaxSize(MAX_CONTACT_EMAILS, { + message: `이메일은 ${MAX_CONTACT_EMAILS}개까지 넣을 수 있습니다.`, + }), + ValidateNested({ each: true }), + Type(() => ContactEmailDto), + ); +} + +function PhoneList(sent: Sent): PropertyDecorator { + return applyDecorators( + ...(sent === 'patch' ? [ValidateIf(whenSent)] : []), + IsArray({ message: '전화번호 목록 형식이 올바르지 않습니다.' }), + ArrayMaxSize(MAX_CONTACT_PHONES, { + message: `전화번호는 ${MAX_CONTACT_PHONES}개까지 넣을 수 있습니다.`, + }), + ValidateNested({ each: true }), + Type(() => ContactPhoneDto), + ); +} + +function GroupIdList(sent: Sent): PropertyDecorator { + return applyDecorators( + ...(sent === 'patch' ? [ValidateIf(whenSent)] : []), + IsArray({ message: '그룹 목록 형식이 올바르지 않습니다.' }), + ArrayMaxSize(MAX_CONTACT_GROUP_IDS, { + message: `그룹은 ${MAX_CONTACT_GROUP_IDS}개까지 고를 수 있습니다.`, + }), + Matches(CONTACT_ID_PATTERN, { + each: true, + message: '그룹을 다시 골라 주세요.', + }), + ArrayDistinct({ message: '같은 그룹을 두 번 고를 수 없습니다.' }), + ); +} + +/** 한 줄 글 칸의 설명 */ +function singleLineDescription(what: string): string { + return `${what} — 앞뒤 공백을 뺀 ${MAX_CONTACT_TEXT_LENGTH}자까지(JS 문자열 길이) · 제어 문자 없음. 없으면 null (빈 글은 null 로 저장한다)`; +} + +const NOTE_DESCRIPTION = `메모 — 앞뒤 공백을 뺀 ${MAX_CONTACT_NOTE_LENGTH.toLocaleString('en-US')}자까지. 줄바꿈(\\n)은 되고 그 밖의 제어 문자(탭 · \\r 포함)는 안 된다. 없으면 null`; +const EMAILS_DESCRIPTION = `이메일 — ${MAX_CONTACT_EMAILS}개까지(화면은 5개까지 더한다), 앞의 것이 대표 주소. 같은 연락처 안에서 겹치지 않게(대소문자 무시 — 아니면 400 VAL_001)`; +const PHONES_DESCRIPTION = `전화번호 — ${MAX_CONTACT_PHONES}개까지`; +const GROUP_IDS_DESCRIPTION = `속할 그룹 id — ${MAX_CONTACT_GROUP_IDS}개까지, 겹치지 않게. 이 주소록에 없는 그룹이면 404 RES_001`; + +export class ContactInputDto implements ContactInput { + @ApiProperty({ + description: singleLineDescription('표시 이름'), + nullable: true, + type: String, + example: '박지훈', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('이름', '이름은', 'always') + name!: string | null; + + @ApiProperty({ + description: EMAILS_DESCRIPTION, + type: [ContactEmailDto], + maxItems: MAX_CONTACT_EMAILS, + }) + @EmailList('always') + emails!: ContactEmailDto[]; + + @ApiProperty({ + description: PHONES_DESCRIPTION, + type: [ContactPhoneDto], + maxItems: MAX_CONTACT_PHONES, + }) + @PhoneList('always') + phones!: ContactPhoneDto[]; + + @ApiProperty({ + description: singleLineDescription('회사'), + nullable: true, + type: String, + example: '한빛상사', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('회사', '회사는', 'always') + company!: string | null; + + @ApiProperty({ + description: singleLineDescription('부서'), + nullable: true, + type: String, + example: '구매팀', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('부서', '부서는', 'always') + department!: string | null; + + @ApiProperty({ + description: singleLineDescription('직책'), + nullable: true, + type: String, + example: '과장', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('직책', '직책은', 'always') + title!: string | null; + + @ApiProperty({ + description: NOTE_DESCRIPTION, + nullable: true, + type: String, + example: '견적 담당\n월요일 오전에 연락', + maxLength: MAX_CONTACT_NOTE_LENGTH, + }) + @NoteText('always') + note!: string | null; + + @ApiProperty({ + description: GROUP_IDS_DESCRIPTION, + type: [String], + maxItems: MAX_CONTACT_GROUP_IDS, + example: ['jy'], + }) + @GroupIdList('always') + groupIds!: string[]; +} + +const PATCH_NOTE = + ' — 보낼 때만 검사하고 바꾼다(보내지 않으면 그대로, 다른 프로그램이 넣은 규칙 밖의 값도 그대로)'; + +/** + * PATCH 본문 — 바꾼 칸만(하나 이상 — 비면 400 VAL_001, 서비스가 본다). 보낸 칸만 위 규칙으로 보고, "이름이나 이메일"은 지금 연락처와 합친 결과로 본다. + * emails · phones · groupIds 는 보내면 그 칸이 통째로 바뀐다. 텍스트 칸의 null 은 "지운다" + */ +export class ContactPatchDto implements ContactPatch { + @ApiPropertyOptional({ + description: `${singleLineDescription('표시 이름')}${PATCH_NOTE}`, + nullable: true, + type: String, + example: '박지훈', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('이름', '이름은', 'patch') + name?: string | null; + + @ApiPropertyOptional({ + description: `${EMAILS_DESCRIPTION}${PATCH_NOTE}`, + type: [ContactEmailDto], + maxItems: MAX_CONTACT_EMAILS, + }) + @EmailList('patch') + emails?: ContactEmailDto[]; + + @ApiPropertyOptional({ + description: `${PHONES_DESCRIPTION}${PATCH_NOTE}`, + type: [ContactPhoneDto], + maxItems: MAX_CONTACT_PHONES, + }) + @PhoneList('patch') + phones?: ContactPhoneDto[]; + + @ApiPropertyOptional({ + description: `${singleLineDescription('회사')}${PATCH_NOTE}`, + nullable: true, + type: String, + example: '한빛상사', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('회사', '회사는', 'patch') + company?: string | null; + + @ApiPropertyOptional({ + description: `${singleLineDescription('부서')}${PATCH_NOTE}`, + nullable: true, + type: String, + example: '구매팀', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('부서', '부서는', 'patch') + department?: string | null; + + @ApiPropertyOptional({ + description: `${singleLineDescription('직책')}${PATCH_NOTE}`, + nullable: true, + type: String, + example: '과장', + maxLength: MAX_CONTACT_TEXT_LENGTH, + }) + @SingleLineText('직책', '직책은', 'patch') + title?: string | null; + + @ApiPropertyOptional({ + description: `${NOTE_DESCRIPTION}${PATCH_NOTE}`, + nullable: true, + type: String, + example: '견적 담당', + maxLength: MAX_CONTACT_NOTE_LENGTH, + }) + @NoteText('patch') + note?: string | null; + + @ApiPropertyOptional({ + description: `${GROUP_IDS_DESCRIPTION}${PATCH_NOTE}`, + type: [String], + maxItems: MAX_CONTACT_GROUP_IDS, + example: ['jy'], + }) + @GroupIdList('patch') + groupIds?: string[]; +} + +/** 응답 — 계약 3절 Contact */ +export class ContactDto implements Contact { + @ApiProperty({ description: '연락처 id (엔진의 카드 id)', example: 'jw' }) + id!: string; + + @ApiProperty({ + description: + '표시 이름 (엔진 카드의 name.full — 없으면 이름 조각을 이어 붙인 것(한글 조각끼리는 붙여 쓴다), 그것도 없으면 null)', + nullable: true, + type: String, + example: '박지훈', + }) + name!: string | null; + + @ApiProperty({ + description: + '이메일 — 앞의 것이 대표 주소(받는 사람 추천의 첫 줄 · 그룹으로 보낼 때 쓴다). 다른 프로그램이 넣은 것은 개수 제한 없이 그대로 보인다', + type: [ContactEmailDto], + }) + emails!: ContactEmailDto[]; + + @ApiProperty({ description: '전화번호', type: [ContactPhoneDto] }) + phones!: ContactPhoneDto[]; + + @ApiProperty({ + description: '회사 (없으면 null)', + nullable: true, + type: String, + example: '한빛상사', + }) + company!: string | null; + + @ApiProperty({ + description: '부서 (없으면 null)', + nullable: true, + type: String, + example: '구매팀', + }) + department!: string | null; + + @ApiProperty({ + description: '직책 (없으면 null)', + nullable: true, + type: String, + example: '과장', + }) + title!: string | null; + + @ApiProperty({ + description: '메모 (줄바꿈 있음, 없으면 null)', + nullable: true, + type: String, + example: '견적 담당\n월요일 오전에 연락', + }) + note!: string | null; + + @ApiProperty({ + description: '속한 그룹 id — 이 주소록의 그룹만 (그룹 이름 차례)', + type: [String], + example: ['jy'], + }) + groupIds!: string[]; +} + +/** 연락처 지우기의 응답 — 계약 3절 ContactDeleted */ +export class ContactDeletedDto implements ContactDeleted { + @ApiProperty({ + type: ContactDto, + description: '지운 연락처 (그룹에서도 뺐다)', + }) + contact!: ContactDto; + + @ApiProperty({ + description: + '[되돌리기] 표 — POST /api/contact-restorations 의 token. 5분 · 한 번만 쓴다. 서버가 기억하지 못했으면 null(카드가 256KB 를 넘음 등 — 화면은 [되돌리기] 없이 알린다)', + nullable: true, + type: String, + example: 'q3Jd0Vb1Lw9xA2mYk7Pz4g', + }) + restoreToken!: string | null; +} + +export class ContactPageDto { + @ApiProperty({ + type: [ContactDto], + description: + '이름 차례(한국어 사전 차례 — 이름이 없으면 대표 주소로 견준다. 같으면 대표 주소 → id). 이름도 주소도 없는 연락처는 맨 뒤', + }) + items!: ContactDto[]; + + @ApiProperty({ type: PageInfoDto, description: 'total 포함 (거른 개수)' }) + pageInfo!: PageInfoDto; +} + +/** 찾는 글(q) — 연락처 · 사내 목록이 같은 규칙 */ +export function ContactQueryText(): PropertyDecorator { + return applyDecorators( + IsOptional(), + TrimString(), + IsString({ message: '찾는 글 형식이 올바르지 않습니다.' }), + MaxCodeUnits(MAX_CONTACT_QUERY_LENGTH, { + message: `찾는 글은 ${MAX_CONTACT_QUERY_LENGTH}자 이하로 입력해 주세요.`, + }), + ); +} + +/** 그룹 id 쿼리(groupId · excludeGroupId) */ +export function ContactGroupIdQuery(): PropertyDecorator { + return applyDecorators( + IsOptional(), + IsString({ message: '그룹을 다시 골라 주세요.' }), + Matches(CONTACT_ID_PATTERN, { message: '그룹을 다시 골라 주세요.' }), + ); +} + +/** GET /api/contacts — 커서 페이지네이션 + 찾는 글 · 그룹 */ +export class ContactListQueryDto extends CursorPaginationQueryDto { + @ApiPropertyOptional({ + description: + `찾는 글 — 앞뒤 공백을 뺀 ${MAX_CONTACT_QUERY_LENGTH}자까지(비면 거르지 않는다). 초성만 친 글이면 이름의 초성과 견주고(ㅂㅈㅎ → 박지훈), ` + + '그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 든 것(ASCII 대소문자 무시 — 전화번호는 숫자만 견준다)', + example: 'ㅂㅈㅎ', + }) + @ContactQueryText() + q?: string; + + @ApiPropertyOptional({ + description: '이 그룹의 구성원만 — 없는 그룹이면 404 RES_001', + example: 'jy', + }) + @ContactGroupIdQuery() + groupId?: string; + + @ApiPropertyOptional({ + description: + '이 그룹의 구성원을 빼고(구성원 넣기 창) — 없는 그룹이면 404 RES_001. groupId 와 함께 쓰지 않는다(400 VAL_001)', + example: 'jy', + }) + @ContactGroupIdQuery() + excludeGroupId?: string; +} diff --git a/backend/src/modules/contacts/dto/directory-entry.dto.ts b/backend/src/modules/contacts/dto/directory-entry.dto.ts new file mode 100644 index 0000000..ccec2f2 --- /dev/null +++ b/backend/src/modules/contacts/dto/directory-entry.dto.ts @@ -0,0 +1,84 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +import { + CursorPaginationQueryDto, + PageInfoDto, +} from '@/common/dto/pagination.dto'; +import { MAX_CONTACT_QUERY_LENGTH } from '@/modules/contacts/contacts.constants'; +import { + DIRECTORY_ENTRY_KINDS, + type DirectoryEntry, + type DirectoryEntryKind, +} from '@/modules/contacts/contacts.types'; +import { ContactGroupIdQuery, ContactQueryText } from './contact.dto'; + +/** GET /api/directory-entries — 커서 페이지네이션 + 찾는 글 · 종류 (계약 3절 "사내 목록") */ +export class DirectoryEntryQueryDto extends CursorPaginationQueryDto { + @ApiPropertyOptional({ + description: + `찾는 글 — 앞뒤 공백을 뺀 ${MAX_CONTACT_QUERY_LENGTH}자까지(비면 거르지 않는다). 초성만 친 글이면 이름의 초성과 견주고, ` + + '그 밖에는 이름 · 주소에 든 것 (ASCII 대소문자 무시)', + example: '도윤', + }) + @ContactQueryText() + q?: string; + + @ApiPropertyOptional({ + description: 'person 직원(개인 계정)만 · shared 공용 주소(그룹 계정)만', + enum: [...DIRECTORY_ENTRY_KINDS], + example: 'person', + }) + @IsOptional() + @IsIn(DIRECTORY_ENTRY_KINDS, { message: '종류를 다시 골라 주세요.' }) + kind?: DirectoryEntryKind; + + @ApiPropertyOptional({ + description: + '이 그룹(내 주소록) 구성원이 가진 주소(대소문자 무시)를 뺀다 — 구성원 넣기 창. 없는 그룹이면 404 RES_001', + example: 'jy', + }) + @ContactGroupIdQuery() + excludeGroupId?: string; +} + +/** 응답 — 계약 3절 DirectoryEntry */ +export class DirectoryEntryDto implements DirectoryEntry { + @ApiProperty({ + description: + '엔진의 계정 id — 그룹 구성원 넣기(POST /api/contact-groups/{id}/members)의 directoryIds 에 쓴다', + example: 'b', + }) + id!: string; + + @ApiProperty({ + description: + '엔진의 표시 이름(계정의 설명 — 관리 화면에서 넣는 이름), 없으면 주소의 @ 앞', + example: '이도윤', + }) + name!: string; + + @ApiProperty({ + description: '사내 주소 (소문자)', + example: 'doyun.lee@arasystem.it.kr', + }) + email!: string; + + @ApiProperty({ + description: 'person 직원(개인 계정) · shared 공용 주소(그룹 계정)', + enum: [...DIRECTORY_ENTRY_KINDS], + example: 'person', + }) + kind!: DirectoryEntryKind; +} + +export class DirectoryEntryPageDto { + @ApiProperty({ + type: [DirectoryEntryDto], + description: '이름 차례(한국어 사전 차례 → 주소 → id)', + }) + items!: DirectoryEntryDto[]; + + @ApiProperty({ type: PageInfoDto, description: 'total 포함 (거른 개수)' }) + pageInfo!: PageInfoDto; +} diff --git a/backend/src/modules/mail-directory/mail-directory.entity.ts b/backend/src/modules/mail-directory/mail-directory.entity.ts index 70ac8fa..942f26c 100644 --- a/backend/src/modules/mail-directory/mail-directory.entity.ts +++ b/backend/src/modules/mail-directory/mail-directory.entity.ts @@ -6,9 +6,10 @@ const EMAIL_COLUMN_LENGTH = 254; export const DIRECTORY_NAME_LENGTH = 200; /** - * 사내 목록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름. - * 메일 엔진의 계정 목록(Principal) 권한은 쓰지 않는다 — 일부러 막아 두었다 (mail/scripts/setup.mjs). - * 메일 엔진 관리자·게이트웨이·막은 계정은 넣지 않는다 (로그인할 수도 없다). + * 로그인 기록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름. + * 8단계부터 받는 사람 추천의 "사내"는 엔진의 사내 목록(contacts/directory.service.ts — 사내 목록 계정으로 계정 목록을 읽는다)이고, + * 이 표는 그것을 읽지 못할 때 대신 쓴다. 직원 역할의 계정 목록(Principal) 권한은 그대로 막아 두었다 (mail/scripts/setup.mjs). + * 메일 엔진 관리자·게이트웨이·사내 목록 계정·막은 계정은 넣지 않는다 (로그인할 수도 없다). */ @Entity({ name: 'mail_directory' }) export class MailDirectoryEntryEntity { diff --git a/backend/src/modules/mail-directory/mail-directory.module.ts b/backend/src/modules/mail-directory/mail-directory.module.ts index 742d523..197900b 100644 --- a/backend/src/modules/mail-directory/mail-directory.module.ts +++ b/backend/src/modules/mail-directory/mail-directory.module.ts @@ -5,7 +5,7 @@ import { MailDirectoryEntryEntity } from './mail-directory.entity'; import { MailDirectoryService } from './mail-directory.service'; /** - * 사내 목록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천)이 함께 쓴다. + * 로그인 기록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천 — 엔진의 사내 목록을 읽지 못할 때 대신)이 함께 쓴다. * 두 모듈 사이에 두어 서로를 가져오지 않게 한다 (AuthModule → MailModule 방향만 있다). */ @Module({ diff --git a/backend/src/modules/mail-directory/mail-directory.service.ts b/backend/src/modules/mail-directory/mail-directory.service.ts index d57a4c3..de9af1e 100644 --- a/backend/src/modules/mail-directory/mail-directory.service.ts +++ b/backend/src/modules/mail-directory/mail-directory.service.ts @@ -20,7 +20,8 @@ export const MAIL_DIRECTORY_ACTIVE_DAYS = 90; const DAY_MS = 24 * 60 * 60 * 1000; /** - * 사내 목록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 읽는다(RecipientSuggestionService). + * 로그인 기록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 엔진의 사내 목록을 읽지 못할 때 대신 읽는다 + * (RecipientSuggestionService — 8단계부터 "사내"는 contacts/directory.service.ts). * 누가 넣을지(관리자·게이트웨이 제외)는 부르는 쪽이 정한다. */ @Injectable() diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index d5b4fef..4cb8cc9 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -1,6 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { PageInfoDto } from '@/common/dto/pagination.dto'; +import { + CONTACT_EMAIL_LABELS, + type ContactEmailLabel, +} from '@/modules/contacts/contacts.types'; import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants'; import type { Draft, @@ -526,6 +530,7 @@ export class DraftSavedDto implements SavedDraft { } const SUGGESTION_SOURCES: RecipientSuggestion['source'][] = [ + 'contact', 'directory', 'recent', ]; @@ -548,18 +553,36 @@ export class RecipientSuggestionDto implements RecipientSuggestion { @ApiProperty({ description: - 'directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소. 두 쪽에 다 있으면 directory', + 'contact = 내 주소록(연락처의 주소마다 한 줄), directory = 사내(엔진의 사내 목록 — 읽지 못하면 웹메일에 로그인한 적 있는 직원), ' + + 'recent = 최근 주고받은 주소. 여러 묶음에 있으면 앞 묶음(contact → directory → recent)', enum: SUGGESTION_SOURCES, example: 'directory', }) source!: RecipientSuggestion['source']; + + @ApiProperty({ + description: 'contact 만 — 그 연락처의 회사 (그 밖에는 null)', + nullable: true, + type: String, + example: null, + }) + company!: string | null; + + @ApiProperty({ + description: + 'contact 만 — 그 주소의 종류 work 회사 · personal 개인 · other 기타 (그 밖에는 null)', + nullable: true, + enum: [...CONTACT_EMAIL_LABELS], + example: null, + }) + emailLabel!: ContactEmailLabel | null; } export class RecipientSuggestionPageDto { @ApiProperty({ type: [RecipientSuggestionDto], description: - '사내 → 최근 주고받은 주소, 묶음 안에서는 주고받은 횟수와 최근성 순', + '주소록(연락처의 이름 차례 — 대표 주소가 먼저) → 사내 → 최근 주고받은 주소, 사내 · 최근 안에서는 주고받은 횟수와 최근성 순', }) items!: RecipientSuggestionDto[]; diff --git a/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts b/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts index 683ad26..93e3e4d 100644 --- a/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts +++ b/backend/src/modules/mail/dto/recipient-suggestion-query.dto.ts @@ -7,7 +7,7 @@ import { MAX_SUGGESTION_QUERY_LENGTH } from '@/modules/mail/mail.constants'; /** GET /api/recipient-suggestions — 커서 페이지네이션 + 거르는 글자 (계약 2절 "받는 사람 추천") */ export class RecipientSuggestionQueryDto extends CursorPaginationQueryDto { @ApiPropertyOptional({ - description: `이름·주소에 든 글자 (${MAX_SUGGESTION_QUERY_LENGTH}자 이하, 대소문자 무시). 없거나 공백뿐이면 거르지 않는다`, + description: `찾는 글자 (${MAX_SUGGESTION_QUERY_LENGTH}자 이하) — 쓰기 화면과 같은 맞추기: 이름은 초성 · 조합 중 글자도, 주소는 그 글자가 든 것(완전한 주소면 같은 아이디도), 대소문자 무시. 없거나 공백뿐이면 거르지 않는다`, example: '도윤', }) @IsOptional() diff --git a/backend/src/modules/mail/jmap/contact-card.spec.ts b/backend/src/modules/mail/jmap/contact-card.spec.ts new file mode 100644 index 0000000..674102a --- /dev/null +++ b/backend/src/modules/mail/jmap/contact-card.spec.ts @@ -0,0 +1,861 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { ContactFields } from '@/modules/contacts/contacts.types'; +import { + cardOriginal, + contactPatch, + groupPatch, + newCardJson, + patchedCard, + readContactCard, +} from './contact-card'; +import type { JmapContactCard } from './jmap.types'; + +/** 2026-10-29 개발 엔진(Stalwart 0.16.27)이 돌려준 카드 모양 — p8-contacts-state.log */ +const ENGINE_CARD: JmapContactCard = { + id: 'ju', + uid: 'urn:uuid:0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f', + kind: 'individual', + name: { full: '시험' }, + emails: { + e1: { address: 't@example.com', contexts: { work: true }, pref: 1 }, + }, + phones: { p1: { number: '010-1234-5678', features: { mobile: true } } }, + organizations: { o1: { name: '시험회사', units: [{ name: '시험부서' }] } }, + titles: { t1: { name: '과장', kind: 'title' } }, + notes: { n1: { note: '메모\n둘째 줄' } }, +}; + +const fields: ContactFields = { + name: '박지훈', + emails: [ + { address: 'jihoon@hanbit.example', label: 'work' }, + { address: 'jihoon@home.example', label: 'personal' }, + { address: 'jihoon@old.example', label: 'other' }, + ], + phones: [ + { number: '010-3192-5540', label: 'mobile' }, + { number: '02-333-4444', label: 'work' }, + { number: '031-555-6666', label: 'home' }, + { number: '070-1111-2222', label: 'other' }, + ], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '견적 담당\n월요일 오전', +}; + +describe('JSContact 카드 읽기 (readContactCard)', () => { + it('엔진이 돌려준 카드 — 우리 칸으로 그대로 (메모의 줄바꿈 포함)', () => { + expect(readContactCard(ENGINE_CARD)).toEqual({ + kind: 'contact', + card: { + id: 'ju', + uid: 'urn:uuid:0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f', + name: '시험', + emails: [{ address: 't@example.com', label: 'work' }], + phones: [{ number: '010-1234-5678', label: 'mobile' }], + company: '시험회사', + department: '시험부서', + title: '과장', + note: '메모\n둘째 줄', + }, + }); + }); + + it('uid 없는 카드(다른 프로그램이 uid 없이 만든 것) — uid 는 null', () => { + const read = readContactCard({ id: 'a', name: { full: '옛 카드' } }); + expect(read?.card.uid).toBeNull(); + }); + + it('칸이 없는 카드 — 엔진은 이름 없는 카드에 빈 full 을 준다 → 이름 null, 나머지는 빈 값', () => { + expect(readContactCard({ id: 'j0', name: { full: '' } })).toEqual({ + kind: 'contact', + card: { + id: 'j0', + uid: null, + name: null, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }, + }); + // kind 가 없거나 group 이 아니면 연락처다 + expect(readContactCard({ id: 'x', kind: 'org' })?.kind).toBe('contact'); + // id 가 없으면 읽지 않는다 + expect(readContactCard({ id: '' })).toBeNull(); + }); + + it('이메일 6개 이상(화면은 5개까지 더한다) — 모두 읽고, pref(작을수록 먼저) → 엔진이 준 차례', () => { + const emails: Record> = Object.fromEntries( + Array.from({ length: 7 }, (_, index) => [ + `e${index + 1}`, + { address: `p${index + 1}@x.example` }, + ]), + ); + emails.e6 = { + address: 'p6@x.example', + pref: 1, + contexts: { private: true }, + }; + emails.e3 = { address: 'p3@x.example', pref: 50 }; + + const read = readContactCard({ id: 'many', emails }); + expect( + read?.kind === 'contact' && + read.card.emails.map((email) => email.address), + ).toEqual([ + 'p6@x.example', + 'p3@x.example', + 'p1@x.example', + 'p2@x.example', + 'p4@x.example', + 'p5@x.example', + 'p7@x.example', + ]); + expect(read?.kind === 'contact' && read.card.emails[0]?.label).toBe( + 'personal', + ); + }); + + it('종류 — 이메일은 contexts(work 회사 · private 개인 · 그 밖 기타), 전화는 features.mobile → contexts work · private → 기타', () => { + const read = readContactCard({ + id: 'k', + emails: { + a: { address: 'a@x.example', contexts: { work: true, private: true } }, + b: { address: 'b@x.example', contexts: { private: true } }, + c: { address: 'c@x.example', contexts: { billing: true } }, + }, + phones: { + a: { + number: '1', + features: { mobile: true }, + contexts: { work: true }, + }, + b: { number: '2', contexts: { work: true } }, + c: { number: '3', contexts: { private: true } }, + d: { number: '4', features: { fax: true } }, + }, + }); + + expect(read?.kind === 'contact' && read.card.emails).toEqual([ + { address: 'a@x.example', label: 'work' }, + { address: 'b@x.example', label: 'personal' }, + { address: 'c@x.example', label: 'other' }, + ]); + expect(read?.kind === 'contact' && read.card.phones).toEqual([ + { number: '1', label: 'mobile' }, + { number: '2', label: 'work' }, + { number: '3', label: 'home' }, + { number: '4', label: 'other' }, + ]); + }); + + it('다른 프로그램의 모양 — 이름 조각 · tel: 번호 · 문자열 부서 · role 직책 · 여러 메모(\\r\\n · 탭) · 모양이 틀린 값은 건너뛴다', () => { + const read = readContactCard({ + id: 'v', + name: { + components: [ + { kind: 'surname', value: '김' }, + { kind: 'given', value: '아라' }, + ], + }, + emails: { + a: { address: 42 }, + b: null, + c: { address: ' ara@x.example ' }, + }, + phones: { a: { number: 'tel:+82-10-1234-5678' }, b: { number: '' } }, + organizations: { o: { name: '한빛\t상사', units: ['영업팀'] } }, + titles: { + r: { name: '총무', kind: 'role' }, + t: { name: '대리' }, + }, + notes: { a: { note: '첫 줄\r\n둘째\t줄' }, b: { note: '다른 메모' } }, + }); + + expect(read).toEqual({ + kind: 'contact', + card: { + id: 'v', + uid: null, + name: '김아라', + emails: [{ address: 'ara@x.example', label: 'other' }], + phones: [{ number: '+82-10-1234-5678', label: 'other' }], + company: '한빛 상사', + department: '영업팀', + title: '대리', + note: '첫 줄\n둘째 줄\n다른 메모', + }, + }); + }); + + it.each<[string, Record, string]>([ + [ + '한글 조각끼리는 붙여 쓴다', + { + components: [ + { kind: 'surname', value: '남궁' }, + { kind: 'given', value: '민수' }, + ], + }, + '남궁민수', + ], + [ + '로마자 조각은 띄어 쓴다', + { + components: [ + { kind: 'given', value: 'Ara' }, + { kind: 'surname', value: 'Kim' }, + ], + }, + 'Ara Kim', + ], + [ + '한글과 로마자 사이는 띄어 쓴다', + { + components: [ + { kind: 'surname', value: '김' }, + { kind: 'given', value: 'Ara' }, + ], + }, + '김 Ara', + ], + [ + 'separator 조각은 그 글 그대로 (기본 구분보다 먼저)', + { + isOrdered: true, + defaultSeparator: ' ', + components: [ + { kind: 'surname', value: '김' }, + { kind: 'separator', value: ', ' }, + { kind: 'given', value: '아라' }, + ], + }, + '김, 아라', + ], + [ + 'defaultSeparator 가 있으면 한글끼리도 그것으로', + { + isOrdered: true, + defaultSeparator: ' ', + components: [ + { kind: 'surname', value: '김' }, + { kind: 'given', value: '아라' }, + ], + }, + '김 아라', + ], + [ + '빈 separator 는 붙여 쓰기 · 앞뒤의 separator 와 빈 조각은 버린다', + { + isOrdered: true, + defaultSeparator: ' ', + components: [ + { kind: 'separator', value: '-' }, + { kind: 'given', value: 'Jean' }, + { kind: 'separator', value: '' }, + { kind: 'given2', value: 'Luc' }, + { kind: 'title', value: ' ' }, + { kind: 'separator', value: '!' }, + ], + }, + 'JeanLuc', + ], + [ + 'full 이 있으면 조각은 보지 않는다', + { full: '김아라 과장', components: [{ kind: 'given', value: '아라' }] }, + '김아라 과장', + ], + ])('이름 조각만 있는 이름 — %s', (_label, name, expected) => { + expect(readContactCard({ id: 'n', name })?.card.name).toBe(expected); + }); + + it('그룹 — 이름과 구성원(true 인 uid 만). 구성원을 모두 빼면 엔진은 빈 members 를 준다', () => { + expect( + readContactCard({ + id: 'g', + uid: 'urn:uuid:g', + kind: 'group', + name: { full: '거래처' }, + members: { 'urn:uuid:a': true, 'urn:uuid:b': false, ' ': true }, + }), + ).toEqual({ + kind: 'group', + card: { + id: 'g', + uid: 'urn:uuid:g', + name: '거래처', + memberUids: ['urn:uuid:a'], + }, + }); + expect( + readContactCard({ id: 'e', kind: 'group', members: {} }), + ).toMatchObject({ card: { name: '', memberUids: [] } }); + }); +}); + +describe('JSContact 카드 쓰기 (newCardJson · cardPatch)', () => { + it('새 연락처 — @type · version · uid · kind · 기본 주소록, 대표 주소만 pref 1, 종류는 contexts · features', () => { + expect( + newCardJson('book', { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields, + }), + ).toEqual({ + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:new', + addressBookIds: { book: true }, + kind: 'individual', + name: { full: '박지훈' }, + emails: { + e1: { + address: 'jihoon@hanbit.example', + contexts: { work: true }, + pref: 1, + }, + e2: { address: 'jihoon@home.example', contexts: { private: true } }, + e3: { address: 'jihoon@old.example' }, + }, + phones: { + p1: { number: '010-3192-5540', features: { mobile: true } }, + p2: { number: '02-333-4444', contexts: { work: true } }, + p3: { number: '031-555-6666', contexts: { private: true } }, + p4: { number: '070-1111-2222' }, + }, + organizations: { + o1: { name: '한빛상사', units: [{ name: '구매팀' }] }, + }, + titles: { t1: { name: '과장', kind: 'title' } }, + notes: { n1: { note: '견적 담당\n월요일 오전' } }, + }); + }); + + it('새 연락처의 빈 칸은 보내지 않는다 — 부서만 있으면 소속은 부서만', () => { + expect( + newCardJson('book', { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields: { + name: null, + emails: [{ address: 'a@x.example', label: 'other' }], + phones: [], + company: null, + department: '총무팀', + title: null, + note: null, + }, + }), + ).toEqual({ + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:new', + addressBookIds: { book: true }, + kind: 'individual', + emails: { e1: { address: 'a@x.example', pref: 1 } }, + organizations: { o1: { units: [{ name: '총무팀' }] } }, + }); + }); + + it('새 그룹 — kind group · 이름 · 구성원(uid 맵), 구성원이 없으면 members 를 보내지 않는다', () => { + expect( + newCardJson('book', { + kind: 'group', + key: 'group', + uid: 'urn:uuid:g', + name: '거래처', + memberUids: ['urn:uuid:a', 'urn:uuid:b'], + }), + ).toEqual({ + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:g', + addressBookIds: { book: true }, + kind: 'group', + name: { full: '거래처' }, + members: { 'urn:uuid:a': true, 'urn:uuid:b': true }, + }); + expect( + newCardJson('book', { + kind: 'group', + key: 'group', + uid: 'urn:uuid:g', + name: '빈 그룹', + memberUids: [], + }), + ).not.toHaveProperty('members'); + }); + + it('쓰고 다시 읽으면 같다 (왕복)', () => { + const json = newCardJson('book', { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields, + }); + expect(readContactCard({ ...json, id: 'n1' })).toEqual({ + kind: 'contact', + card: { id: 'n1', uid: 'urn:uuid:new', ...fields }, + }); + }); +}); + +describe('되돌리기 카드 (newCardJson 의 restored · cardOriginal)', () => { + /** 2026-10-29 개발 엔진이 properties: null 로 돌려준 카드 모양 (be-probe-r2.log — 다른 프로그램의 칸이 많은 카드) */ + const ORIGINAL = { + id: 'lm', + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:orig', + kind: 'individual', + addressBookIds: { old: true }, + name: { full: '시험 원본', components: [{ kind: 'given', value: '원본' }] }, + nicknames: { k1: { name: '별명' } }, + emails: { x1: { address: 'one@x.example', label: '본사', pref: 1 } }, + anniversaries: { + b1: { + date: { '@type': 'PartialDate', month: 4, day: 26 }, + kind: 'birth', + }, + }, + keywords: { vip: true }, + }; + + it('원본에서 id 만 빼고 uid · 주소록을 바꾼다 — 우리가 다루지 않는 칸(별명 · 생일 · 키워드)도 그대로', () => { + const original = cardOriginal(ORIGINAL); + const { id: _id, ...rest } = ORIGINAL; + void _id; + expect( + newCardJson('book', { + kind: 'restored', + key: 'restored', + uid: 'urn:uuid:new', + original, + memberUids: null, + card: { + kind: 'contact', + card: { + id: 'lm', + uid: 'urn:uuid:orig', + name: '시험 원본', + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }, + }, + }), + ).toEqual({ ...rest, uid: 'urn:uuid:new', addressBookIds: { book: true } }); + }); + + it('그룹은 구성원을 넘긴 것으로 바꾼다 — 비면 members 를 뺀다', () => { + const original = cardOriginal({ + id: 'g', + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:g', + kind: 'group', + addressBookIds: { book: true }, + name: { full: '거래처' }, + members: { 'urn:uuid:gone': true, 'urn:uuid:a': true }, + }); + const restored = (memberUids: string[]) => + newCardJson('book', { + kind: 'restored', + key: 'restored', + uid: 'urn:uuid:g', + original, + memberUids, + card: { + kind: 'group', + card: { id: 'g', uid: 'urn:uuid:g', name: '거래처', memberUids: [] }, + }, + }); + expect(restored(['urn:uuid:a'])).toMatchObject({ + kind: 'group', + members: { 'urn:uuid:a': true }, + }); + expect(restored(['urn:uuid:a']).members).toEqual({ 'urn:uuid:a': true }); + expect(restored([])).not.toHaveProperty('members'); + }); + + it('원본의 크기는 UTF-8 바이트 — 한글은 세 바이트', () => { + expect(cardOriginal({ id: 'a', name: { full: '가' } }).bytes).toBe( + Buffer.byteLength('{"id":"a","name":{"full":"가"}}', 'utf8'), + ); + }); + + it('원본이 우리가 쓴 글이 아니면 MAIL_001 (서버 안의 잘못)', () => { + expect(() => + newCardJson('book', { + kind: 'restored', + key: 'restored', + uid: 'urn:uuid:x', + original: { source: '[1,2]', bytes: 5 }, + memberUids: null, + card: { + kind: 'group', + card: { id: 'g', uid: null, name: '', memberUids: [] }, + }, + }), + ).toThrow(BusinessException); + }); +}); + +describe('연락처 고치기 (contactPatch — 칸 안의 남의 정보를 지킨다, 계약 3절 "고치기")', () => { + /** 다른 프로그램이 만든 카드 — 2026-10-29 개발 엔진에 넣고 다시 읽은 모양(be-probe-r2.log) */ + const RICH: Record = { + id: 'lm', + uid: 'urn:uuid:rich', + kind: 'individual', + name: { + full: '시험 원본', + components: [ + { kind: 'surname', value: '시험' }, + { kind: 'given', value: '원본' }, + ], + defaultSeparator: ' ', + isOrdered: true, + }, + emails: { + x1: { + address: 'one@x.example', + contexts: { work: true, billing: true }, + pref: 1, + label: '본사', + }, + x2: { address: 'two@x.example', contexts: { private: true }, pref: 3 }, + }, + phones: { + y1: { + number: 'tel:+82-2-333-4444', + contexts: { work: true }, + features: { fax: true, voice: true }, + label: '팩스', + }, + y2: { number: '010-1111-2222', features: { mobile: true, text: true } }, + }, + organizations: { + o1: { + name: '한빛상사', + units: [{ name: '구매팀' }, { name: '1파트' }], + sortAs: 'HANBIT', + }, + o2: { name: '두번째 회사' }, + }, + titles: { + t2: { name: '과장', kind: 'title', organizationId: 'o1' }, + t1: { name: '팀장', kind: 'role', organizationId: 'o1' }, + }, + notes: { + n1: { note: '첫 메모', author: { name: '누군가' } }, + n2: { note: '둘째 메모' }, + }, + }; + const contactOf = (card: Record) => { + const read = readContactCard(card); + if (read?.kind !== 'contact') throw new Error('연락처가 아니다'); + return read.card; + }; + + it('읽은 모양 — 대표 주소 · 팩스는 기타 · role 은 직책이 아니다 · 첫 조직 · 메모는 이어서', () => { + expect(contactOf(RICH)).toEqual({ + id: 'lm', + uid: 'urn:uuid:rich', + name: '시험 원본', + emails: [ + { address: 'one@x.example', label: 'work' }, + { address: 'two@x.example', label: 'personal' }, + ], + phones: [ + { number: '+82-2-333-4444', label: 'work' }, + { number: '010-1111-2222', label: 'mobile' }, + ], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '첫 메모\n둘째 메모', + }); + }); + + it('이메일 — 같은 주소(대소문자 무시) 항목의 남의 속성을 남기고, 종류를 바꾼 것만 우리 값(다른 contexts 는 둔다) · 새 주소는 새 키 · 빠진 주소는 뺀다', () => { + const patch = contactPatch(RICH, { + emails: [ + { address: 'TWO@x.example', label: 'work' }, + { address: 'one@x.example', label: 'other' }, + { address: 'new@x.example', label: 'personal' }, + ], + }); + expect(patch).toEqual({ + emails: { + // 대표 주소가 된 둘째 — pref 1, 대소문자를 바꾼 주소는 보낸 대로, 종류만 work 로 + x2: { address: 'TWO@x.example', contexts: { work: true }, pref: 1 }, + // 첫째 — 종류를 other 로: work · private 만 빼고 billing · label 은 둔다. pref 1 은 첫 주소만이라 뺀다 + x1: { + address: 'one@x.example', + contexts: { billing: true }, + label: '본사', + }, + e1: { address: 'new@x.example', contexts: { private: true } }, + }, + }); + }); + + it('이메일을 그대로 다시 보내면 항목이 그대로다 (주소 · 종류 · 남의 속성 · pref)', () => { + expect( + contactPatch(RICH, { + emails: [ + { address: 'one@x.example', label: 'work' }, + { address: 'two@x.example', label: 'personal' }, + ], + }), + ).toEqual({ emails: RICH.emails }); + }); + + it('pref — 첫 주소는 1, 나머지는 차례를 바꾸지 않는 것만 남긴다 (pref 없는 항목 뒤의 pref 도 뺀다)', () => { + const card = { + id: 'p', + emails: { + a: { address: 'a@x.example', pref: 2 }, + b: { address: 'b@x.example', pref: 5 }, + c: { address: 'c@x.example' }, + d: { address: 'd@x.example', pref: 9 }, + e: { address: 'e@x.example', pref: 7 }, + }, + }; + const patch = contactPatch(card, { + emails: ['b', 'a', 'e', 'c', 'd'].map((key) => ({ + address: `${key}@x.example`, + label: 'other' as const, + })), + }); + expect(patch.emails).toEqual({ + b: { address: 'b@x.example', pref: 1 }, + a: { address: 'a@x.example', pref: 2 }, + e: { address: 'e@x.example', pref: 7 }, + c: { address: 'c@x.example' }, + d: { address: 'd@x.example' }, + }); + // 다시 읽으면 보낸 차례 그대로 + expect( + contactOf(patchedCard(card, patch)).emails.map((email) => email.address), + ).toEqual(['b', 'a', 'e', 'c', 'd'].map((key) => `${key}@x.example`)); + }); + + it('전화 — 같은 번호(보인 글 → 같은 숫자) 항목의 fax · text · label 을 남기고 종류만 우리 값, 보인 번호를 그대로 받으면 tel: 글을 둔다', () => { + const patch = contactPatch(RICH, { + phones: [ + // 팩스 번호 — 종류를 집으로: contexts.work → private, features.fax · voice · label 은 둔다 + { number: '+82-2-333-4444', label: 'home' }, + // 휴대전화 번호를 띄어 써 보냄 — 같은 숫자라 짝, 종류를 회사로: features.mobile 만 빼고 text 는 둔다 + { number: '010 1111 2222', label: 'work' }, + { number: '031-555-6666', label: 'mobile' }, + ], + }); + expect(patch).toEqual({ + phones: { + y1: { + number: 'tel:+82-2-333-4444', + contexts: { private: true }, + features: { fax: true, voice: true }, + label: '팩스', + }, + y2: { + number: '010 1111 2222', + features: { text: true }, + contexts: { work: true }, + }, + p1: { number: '031-555-6666', features: { mobile: true } }, + }, + }); + }); + + it('직책 — role 이 아닌 첫 직책만 고치고(다른 속성은 둔다) role 은 그대로, 지우면 그 항목만 뺀다', () => { + expect(contactPatch(RICH, { title: '차장' })).toEqual({ + titles: { + t2: { name: '차장', kind: 'title', organizationId: 'o1' }, + t1: { name: '팀장', kind: 'role', organizationId: 'o1' }, + }, + }); + expect(contactPatch(RICH, { title: null })).toEqual({ + titles: { t1: { name: '팀장', kind: 'role', organizationId: 'o1' } }, + }); + // 직책이 없던 카드 — 새 항목 (role 은 그대로) + expect( + contactPatch( + { id: 'r', titles: { t1: { name: '팀장', kind: 'role' } } }, + { title: '대리' }, + ), + ).toEqual({ + titles: { + t1: { name: '팀장', kind: 'role' }, + t2: { name: '대리', kind: 'title' }, + }, + }); + expect(contactPatch({ id: 'e' }, { title: null })).toEqual({ + titles: null, + }); + }); + + it('회사 · 부서 — 첫 조직의 이름 · 첫 units 만(sortAs · 둘째 부서 · 둘째 조직은 둔다)', () => { + expect(contactPatch(RICH, { company: '한빛상사2' })).toEqual({ + organizations: { + o1: { + name: '한빛상사2', + units: [{ name: '구매팀' }, { name: '1파트' }], + sortAs: 'HANBIT', + }, + o2: { name: '두번째 회사' }, + }, + }); + expect(contactPatch(RICH, { department: '영업팀' })).toEqual({ + organizations: { + o1: { + name: '한빛상사', + units: [{ name: '영업팀' }, { name: '1파트' }], + sortAs: 'HANBIT', + }, + o2: { name: '두번째 회사' }, + }, + }); + // 부서를 지우면 첫 units 만 빠진다 + expect(contactPatch(RICH, { department: null })).toMatchObject({ + organizations: { o1: { units: [{ name: '1파트' }] } }, + }); + }); + + it('회사 · 부서를 둘 다 비우면 — 남은 것이 없는 조직은 빼고(둘째 조직은 그대로), 남은 칸이 있으면 둔다', () => { + const plain = { + id: 'o', + organizations: { + a: { '@type': 'Organization', name: '회사', units: [{ name: '부서' }] }, + b: { name: '다른 회사' }, + }, + }; + expect(contactPatch(plain, { company: null, department: null })).toEqual({ + organizations: { b: { name: '다른 회사' } }, + }); + expect( + contactPatch( + { id: 'o', organizations: { a: { name: '회사' } } }, + { company: null }, + ), + ).toEqual({ organizations: null }); + expect( + contactPatch(RICH, { company: null, department: null }), + ).toMatchObject({ + organizations: { + o1: { units: [{ name: '1파트' }], sortAs: 'HANBIT' }, + o2: { name: '두번째 회사' }, + }, + }); + // 조직이 없던 카드 — 새 조직 + expect( + contactPatch({ id: 'n' }, { company: '새 회사', department: '새 부서' }), + ).toEqual({ + organizations: { o1: { name: '새 회사', units: [{ name: '새 부서' }] } }, + }); + }); + + it('메모 — 여러 개였으면 첫 항목의 다른 속성(author)을 남기고 하나로, null 이면 칸을 뺀다', () => { + expect(contactPatch(RICH, { note: '하나로 쓴 메모' })).toEqual({ + notes: { n1: { note: '하나로 쓴 메모', author: { name: '누군가' } } }, + }); + expect(contactPatch(RICH, { note: null })).toEqual({ notes: null }); + expect(contactPatch({ id: 'n' }, { note: '새 메모' })).toEqual({ + notes: { n1: { note: '새 메모' } }, + }); + }); + + it('이름 — { full } 로 (이름 조각과 그에 딸린 칸은 지운다), null 이면 칸을 뺀다', () => { + expect(contactPatch(RICH, { name: '시험 고침' })).toEqual({ + name: { full: '시험 고침' }, + }); + expect(contactPatch(RICH, { name: null })).toEqual({ name: null }); + }); + + it('보내지 않은 칸은 패치에 없다 · 빈 목록은 칸을 뺀다', () => { + expect(contactPatch(RICH, {})).toEqual({}); + expect(contactPatch(RICH, { emails: [], phones: [] })).toEqual({ + emails: null, + phones: null, + }); + }); + + it('왕복 — 고친 카드를 다시 읽으면 보낸 칸 그대로이고, 다른 칸 · 칸 안의 남의 정보(fax · role · 둘째 조직 · label)는 남는다', () => { + const fields: Partial = { + name: '시험 고침', + emails: [ + { address: 'two@x.example', label: 'work' }, + { address: 'one@x.example', label: 'work' }, + ], + phones: [{ number: '+82-2-333-4444', label: 'home' }], + company: '새 회사', + title: '차장', + note: '고친 메모', + }; + const next = patchedCard( + { ...RICH, addresses: { a1: { countryCode: 'KR' } } }, + contactPatch(RICH, fields), + ); + expect(contactOf(next)).toEqual({ + id: 'lm', + uid: 'urn:uuid:rich', + name: '시험 고침', + emails: fields.emails, + phones: fields.phones, + company: '새 회사', + department: '구매팀', + title: '차장', + note: '고친 메모', + }); + expect(next.addresses).toEqual({ a1: { countryCode: 'KR' } }); + expect(next.phones).toEqual({ + y1: { + number: 'tel:+82-2-333-4444', + contexts: { private: true }, + features: { fax: true, voice: true }, + label: '팩스', + }, + }); + expect(next.titles).toMatchObject({ t1: { kind: 'role', name: '팀장' } }); + expect(next.organizations).toMatchObject({ o2: { name: '두번째 회사' } }); + expect(next.emails).toMatchObject({ + x1: { label: '본사', contexts: { work: true, billing: true } }, + }); + }); +}); + +describe('그룹 고치기 (groupPatch)', () => { + it('이름은 name 통째로, 구성원은 members 통째로(엔진은 구성원 없는 그룹에 members/ 패치를 받지 않는다), 비면 null', () => { + expect(groupPatch({ kind: 'group', id: 'g', name: '협력사' })).toEqual({ + name: { full: '협력사' }, + }); + expect( + groupPatch({ kind: 'group', id: 'g', memberUids: ['urn:uuid:a'] }), + ).toEqual({ members: { 'urn:uuid:a': true } }); + expect(groupPatch({ kind: 'group', id: 'g', memberUids: [] })).toEqual({ + members: null, + }); + }); + + it('patchedCard — null 인 칸은 뺀다', () => { + expect( + patchedCard( + { id: 'g', name: { full: 'a' }, members: { x: true } }, + { + members: null, + name: { full: 'b' }, + }, + ), + ).toEqual({ id: 'g', name: { full: 'b' } }); + }); +}); diff --git a/backend/src/modules/mail/jmap/contact-card.ts b/backend/src/modules/mail/jmap/contact-card.ts new file mode 100644 index 0000000..ffb6a5f --- /dev/null +++ b/backend/src/modules/mail/jmap/contact-card.ts @@ -0,0 +1,777 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { + AddressBookCard, + AddressBookCardUpdate, + NewAddressBookCard, + RemovedCardOriginal, +} from '@/modules/contacts/contacts.provider'; +import type { + ContactEmail, + ContactEmailLabel, + ContactFields, + ContactPhone, + ContactPhoneLabel, +} from '@/modules/contacts/contacts.types'; +import type { JmapContactCard } from './jmap.types'; + +/** + * JSContact 카드(RFC 9553 — 엔진의 ContactCard) ↔ 주소록 카드 (순수 함수) — 계약 3절 "엔진에 두는 모양" · "고치기" · "되돌리기". + * 이 파일의 함수 · 엔진 모양은 `modules/mail/jmap/` 밖으로 내보내지 않는다 (jmap-contacts.provider.ts 가 쓴다). + * + * 읽기 (다른 프로그램 · CardDAV 에서 온 카드도 — 모양이 맞지 않는 값은 건너뛴다): + * - kind 가 group 이면 그룹(구성원은 `members` 의 uid), 그 밖은 연락처 + * - 이름: name.full — 없으면 이름 조각(components)을 이어 붙인 것(조각 사이는 separator 조각 → defaultSeparator → 한글끼리는 붙이고 그 밖은 + * 띄어쓰기 — 김 + 아라 → 김아라). 엔진은 이름 없는 카드에 빈 full 을 준다 → null + * - 이메일 · 전화: pref(작을수록 먼저) → 엔진이 준 차례. 이메일 종류는 contexts(work 회사 · private 개인 · 그 밖 기타), + * 전화는 features.mobile 휴대전화 → contexts work 회사 · private 집 → 기타. 번호의 `tel:` 은 뗀다 + * - 소속: organizations 의 첫째 — name 이 회사, units 첫째가 부서. 직책: titles 에서 kind 가 role 이 아닌 첫째. 메모: notes 를 줄바꿈으로 잇는다 + * - 한 줄 칸은 제어 문자를 띄어쓰기로 바꾸고 앞뒤 공백을 뺀다. 메모는 줄바꿈을 \n 으로 맞추고 그 밖의 제어 문자는 띄어쓰기로 — 고치기 화면이 + * 받은 그대로 다시 보내도 요청 검증(contacts/dto/contact.dto.ts)을 통과하게 + * + * 만들기: `@type: Card` · `version: 1.0` · `uid` · `kind` · `addressBookIds`(기본 주소록) + 우리 칸. 대표 주소는 `pref: 1`, 키는 e1 · p1 … + * (엔진이 넣은 차례를 지킨다 — 2026-10-29 확인). 되돌리기는 지운 카드의 원본에서 id 만 빼고 uid · 주소록(그룹은 구성원도)만 바꿔 그대로 만든다 — + * 엔진은 읽은 카드 전체를 그대로 다시 받는다(id 말고 빼야 할 칸 없음 · 같은 주소록 안의 같은 uid 는 invalidProperties uid — 2026-10-29 확인) + * + * 고치기(계약 3절 "고치기"): 바뀐 칸만, 지금 카드와 합쳐 그 칸을 통째로 쓴다(맨 위 칸 — 엔진은 남의 속성을 실은 칸을 그대로 받는다, 2026-10-29 확인). + * - 이메일 · 전화: 보낸 주소(대소문자 무시) · 번호(같은 글 → 같은 숫자)를 지금 항목과 짝지어 그 항목의 다른 속성(label · 다른 contexts · fax 같은 features · + * pref)을 남기고, 종류를 바꾼 것만 우리 값(contexts work · private, features.mobile)으로 고친다. 대표 주소 `pref: 1` 은 첫 주소에만 — + * 다른 pref 는 그대로 두되 1 이거나 차례를 바꾸게 되면 뺀다(읽는 차례가 보낸 차례와 같게) + * - 직책: `kind: title`(role 이 아닌) 첫 항목 하나만. 회사 · 부서: 첫 조직의 이름과 첫 units 만 (둘 다 비고 남은 것이 없으면 그 조직을 뺀다). + * 메모: 여러 개였으면 하나로. 이름: name 을 `{ full }` 로 — 이름 조각(components)과 그에 딸린 칸은 지운다 + */ + +const JSCONTACT_TYPE = 'Card'; +const JSCONTACT_VERSION = '1.0'; +const GROUP_KIND = 'group'; +const INDIVIDUAL_KIND = 'individual'; +/** 직책 칸의 종류 — role(역할)은 직책이 아니다 */ +const ROLE_TITLE_KIND = 'role'; +const TITLE_KIND = 'title'; +/** 이름 조각의 종류 — 조각 사이에 넣는 글 */ +const SEPARATOR_KIND = 'separator'; +/** 객체의 종류 표시 — 이것만 남은 조직은 빈 조직이다 */ +const TYPE_PROPERTY = '@type'; + +/** 종류를 나타내는 contexts · features 의 키 — 종류를 바꿀 때 이것만 우리 값으로 바꾸고 다른 키는 둔다 */ +const WORK_CONTEXT = 'work'; +const PRIVATE_CONTEXT = 'private'; +const MOBILE_FEATURE = 'mobile'; + +/** 새 항목의 키 앞머리 — e1 · p1 · o1 · t1 · n1 */ +const EMAIL_KEY_PREFIX = 'e'; +const PHONE_KEY_PREFIX = 'p'; +const ORGANIZATION_KEY_PREFIX = 'o'; +const TITLE_KEY_PREFIX = 't'; +const NOTE_KEY_PREFIX = 'n'; + +/** 대표 주소의 pref */ +const PRIMARY_PREF = 1; + +/** 제어 문자 (C0 · DEL · C1) */ +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]+/g; +/** 메모에서 줄바꿈 말고 다른 제어 문자 */ +// eslint-disable-next-line no-control-regex +const NOTE_CONTROL_CHARACTERS = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]+/g; +const TEL_SCHEME = /^tel:/i; +const NON_DIGITS = /\D+/g; +/** 한글 음절 — 이름 조각을 붙여 쓸지 정한다 */ +const HANGUL_SYLLABLE_START = 0xac00; +const HANGUL_SYLLABLE_END = 0xd7a3; + +type JsonObject = Record; + +function isRecord(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** 한 줄 글 — 문자열이 아니거나 비면 null */ +function singleLine(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = value.replace(CONTROL_CHARACTERS, ' ').trim(); + return text === '' ? null : text; +} + +/** 여러 줄 글 (메모) — 줄바꿈은 \n 으로 맞추고 앞뒤 공백을 뺀다 */ +function multiLine(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = value + .replace(/\r\n?/g, '\n') + .replace(NOTE_CONTROL_CHARACTERS, ' ') + .trim(); + return text === '' ? null : text; +} + +function prefOf(entry: JsonObject): number { + return typeof entry.pref === 'number' && Number.isFinite(entry.pref) + ? entry.pref + : Number.POSITIVE_INFINITY; +} + +/** 맵 칸(emails 등)의 항목 — 객체인 것만, pref(작을수록 먼저) → 엔진이 준 차례 */ +function preferredEntries(map: unknown): [string, JsonObject][] { + if (!isRecord(map)) return []; + return Object.entries(map) + .filter((entry): entry is [string, JsonObject] => isRecord(entry[1])) + .map((entry, index) => ({ entry, index })) + .sort( + (a, b) => prefOf(a.entry[1]) - prefOf(b.entry[1]) || a.index - b.index, + ) + .map(({ entry }) => entry); +} + +function flag(map: unknown, key: string): boolean { + return isRecord(map) && map[key] === true; +} + +function isHangulSyllable(char: string | undefined): boolean { + const code = char?.charCodeAt(0) ?? 0; + return code >= HANGUL_SYLLABLE_START && code <= HANGUL_SYLLABLE_END; +} + +/** 조각 사이의 기본 글 — 한글 음절끼리는 붙여 쓴다(김 + 아라 → 김아라), 그 밖은 띄어쓰기 */ +function joinerBetween(previous: string, next: string): string { + return isHangulSyllable(previous.at(-1)) && isHangulSyllable(next[0]) + ? '' + : ' '; +} + +/** 이름 조각을 이어 붙인다 — separator 조각이 있으면 그 글, 없으면 defaultSeparator, 그것도 없으면 joinerBetween */ +function joinNameComponents(name: JsonObject): string | null { + if (!Array.isArray(name.components)) return null; + const defaultSeparator = + typeof name.defaultSeparator === 'string' ? name.defaultSeparator : null; + let text = ''; + let previous: string | null = null; + let separator: string | null = null; + for (const component of name.components) { + if (!isRecord(component)) continue; + if (component.kind === SEPARATOR_KIND) { + if (typeof component.value === 'string') { + separator = (separator ?? '') + component.value; + } + continue; + } + const part = singleLine(component.value); + if (part === null) continue; + if (previous !== null) { + text += separator ?? defaultSeparator ?? joinerBetween(previous, part); + } + text += part; + previous = part; + separator = null; + } + return singleLine(text); +} + +function nameOf(name: unknown): string | null { + if (!isRecord(name)) return null; + return singleLine(name.full) ?? joinNameComponents(name); +} + +function emailLabelOf(contexts: unknown): ContactEmailLabel { + if (flag(contexts, WORK_CONTEXT)) return 'work'; + if (flag(contexts, PRIVATE_CONTEXT)) return 'personal'; + return 'other'; +} + +function phoneLabelOf(entry: JsonObject): ContactPhoneLabel { + if (flag(entry.features, MOBILE_FEATURE)) return 'mobile'; + if (flag(entry.contexts, WORK_CONTEXT)) return 'work'; + if (flag(entry.contexts, PRIVATE_CONTEXT)) return 'home'; + return 'other'; +} + +/** 읽은 주소 — 한 줄 글 */ +function emailAddressOf(entry: JsonObject): string | null { + return singleLine(entry.address); +} + +/** 읽은 번호 — 한 줄 글에서 `tel:` 을 뗀 것 */ +function phoneNumberOf(entry: JsonObject): string | null { + const raw = singleLine(entry.number); + return raw === null ? null : singleLine(raw.replace(TEL_SCHEME, '')); +} + +function emailsOf(map: unknown): ContactEmail[] { + return preferredEntries(map).flatMap(([, entry]) => { + const address = emailAddressOf(entry); + return address === null + ? [] + : [{ address, label: emailLabelOf(entry.contexts) }]; + }); +} + +function phonesOf(map: unknown): ContactPhone[] { + return preferredEntries(map).flatMap(([, entry]) => { + const number = phoneNumberOf(entry); + return number === null ? [] : [{ number, label: phoneLabelOf(entry) }]; + }); +} + +/** 첫째 조직 — 객체인 첫 값 (키와 함께) */ +function firstOrganization(map: unknown): [string, JsonObject] | undefined { + if (!isRecord(map)) return undefined; + return Object.entries(map).find((entry): entry is [string, JsonObject] => + isRecord(entry[1]), + ); +} + +function unitName(unit: unknown): string | null { + return singleLine(isRecord(unit) ? unit.name : unit); +} + +/** 소속 — 첫째 조직의 이름(회사)과 첫째 부서 */ +function organizationOf(map: unknown): { + company: string | null; + department: string | null; +} { + const first = firstOrganization(map)?.[1]; + if (!first) return { company: null, department: null }; + const unit: unknown = Array.isArray(first.units) ? first.units[0] : undefined; + return { company: singleLine(first.name), department: unitName(unit) }; +} + +/** 직책으로 읽는 항목 — role 이 아니고 이름이 있는 첫째 (키와 함께) */ +function titleEntry(map: unknown): [string, JsonObject] | undefined { + if (!isRecord(map)) return undefined; + return Object.entries(map).find( + (entry): entry is [string, JsonObject] => + isRecord(entry[1]) && + entry[1].kind !== ROLE_TITLE_KIND && + singleLine(entry[1].name) !== null, + ); +} + +function titleOf(map: unknown): string | null { + return singleLine(titleEntry(map)?.[1].name); +} + +function noteOf(map: unknown): string | null { + if (!isRecord(map)) return null; + const notes = Object.values(map) + .filter(isRecord) + .map((entry) => multiLine(entry.note)) + .filter((note): note is string => note !== null); + return notes.length > 0 ? notes.join('\n') : null; +} + +function membersOf(map: unknown): string[] { + if (!isRecord(map)) return []; + return Object.entries(map) + .filter(([uid, member]) => member === true && uid.trim() !== '') + .map(([uid]) => uid); +} + +function uidOf(value: unknown): string | null { + return typeof value === 'string' && value.trim() !== '' ? value : null; +} + +/** 엔진의 카드 하나를 읽는다 — 카드가 아니거나 id 가 없으면 null */ +export function readContactCard( + card: JmapContactCard | JsonObject | null | undefined, +): AddressBookCard | null { + if ( + typeof card !== 'object' || + card === null || + typeof card.id !== 'string' || + card.id === '' + ) { + return null; + } + const uid = uidOf(card.uid); + + if (card.kind === GROUP_KIND) { + return { + kind: 'group', + card: { + id: card.id, + uid, + name: nameOf(card.name) ?? '', + memberUids: membersOf(card.members), + }, + }; + } + return { + kind: 'contact', + card: { + id: card.id, + uid, + name: nameOf(card.name), + emails: emailsOf(card.emails), + phones: phonesOf(card.phones), + ...organizationOf(card.organizations), + title: titleOf(card.titles), + note: noteOf(card.notes), + }, + }; +} + +// ── 만들기 ─────────────────────────────────────────────── + +function emailContexts(label: ContactEmailLabel): JsonObject | undefined { + if (label === 'work') return { [WORK_CONTEXT]: true }; + if (label === 'personal') return { [PRIVATE_CONTEXT]: true }; + return undefined; +} + +function newEmailEntry(email: ContactEmail): JsonObject { + const contexts = emailContexts(email.label); + return { address: email.address, ...(contexts ? { contexts } : {}) }; +} + +function newPhoneEntry(phone: ContactPhone): JsonObject { + return { number: phone.number, ...phoneTypeKeys(phone.label) }; +} + +/** 전화 종류의 우리 값 — 휴대전화는 features.mobile, 회사 · 집은 contexts work · private */ +function phoneTypeKeys(label: ContactPhoneLabel): { + features?: JsonObject; + contexts?: JsonObject; +} { + if (label === 'mobile') return { features: { [MOBILE_FEATURE]: true } }; + if (label === 'work') return { contexts: { [WORK_CONTEXT]: true } }; + if (label === 'home') return { contexts: { [PRIVATE_CONTEXT]: true } }; + return {}; +} + +function newOrganization( + company: string | null, + department: string | null, +): JsonObject { + return { + ...(company !== null ? { name: company } : {}), + ...(department !== null ? { units: [{ name: department }] } : {}), + }; +} + +/** 새 연락처의 우리 칸 (빈 칸은 빼고) */ +function newContactProperties(fields: ContactFields): JsonObject { + const properties: JsonObject = {}; + if (fields.name !== null) properties.name = { full: fields.name }; + if (fields.emails.length > 0) { + properties.emails = Object.fromEntries( + fields.emails.map((email, index) => [ + `${EMAIL_KEY_PREFIX}${index + 1}`, + { + ...newEmailEntry(email), + // 대표 주소 — 다른 프로그램도 이것을 먼저 보인다 + ...(index === 0 ? { pref: PRIMARY_PREF } : {}), + }, + ]), + ); + } + if (fields.phones.length > 0) { + properties.phones = Object.fromEntries( + fields.phones.map((phone, index) => [ + `${PHONE_KEY_PREFIX}${index + 1}`, + newPhoneEntry(phone), + ]), + ); + } + if (fields.company !== null || fields.department !== null) { + properties.organizations = { + [`${ORGANIZATION_KEY_PREFIX}1`]: newOrganization( + fields.company, + fields.department, + ), + }; + } + if (fields.title !== null) { + properties.titles = { + [`${TITLE_KEY_PREFIX}1`]: { name: fields.title, kind: TITLE_KIND }, + }; + } + if (fields.note !== null) { + properties.notes = { [`${NOTE_KEY_PREFIX}1`]: { note: fields.note } }; + } + return properties; +} + +function membersJson(uids: readonly string[]): Record | null { + return uids.length === 0 + ? null + : Object.fromEntries(uids.map((uid) => [uid, true as const])); +} + +/** 지운 카드의 원본을 읽는다 — 우리가 쓴 글만 받는다(아니면 서버 안의 잘못) */ +function parseOriginal(original: RemovedCardOriginal): JsonObject { + let parsed: unknown; + try { + parsed = JSON.parse(original.source); + } catch { + parsed = null; + } + if (!isRecord(parsed)) { + throw new BusinessException('MAIL_001', { + context: { reason: 'restore-original-malformed' }, + }); + } + return parsed; +} + +/** 만들 카드 — 기본 주소록에 (ContactCard/set 의 create) */ +export function newCardJson( + addressBookId: string, + card: NewAddressBookCard, +): JsonObject { + const addressBookIds = { [addressBookId]: true }; + if (card.kind === 'restored') { + // 원본 그대로 — 엔진이 주는 id 만 빼고 uid · 주소록(그룹은 구성원도)을 바꾼다 + const restored: JsonObject = { ...parseOriginal(card.original) }; + delete restored.id; + restored.uid = card.uid; + restored.addressBookIds = addressBookIds; + if (card.memberUids !== null) { + const members = membersJson(card.memberUids); + if (members === null) delete restored.members; + else restored.members = members; + } + return restored; + } + const base = { + '@type': JSCONTACT_TYPE, + version: JSCONTACT_VERSION, + uid: card.uid, + addressBookIds, + }; + if (card.kind === 'group') { + const members = membersJson(card.memberUids); + return { + ...base, + kind: GROUP_KIND, + name: { full: card.name }, + ...(members !== null ? { members } : {}), + }; + } + return { + ...base, + kind: INDIVIDUAL_KIND, + ...newContactProperties(card.fields), + }; +} + +/** 지운 카드의 원본 — 되돌리기가 그대로 다시 만든다 (엔진 카드 전체) */ +export function cardOriginal(card: JsonObject): RemovedCardOriginal { + const source = JSON.stringify(card); + return { source, bytes: Buffer.byteLength(source, 'utf8') }; +} + +// ── 고치기 ─────────────────────────────────────────────── + +/** 이미 쓴 키와 겹치지 않는 새 키 — 앞머리 + 1, 2, … */ +function freshKeys(prefix: string, used: Set): () => string { + let next = 1; + return () => { + while (used.has(`${prefix}${next}`)) next += 1; + const key = `${prefix}${next}`; + used.add(key); + return key; + }; +} + +/** 키를 뺀 객체 — 비면 undefined */ +function without( + map: unknown, + keys: readonly string[], +): JsonObject | undefined { + if (!isRecord(map)) return undefined; + const rest = Object.fromEntries( + Object.entries(map).filter(([key]) => !keys.includes(key)), + ); + return Object.keys(rest).length > 0 ? rest : undefined; +} + +/** 객체에 값을 넣거나(값이 있으면) 뺀다(undefined) */ +function assign(target: JsonObject, key: string, value: unknown): void { + if (value === undefined) delete target[key]; + else target[key] = value; +} + +/** 다른 contexts 는 두고 종류(work · private)만 우리 값으로 */ +function relabeledEmail(entry: JsonObject, label: ContactEmailLabel): void { + const ours = emailContexts(label); + const rest = without(entry.contexts, [WORK_CONTEXT, PRIVATE_CONTEXT]); + const contexts = rest || ours ? { ...rest, ...ours } : undefined; + assign(entry, 'contexts', contexts); +} + +/** 다른 features · contexts 는 두고 종류(features.mobile · contexts work · private)만 우리 값으로 */ +function relabeledPhone(entry: JsonObject, label: ContactPhoneLabel): void { + const ours = phoneTypeKeys(label); + const features = without(entry.features, [MOBILE_FEATURE]); + const contexts = without(entry.contexts, [WORK_CONTEXT, PRIVATE_CONTEXT]); + assign( + entry, + 'features', + features || ours.features ? { ...features, ...ours.features } : undefined, + ); + assign( + entry, + 'contexts', + contexts || ours.contexts ? { ...contexts, ...ours.contexts } : undefined, + ); +} + +/** + * pref 를 보낸 차례에 맞춘다 — first 가 있으면 첫 항목은 그 값(대표 주소 1)이고 다른 항목의 그 값은 뺀다. + * 나머지 pref 는 앞 항목보다 작지 않을 때만 남긴다(pref 가 없는 항목 뒤의 pref 도 뺀다) — 읽는 차례(pref → 맵 차례)가 보낸 차례와 같게 + */ +function orderPrefs( + entries: readonly JsonObject[], + first: number | null, +): void { + /** 앞 항목의 pref — 아직 없으면 null, 앞에 pref 없는 항목이 있으면 +∞ */ + let last: number | null = null; + entries.forEach((entry, index) => { + if (index === 0 && first !== null) { + entry.pref = first; + last = first; + return; + } + const pref = prefOf(entry); + const keep = + Number.isFinite(pref) && + pref !== first && + (last === null || pref >= last); + if (keep) { + last = pref; + } else { + delete entry.pref; + last = Number.POSITIVE_INFINITY; + } + }); +} + +/** 보낸 것마다 짝이 되는 지금 항목을 찾는다 — 한 항목은 한 번만. matchers 차례로(앞의 것이 더 엄격하다) */ +function pairEntries( + sent: readonly T[], + current: [string, JsonObject][], + matchers: readonly ((item: T, entry: JsonObject) => boolean)[], +): ([string, JsonObject] | undefined)[] { + const taken = new Set(); + const pairs: ([string, JsonObject] | undefined)[] = sent.map(() => undefined); + for (const matches of matchers) { + sent.forEach((item, index) => { + if (pairs[index]) return; + const found = current.find( + ([key, entry]) => !taken.has(key) && matches(item, entry), + ); + if (found) { + taken.add(found[0]); + pairs[index] = found; + } + }); + } + return pairs; +} + +function emailKey(address: string): string { + return address.trim().toLowerCase(); +} + +function mergedEmails( + current: unknown, + emails: readonly ContactEmail[], +): JsonObject | null { + if (emails.length === 0) return null; + const entries = preferredEntries(current); + const pairs = pairEntries(emails, entries, [ + (email, entry) => emailAddressOf(entry) === email.address, + (email, entry) => { + const address = emailAddressOf(entry); + return address !== null && emailKey(address) === emailKey(email.address); + }, + ]); + const keyOf = freshKeys( + EMAIL_KEY_PREFIX, + new Set(pairs.flatMap((pair) => (pair ? [pair[0]] : []))), + ); + const merged = emails.map((email, index): [string, JsonObject] => { + const pair = pairs[index]; + if (!pair) return [keyOf(), newEmailEntry(email)]; + const entry = { ...pair[1] }; + if (emailAddressOf(entry) !== email.address) entry.address = email.address; + if (emailLabelOf(entry.contexts) !== email.label) { + relabeledEmail(entry, email.label); + } + return [pair[0], entry]; + }); + orderPrefs( + merged.map(([, entry]) => entry), + PRIMARY_PREF, + ); + return Object.fromEntries(merged); +} + +function digitsOf(text: string): string { + return text.replace(NON_DIGITS, ''); +} + +function mergedPhones( + current: unknown, + phones: readonly ContactPhone[], +): JsonObject | null { + if (phones.length === 0) return null; + const entries = preferredEntries(current); + const pairs = pairEntries(phones, entries, [ + (phone, entry) => phoneNumberOf(entry) === phone.number, + (phone, entry) => { + const number = phoneNumberOf(entry); + const digits = digitsOf(phone.number); + return number !== null && digits !== '' && digitsOf(number) === digits; + }, + ]); + const keyOf = freshKeys( + PHONE_KEY_PREFIX, + new Set(pairs.flatMap((pair) => (pair ? [pair[0]] : []))), + ); + const merged = phones.map((phone, index): [string, JsonObject] => { + const pair = pairs[index]; + if (!pair) return [keyOf(), newPhoneEntry(phone)]; + const entry = { ...pair[1] }; + // 보인 번호를 그대로 돌려받았으면 엔진의 글(tel: 등)을 그대로 둔다 + if (phoneNumberOf(entry) !== phone.number) entry.number = phone.number; + if (phoneLabelOf(entry) !== phone.label) relabeledPhone(entry, phone.label); + return [pair[0], entry]; + }); + orderPrefs( + merged.map(([, entry]) => entry), + null, + ); + return Object.fromEntries(merged); +} + +/** 회사 · 부서 — 첫 조직의 이름과 첫 units 만. 둘 다 비고 남은 것이 없으면 그 조직을 뺀다 */ +function mergedOrganizations( + current: unknown, + change: { + readonly company?: string | null; + readonly department?: string | null; + }, +): JsonObject | null { + const map: JsonObject = isRecord(current) ? { ...current } : {}; + const first = firstOrganization(map); + if (first) { + const [key, organization] = first; + const next = { ...organization }; + if (change.company !== undefined) + assign(next, 'name', change.company ?? undefined); + if (change.department !== undefined) { + const units: unknown[] = Array.isArray(next.units) + ? [...(next.units as unknown[])] + : []; + if (change.department === null) { + units.shift(); + } else { + const unit = units[0]; + units[0] = isRecord(unit) + ? { ...unit, name: change.department } + : { name: change.department }; + } + assign(next, 'units', units.length > 0 ? units : undefined); + } + const remains = Object.keys(next).some((name) => name !== TYPE_PROPERTY); + if (remains) map[key] = next; + else delete map[key]; + } else { + const company = change.company ?? null; + const department = change.department ?? null; + if (company !== null || department !== null) { + const keyOf = freshKeys( + ORGANIZATION_KEY_PREFIX, + new Set(Object.keys(map)), + ); + map[keyOf()] = newOrganization(company, department); + } + } + return Object.keys(map).length > 0 ? map : null; +} + +/** 직책 — 직책으로 읽는 항목 하나만 (role 등 다른 항목은 그대로) */ +function mergedTitles( + current: unknown, + title: string | null, +): JsonObject | null { + const map: JsonObject = isRecord(current) ? { ...current } : {}; + const entry = titleEntry(map); + if (entry) { + const [key, value] = entry; + if (title === null) delete map[key]; + else map[key] = { ...value, name: title }; + } else if (title !== null) { + const keyOf = freshKeys(TITLE_KEY_PREFIX, new Set(Object.keys(map))); + map[keyOf()] = { name: title, kind: TITLE_KIND }; + } + return Object.keys(map).length > 0 ? map : null; +} + +/** 메모 — 하나로 (여러 개였으면 첫 항목의 다른 속성을 남기고 글만 바꾼다) */ +function mergedNotes(current: unknown, note: string | null): JsonObject | null { + if (note === null) return null; + const first = isRecord(current) + ? Object.entries(current).find((entry): entry is [string, JsonObject] => + isRecord(entry[1]), + ) + : undefined; + return first + ? { [first[0]]: { ...first[1], note } } + : { [`${NOTE_KEY_PREFIX}1`]: { note } }; +} + +/** + * 연락처 고치기 — 바뀐 칸(fields 에 있는 것)만, 지금 카드(current — 그 칸들을 읽은 것)와 합쳐 그 칸을 통째로 쓰는 패치. + * 값이 null 인 칸은 카드에서 뺀다 + */ +export function contactPatch( + current: JsonObject, + fields: Partial, +): JsonObject { + const patch: JsonObject = {}; + if (fields.name !== undefined) { + patch.name = fields.name === null ? null : { full: fields.name }; + } + if (fields.emails !== undefined) { + patch.emails = mergedEmails(current.emails, fields.emails); + } + if (fields.phones !== undefined) { + patch.phones = mergedPhones(current.phones, fields.phones); + } + if (fields.company !== undefined || fields.department !== undefined) { + patch.organizations = mergedOrganizations(current.organizations, { + ...(fields.company !== undefined ? { company: fields.company } : {}), + ...(fields.department !== undefined + ? { department: fields.department } + : {}), + }); + } + if (fields.title !== undefined) { + patch.titles = mergedTitles(current.titles, fields.title); + } + if (fields.note !== undefined) { + patch.notes = mergedNotes(current.notes, fields.note); + } + return patch; +} + +/** 그룹 고치기 — 이름 · 구성원을 통째로 */ +export function groupPatch( + update: Extract, +): JsonObject { + return { + ...(update.name !== undefined ? { name: { full: update.name } } : {}), + ...(update.memberUids !== undefined + ? { members: membersJson(update.memberUids) } + : {}), + }; +} + +/** 맨 위 칸 패치를 카드에 옮긴 모양 (null 은 칸을 뺀다) — 고친 뒤의 카드를 읽는 데 쓴다 */ +export function patchedCard(card: JsonObject, patch: JsonObject): JsonObject { + const next = { ...card }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete next[key]; + else next[key] = value; + } + return next; +} diff --git a/backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts new file mode 100644 index 0000000..bc8dd9f --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts @@ -0,0 +1,826 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { AddressBookChanges } from '@/modules/contacts/contacts.provider'; +import type { JmapClient } from './jmap.client'; +import { + ADDRESS_BOOK_READ_MAX_CARDS, + JmapContactsProvider, +} from './jmap-contacts.provider'; +import type { JmapContactCard, JmapInvocation } from './jmap.types'; + +const ARA = 'ara.kim@arasystem.test'; +const CONTACTS = 'urn:ietf:params:jmap:contacts'; + +interface FakeBook { + id: string; + isDefault?: boolean; +} + +/** + * 대역 엔진 — 주소록(AddressBook) · 카드(ContactCard) · 상태(ifInState)만 흉내 낸다 + * (JMAP for Contacts — Stalwart 0.16.27 에서 확인한 모양: 지난 ifInState 는 메서드 오류 stateMismatch) + */ +class FakeContactsEngine { + state = 10; + books: FakeBook[] = [{ id: 'book', isDefault: true }]; + /** 카드 id → 담긴 주소록 · 카드 칸 */ + readonly cards = new Map< + string, + { book: string; card: Record } + >(); + readonly requests: { calls: JmapInvocation[]; options: unknown }[] = []; + /** 다음 ContactCard/set 의 응답을 대신한다 */ + setReply: ((args: Record) => JmapInvocation) | null = null; + private nextId = 1; + + readonly client = { + call: jest.fn( + ( + _user: string, + build: (accountId: string) => JmapInvocation[], + options?: unknown, + ) => { + const calls = build('acc'); + this.requests.push({ calls, options }); + const responses: JmapInvocation[] = []; + for (const call of calls) responses.push(this.handle(call, responses)); + return Promise.resolve(responses); + }, + ), + }; + + add(card: JmapContactCard, book = 'book'): void { + this.cards.set(card.id, { book, card: { ...card } }); + } + + cardOf(id: string): Record | undefined { + return this.cards.get(id)?.card; + } + + private handle( + [method, args, callId]: JmapInvocation, + previous: JmapInvocation[], + ): JmapInvocation { + switch (method) { + case 'AddressBook/get': + return [ + method, + { state: 'books', list: this.books.map((book) => ({ ...book })) }, + callId, + ]; + case 'ContactCard/query': { + const filter = args.filter as { inAddressBook?: string }; + const ids = [...this.cards.entries()] + .filter(([, stored]) => stored.book === filter.inAddressBook) + .map(([id]) => id); + const position = Number(args.position ?? 0); + const limit = Number(args.limit ?? ids.length); + return [ + method, + { + ids: ids.slice(position, position + limit), + position, + ...(args.calculateTotal === true ? { total: ids.length } : {}), + }, + callId, + ]; + } + case 'ContactCard/get': { + const reference = args['#ids'] as { resultOf: string } | undefined; + const ids = reference + ? ((previous.find(([, , id]) => id === reference.resultOf)?.[1] + .ids as string[]) ?? []) + : (args.ids as string[]); + return [ + method, + { + state: `s${this.state}`, + list: ids.flatMap((id) => { + const card = this.cardOf(id); + return card ? [{ ...card }] : []; + }), + notFound: ids.filter((id) => !this.cardOf(id)), + }, + callId, + ]; + } + case 'ContactCard/set': + return this.set(args, callId); + default: + return ['error', { type: 'unknownMethod' }, callId]; + } + } + + private set(args: Record, callId: string): JmapInvocation { + if (this.setReply) { + const reply = this.setReply(args); + this.setReply = null; + return reply; + } + if (args.ifInState !== `s${this.state}`) { + return ['error', { type: 'stateMismatch' }, callId]; + } + const oldState = `s${this.state}`; + const created: Record = {}; + for (const [key, card] of Object.entries( + (args.create as Record>) ?? {}, + )) { + const id = `n${this.nextId++}`; + const books = Object.keys(card.addressBookIds as Record); + this.add({ ...card, id }, books[0]); + created[key] = { id }; + } + const updated: Record = {}; + for (const [id, patch] of Object.entries( + (args.update as Record>) ?? {}, + )) { + const card = this.cardOf(id); + if (card) { + Object.assign(card, patch); + updated[id] = null; + } + } + for (const id of (args.destroy as string[]) ?? []) this.cards.delete(id); + this.state += 1; + return [ + 'ContactCard/set', + { oldState, newState: `s${this.state}`, created, updated }, + callId, + ]; + } +} + +const version = (book: string | null, state: string) => + JSON.stringify([book, state]); + +const noChanges: AddressBookChanges = { create: [], update: [], destroy: [] }; + +async function failureOf( + promise: Promise, +): Promise { + const error = await promise.then( + () => null, + (caught: unknown) => caught, + ); + if (!(error instanceof BusinessException)) { + throw new Error(`BusinessException 이 아니다: ${String(error)}`); + } + return error; +} + +describe('JmapContactsProvider (엔진 주소록)', () => { + let engine: FakeContactsEngine; + let provider: JmapContactsProvider; + let warnSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + engine = new FakeContactsEngine(); + provider = new JmapContactsProvider(engine.client as unknown as JmapClient); + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('판 (getAddressBookVersion)', () => { + it('기본 주소록 id 와 카드 상태 — 한 요청, 카드는 읽지 않는다(ids: []), contacts 기능을 선언한다', async () => { + engine.books = [{ id: 'shared' }, { id: 'mine', isDefault: true }]; + + await expect(provider.getAddressBookVersion(ARA)).resolves.toBe( + version('mine', 's10'), + ); + const [request] = engine.requests; + expect(request?.calls.map(([method]) => method)).toEqual([ + 'AddressBook/get', + 'ContactCard/get', + ]); + expect(request?.calls[1]?.[1]).toMatchObject({ ids: [] }); + expect(request?.options).toEqual({ capabilities: [CONTACTS] }); + }); + + it('기본 주소록이 없으면 첫 주소록, 주소록이 없으면 null — 카드가 바뀌면 판이 달라진다', async () => { + engine.books = [{ id: 'first' }, { id: 'second' }]; + await expect(provider.getAddressBookVersion(ARA)).resolves.toBe( + version('first', 's10'), + ); + engine.books = []; + engine.state = 11; + await expect(provider.getAddressBookVersion(ARA)).resolves.toBe( + version(null, 's11'), + ); + }); + + it('엔진이 상태를 알려 주지 않으면 MAIL_001', async () => { + engine.client.call.mockResolvedValueOnce([ + ['AddressBook/get', { list: [] }, 'b'], + ['ContactCard/get', { list: [] }, 's'], + ]); + expect((await failureOf(provider.getAddressBookVersion(ARA))).code).toBe( + 'MAIL_001', + ); + }); + }); + + describe('읽기 (readAddressBook)', () => { + it('기본 주소록의 카드만 500장씩 — 연락처와 그룹으로 나누고, 판은 읽기 전에 물은 것이다', async () => { + for (let index = 0; index < 1_200; index += 1) { + engine.add({ + id: `c${index}`, + uid: `urn:uuid:c${index}`, + kind: 'individual', + name: { full: `연락처 ${index}` }, + }); + } + engine.add({ + id: 'g', + uid: 'urn:uuid:g', + kind: 'group', + name: { full: '거래처' }, + members: { 'urn:uuid:c1': true }, + }); + engine.add({ id: 'elsewhere', name: { full: '다른 주소록' } }, 'other'); + + const snapshot = await provider.readAddressBook(ARA); + + expect(snapshot.version).toBe(version('book', 's10')); + expect(snapshot.contacts).toHaveLength(1_200); + expect(snapshot.groups).toEqual([ + { + id: 'g', + uid: 'urn:uuid:g', + name: '거래처', + memberUids: ['urn:uuid:c1'], + }, + ]); + const queries = engine.requests + .flatMap((request) => request.calls) + .filter(([method]) => method === 'ContactCard/query'); + expect(queries.map(([, args]) => [args.position, args.limit])).toEqual([ + [0, 500], + [500, 500], + [1_000, 500], + ]); + expect(queries[0]?.[1]).toMatchObject({ + filter: { inAddressBook: 'book' }, + calculateTotal: true, + }); + // 사진 등 큰 칸은 읽지 않는다 + const get = engine.requests[1]?.calls[1]?.[1]; + expect(get?.properties).not.toContain('media'); + }); + + it('읽는 사이 차례가 밀려 같은 카드가 두 번 와도 한 번만 넣는다', async () => { + for (let index = 0; index < 600; index += 1) { + engine.add({ id: `c${index}`, name: { full: `연락처 ${index}` } }); + } + // 첫 차례(판을 읽은 다음 요청)를 읽은 뒤 맨 앞에 카드가 하나 생긴다 — 둘째 차례의 첫 카드는 이미 읽은 것이다 + const original = engine.client.call.getMockImplementation(); + let requests = 0; + engine.client.call.mockImplementation((user, build, options) => { + const result = original?.(user, build, options); + requests += 1; + if (requests === 2) { + const rest = [...engine.cards.entries()]; + engine.cards.clear(); + engine.add({ id: 'new', name: { full: '새 카드' } }); + for (const [id, stored] of rest) engine.cards.set(id, stored); + } + return result ?? Promise.resolve([]); + }); + + const snapshot = await provider.readAddressBook(ARA); + + const ids = snapshot.contacts.map((card) => card.id); + expect(ids).toHaveLength(600); + expect(new Set(ids).size).toBe(600); + expect(ids).not.toContain('new'); + }); + + it('판은 읽기 전에 물은 것 — 읽는 사이 다른 곳에서 카드가 생겨 상태가 올라도 옛 판으로 둔다(다음 요청이 판이 달라 다시 읽는다)', async () => { + for (let index = 0; index < 600; index += 1) { + engine.add({ id: `c${index}`, name: { full: `연락처 ${index}` } }); + } + const original = engine.client.call.getMockImplementation(); + let requests = 0; + engine.client.call.mockImplementation((user, build, options) => { + requests += 1; + // 판을 물은 뒤 · 첫 차례를 읽기 전에 다른 곳에서 카드를 만든다 + if (requests === 2) { + engine.add({ id: 'late', name: { full: '늦게 온 카드' } }); + engine.state += 1; + } + return original?.(user, build, options) ?? Promise.resolve([]); + }); + + const snapshot = await provider.readAddressBook(ARA); + + expect(snapshot.version).toBe(version('book', 's10')); + await expect(provider.getAddressBookVersion(ARA)).resolves.not.toBe( + snapshot.version, + ); + }); + + it('주소록이 없으면 빈 주소록 (카드를 묻지 않는다)', async () => { + engine.books = []; + await expect(provider.readAddressBook(ARA)).resolves.toEqual({ + version: version(null, 's10'), + contacts: [], + groups: [], + }); + expect(engine.requests).toHaveLength(1); + }); + + it(`${ADDRESS_BOOK_READ_MAX_CARDS}장을 넘으면 앞의 그만큼만 읽고 warn 로그(개수만)`, async () => { + for (let index = 0; index < ADDRESS_BOOK_READ_MAX_CARDS + 3; index += 1) { + engine.add({ id: `c${index}`, name: { full: `비밀 이름 ${index}` } }); + } + + const snapshot = await provider.readAddressBook(ARA); + + expect(snapshot.contacts).toHaveLength(ADDRESS_BOOK_READ_MAX_CARDS); + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain(`카드 ${ADDRESS_BOOK_READ_MAX_CARDS + 3}장`); + expect(logged).not.toContain('비밀 이름'); + expect(logged).not.toContain(ARA); + }); + }); + + describe('쓰기 (writeAddressBook)', () => { + const newPerson = { + name: '새 사람', + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }; + + it('고칠 연락처 · 지울 카드를 그 판에서 먼저 읽고(한 요청) ContactCard/set 하나에 만들기 · 고치기 · 지우기 — 새 판 · 만든 id · 카드 모양 · 지운 원본을 준다', async () => { + engine.add({ + id: 'c1', + uid: 'urn:uuid:c1', + kind: 'individual', + name: { full: '하나' }, + anniversaries: { a: { kind: 'birth' } }, + } as JmapContactCard); + engine.add({ + id: 'c2', + uid: 'urn:uuid:c2', + name: { full: '둘' }, + photos: { p: { href: 'https://x.example/p.jpg' } }, + } as JmapContactCard); + engine.add({ + id: 'g', + kind: 'group', + name: { full: '거래처' }, + members: { 'urn:uuid:c1': true }, + }); + + const outcome = await provider.writeAddressBook( + ARA, + version('book', 's10'), + { + create: [ + { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields: newPerson, + }, + ], + update: [ + { kind: 'contact', id: 'c1', fields: { name: '고친 이름' } }, + { + kind: 'group', + id: 'g', + memberUids: ['urn:uuid:c1', 'urn:uuid:new'], + }, + ], + destroy: ['c2'], + }, + ); + + // 먼저 읽기 — 고칠 연락처는 우리 칸만, 지울 카드는 전체(properties: null) + const [read, write] = engine.requests; + expect(read?.calls.map(([method, args]) => [method, args.ids])).toEqual([ + ['ContactCard/get', ['c1']], + ['ContactCard/get', ['c2']], + ]); + expect(read?.calls[0]?.[1].properties).toEqual( + expect.arrayContaining(['name', 'emails', 'organizations']), + ); + expect(read?.calls[1]?.[1].properties).toBeNull(); + + const [call] = write?.calls ?? []; + expect(call?.[0]).toBe('ContactCard/set'); + expect(call?.[1]).toMatchObject({ + ifInState: 's10', + create: { + contact: { + '@type': 'Card', + uid: 'urn:uuid:new', + kind: 'individual', + addressBookIds: { book: true }, + name: { full: '새 사람' }, + }, + }, + update: { + c1: { name: { full: '고친 이름' } }, + g: { members: { 'urn:uuid:c1': true, 'urn:uuid:new': true } }, + }, + destroy: ['c2'], + }); + // 고친 칸만 보낸다 — 다른 칸(생일)은 엔진에 그대로 + expect(engine.cardOf('c1')).toMatchObject({ + name: { full: '고친 이름' }, + anniversaries: { a: { kind: 'birth' } }, + }); + expect(write?.options).toEqual({ capabilities: [CONTACTS] }); + + if (outcome.kind !== 'written') throw new Error('written 이 아니다'); + expect(outcome.version).toBe(version('book', 's11')); + expect(outcome.createdIds).toEqual(new Map([['contact', 'n1']])); + // 만들거나 고친 연락처의 모양 (그룹 고치기는 넣지 않는다) + expect([...outcome.cards.keys()].sort()).toEqual(['c1', 'n1']); + expect(outcome.cards.get('c1')).toMatchObject({ + kind: 'contact', + card: { id: 'c1', name: '고친 이름', uid: 'urn:uuid:c1' }, + }); + expect(outcome.cards.get('n1')).toMatchObject({ + kind: 'contact', + card: { id: 'n1', uid: 'urn:uuid:new', name: '새 사람' }, + }); + // 지운 카드의 원본 — 카드 전체(사진 칸 포함) + const original = outcome.removed.get('c2'); + expect(JSON.parse(original?.source ?? 'null')).toEqual({ + id: 'c2', + uid: 'urn:uuid:c2', + name: { full: '둘' }, + photos: { p: { href: 'https://x.example/p.jpg' } }, + }); + expect(original?.bytes).toBe( + Buffer.byteLength(original?.source ?? '', 'utf8'), + ); + }); + + it('연락처 고치기는 지금 카드와 합친다 — 같은 주소의 label · 다른 contexts 를 남긴다 (contact-card.ts)', async () => { + engine.add({ + id: 'c1', + uid: 'urn:uuid:c1', + emails: { + k: { + address: 'a@x.example', + contexts: { work: true, billing: true }, + label: '본사', + pref: 1, + }, + }, + }); + + await provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + update: [ + { + kind: 'contact', + id: 'c1', + fields: { + emails: [ + { address: 'a@x.example', label: 'personal' }, + { address: 'b@x.example', label: 'work' }, + ], + }, + }, + ], + }); + + expect(engine.cardOf('c1')?.emails).toEqual({ + k: { + address: 'a@x.example', + contexts: { billing: true, private: true }, + label: '본사', + pref: 1, + }, + e1: { address: 'b@x.example', contexts: { work: true } }, + }); + }); + + it('먼저 읽은 상태가 판과 다르거나 카드가 없으면 conflict — set 을 보내지 않는다', async () => { + engine.add({ id: 'c1', name: { full: '하나' } }); + await expect( + provider.writeAddressBook(ARA, version('book', 's9'), { + ...noChanges, + update: [{ kind: 'contact', id: 'c1', fields: { name: '둘' } }], + }), + ).resolves.toEqual({ kind: 'conflict' }); + await expect( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + destroy: ['gone'], + }), + ).resolves.toEqual({ kind: 'conflict' }); + expect( + engine.requests + .flatMap((request) => request.calls) + .filter(([method]) => method === 'ContactCard/set'), + ).toHaveLength(0); + expect(engine.cardOf('c1')).toMatchObject({ name: { full: '하나' } }); + }); + + it('만들기 · 그룹 고치기만이면 먼저 읽지 않는다 · 빈 칸(만들기 · 고치기 · 지우기)은 보내지 않는다', async () => { + engine.add({ id: 'g', kind: 'group', name: { full: '거래처' } }); + await provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + update: [{ kind: 'group', id: 'g', name: '협력사' }], + }); + expect(engine.requests).toHaveLength(1); + expect(Object.keys(engine.requests[0]?.calls[0]?.[1] ?? {})).toEqual([ + 'accountId', + 'ifInState', + 'update', + ]); + }); + + it('그사이 바뀌었으면(stateMismatch) conflict — 아무것도 하지 않았다', async () => { + engine.add({ id: 'g', kind: 'group', name: { full: '거래처' } }); + await expect( + provider.writeAddressBook(ARA, version('book', 's9'), { + ...noChanges, + update: [{ kind: 'group', id: 'g', name: '협력사' }], + }), + ).resolves.toEqual({ kind: 'conflict' }); + expect(engine.cardOf('g')).toMatchObject({ name: { full: '거래처' } }); + }); + + it('엔진이 동시 고치기를 거절하면(serverUnavailable — 메서드 오류 · 카드마다) busy — 지운 카드의 원본을 함께, warn 로그에 엔진 설명은 없다', async () => { + engine.add({ id: 'c1', uid: 'urn:uuid:c1', name: { full: '하나' } }); + engine.setReply = () => [ + 'error', + { + type: 'serverUnavailable', + description: 'store.assert-value-failed 하나', + }, + 'w', + ]; + const busy = await provider.writeAddressBook( + ARA, + version('book', 's10'), + { ...noChanges, destroy: ['c1'] }, + ); + expect(busy.kind).toBe('busy'); + expect(busy.kind === 'busy' && [...busy.removed.keys()]).toEqual(['c1']); + + engine.setReply = () => [ + 'ContactCard/set', + { + newState: 's11', + created: { contact: { id: 'n9' } }, + notUpdated: { g: { type: 'serverUnavailable' } }, + }, + 'w', + ]; + await expect( + provider.writeAddressBook(ARA, version('book', 's10'), { + create: [ + { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields: newPerson, + }, + ], + update: [{ kind: 'group', id: 'g', memberUids: ['urn:uuid:new'] }], + destroy: [], + }), + ).resolves.toMatchObject({ kind: 'busy' }); + + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain('serverUnavailable'); + expect(logged).not.toMatch(/assert-value|하나|ara\.kim@/); + }); + + it('엔진이 새 상태를 알려 주지 않으면 version 은 null', async () => { + engine.add({ id: 'c1', name: { full: '하나' } }); + engine.setReply = (args) => [ + 'ContactCard/set', + { created: {}, destroyed: args.destroy }, + 'w', + ]; + await expect( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + destroy: ['c1'], + }), + ).resolves.toMatchObject({ + kind: 'written', + version: null, + createdIds: new Map(), + }); + }); + + it('엔진이 일부를 받지 않으면 그 종류의 코드 — warn 로그는 개수 · 종류만(카드 내용 · 엔진 설명 없음)', async () => { + engine.setReply = () => [ + 'ContactCard/set', + { + newState: 's11', + notCreated: { + contact: { + type: 'invalidProperties', + description: 'Invalid email 박지훈 ', + properties: ['emails/e1/address'], + }, + }, + }, + 'w', + ]; + + const error = await failureOf( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + create: [ + { + kind: 'contact', + key: 'contact', + uid: 'urn:uuid:new', + fields: { + ...newPerson, + name: '박지훈', + emails: [{ address: 'jihoon@x', label: 'work' }], + }, + }, + ], + }), + ); + + expect(error.code).toBe('VAL_001'); + expect(error.context).toMatchObject({ + reason: 'address-book-change-refused', + jmapType: 'invalidProperties', + }); + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain('만들기 1/1'); + expect(logged).not.toMatch(/박지훈|jihoon|Invalid email|ara\.kim@/); + }); + + it('되돌리기 — 원본에서 id 만 빼고 uid · 기본 주소록을 바꿔 만든다. 엔진이 받지 않으면 MAIL_001(입력 오류가 아니다)', async () => { + const original = { + source: JSON.stringify({ + id: 'old', + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:old', + kind: 'individual', + addressBookIds: { gone: true }, + name: { full: '돌아온 사람' }, + nicknames: { k: { name: '별명' } }, + }), + bytes: 1, + }; + const restored = { + kind: 'restored' as const, + key: 'restored', + uid: 'urn:uuid:old', + original, + memberUids: null, + card: { + kind: 'contact' as const, + card: { + id: 'old', + uid: 'urn:uuid:old', + ...newPerson, + name: '돌아온 사람', + }, + }, + }; + const outcome = await provider.writeAddressBook( + ARA, + version('book', 's10'), + { ...noChanges, create: [restored] }, + ); + expect(engine.cardOf('n1')).toEqual({ + id: 'n1', + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:old', + kind: 'individual', + addressBookIds: { book: true }, + name: { full: '돌아온 사람' }, + nicknames: { k: { name: '별명' } }, + }); + expect(outcome).toMatchObject({ kind: 'written' }); + expect( + outcome.kind === 'written' && outcome.cards.get('n1')?.card.name, + ).toBe('돌아온 사람'); + + engine.setReply = () => [ + 'ContactCard/set', + { + newState: 's12', + notCreated: { + restored: { type: 'invalidProperties', properties: ['media'] }, + }, + }, + 'w', + ]; + const error = await failureOf( + provider.writeAddressBook(ARA, version('book', 's11'), { + ...noChanges, + create: [restored], + }), + ); + expect(error.code).toBe('MAIL_001'); + expect(error.context).toMatchObject({ + reason: 'restore-refused', + jmapType: 'invalidProperties', + properties: ['media'], + }); + }); + + it('메서드 오류 · 만든 id 가 없는 응답은 코드로 — serverFail 은 MAIL_001, 권한 없음은 AUTH_003', async () => { + engine.add({ id: 'c1', name: { full: '하나' } }); + engine.setReply = () => ['error', { type: 'serverFail' }, 'w']; + expect( + ( + await failureOf( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + destroy: ['c1'], + }), + ) + ).code, + ).toBe('MAIL_001'); + + engine.setReply = () => ['error', { type: 'forbidden' }, 'w']; + expect( + ( + await failureOf( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + destroy: ['c1'], + }), + ) + ).code, + ).toBe('AUTH_003'); + + engine.setReply = () => [ + 'ContactCard/set', + { newState: 's11', created: {} }, + 'w', + ]; + const missing = await failureOf( + provider.writeAddressBook(ARA, version('book', 's10'), { + ...noChanges, + create: [ + { + kind: 'group', + key: 'group', + uid: 'urn:uuid:g', + name: '거래처', + memberUids: [], + }, + ], + }), + ); + expect(missing.context?.reason).toBe('created-card-id-missing'); + }); + + it('주소록이 없는데 만들 카드가 있으면 MAIL_001 (엔진에 묻지 않고 error 로그) · 우리가 만들지 않은 판은 MAIL_001', async () => { + const error = await failureOf( + provider.writeAddressBook(ARA, version(null, 's10'), { + ...noChanges, + create: [ + { + kind: 'group', + key: 'group', + uid: 'urn:uuid:g', + name: '거래처', + memberUids: [], + }, + ], + }), + ); + expect(error.context?.reason).toBe('address-book-missing'); + expect(errorSpy).toHaveBeenCalledTimes(1); + + for (const bad of ['not-json', '["book"]', '[1, "s"]', '["book", ""]']) { + expect( + (await failureOf(provider.writeAddressBook(ARA, bad, noChanges))) + .context?.reason, + ).toBe('address-book-version-malformed'); + } + expect(engine.requests).toHaveLength(0); + }); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-contacts.provider.ts b/backend/src/modules/mail/jmap/jmap-contacts.provider.ts new file mode 100644 index 0000000..782d507 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-contacts.provider.ts @@ -0,0 +1,449 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { + AddressBookCard, + AddressBookChanges, + AddressBookContactCard, + AddressBookGroupCard, + AddressBookSnapshot, + AddressBookWriteOutcome, + ContactsProvider, + RemovedCardOriginal, +} from '@/modules/contacts/contacts.provider'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { + cardOriginal, + contactPatch, + groupPatch, + newCardJson, + patchedCard, + readContactCard, +} from './contact-card'; +import { JmapClient } from './jmap.client'; +import { jmapFailure, mapJmapErrorType } from './jmap.errors'; +import { + expectResult, + findResult, + firstSetError, + isState, +} from './jmap-results'; +import type { + JmapAddressBook, + JmapContactCard, + JmapGetResult, + JmapInvocation, + JmapQueryResult, + JmapSetResult, +} from './jmap.types'; + +/** JMAP for Contacts — 이 호출에만 선언한다 (계정 id 는 메일 계정과 같다 — 2026-10-29 확인) */ +const CONTACTS_CAPABILITY = 'urn:ietf:params:jmap:contacts'; + +/** 한 번에 읽는 카드 수 — 엔진의 maxObjectsInGet(500) 이하 */ +const READ_PAGE_SIZE = 500; +/** + * 주소록을 읽는 상한 — 우리 한도(연락처 3,000 · 그룹 100)보다 넉넉히. 다른 프로그램이 더 넣었으면 앞의 이만큼만 읽는다(warn 로그). + * 한 사람의 주소록이 서버 메모리 · 엔진 호출을 끝없이 쓰지 않게 + */ +export const ADDRESS_BOOK_READ_MAX_CARDS = 5_000; + +/** 카드에서 읽는 칸 — 사진 등 큰 칸은 읽지 않는다 */ +const CARD_PROPERTIES = [ + 'uid', + 'kind', + 'name', + 'emails', + 'phones', + 'organizations', + 'titles', + 'notes', + 'members', +]; + +/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3 — ContactCard/set 도 같다, 2026-10-29 확인) */ +const STATE_MISMATCH = 'stateMismatch'; +/** + * 엔진이 같은 카드를 동시에 고치는 요청을 거절한 것(store.assert-value-failed — 2026-10-29 확인). 메서드 오류로도 · 카드마다의 SetError 로도 + * 받는다고 보고, 어느 쪽이든 일부가 들어갔을 수 있다 — busy 로 알려 서비스가 다시 읽고 이어 하게 한다 + */ +const SERVER_UNAVAILABLE = 'serverUnavailable'; + +/** 판 — 기본 주소록 id 와 카드 상태 (서비스에는 불투명 문자열) */ +interface BookVersion { + readonly addressBookId: string | null; + readonly state: string; +} + +function encodeVersion(version: BookVersion): string { + return JSON.stringify([version.addressBookId, version.state]); +} + +/** 우리가 만든 판만 받는다 — 아니면 서버 안의 잘못이다 */ +function decodeVersion(text: string): BookVersion { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = null; + } + if ( + Array.isArray(parsed) && + parsed.length === 2 && + (parsed[0] === null || typeof parsed[0] === 'string') && + isState(parsed[1]) + ) { + return { addressBookId: parsed[0] as string | null, state: parsed[1] }; + } + throw new BusinessException('MAIL_001', { + context: { reason: 'address-book-version-malformed' }, + }); +} + +/** 기본 주소록 — isDefault, 없으면 엔진이 준 첫 주소록 (계약 3절 "엔진에 주소록이 여럿이면 기본 주소록만") */ +function pickAddressBook(books: readonly JmapAddressBook[]): string | null { + const preferred = books.find((book) => book.isDefault === true) ?? books[0]; + return typeof preferred?.id === 'string' && preferred.id !== '' + ? preferred.id + : null; +} + +/** + * 메일 엔진(Stalwart)의 주소록 — JMAP for Contacts (계약 3절 "주소록"). 직원마다 기본 주소록 하나를 게이트웨이 대리 접근으로 읽고 쓴다. + * + * - 판: AddressBook/get(기본 주소록 id) + ContactCard/get(ids: [] — 상태만, 가볍다). 주소록 · 카드 무엇이든 바뀌면 판이 달라진다 + * - 읽기: 판을 먼저 묻고 ContactCard/query(그 주소록) + get 을 500장씩 — 판은 읽기 전에 물은 것이다(읽는 사이 바뀌면 다음 요청이 판이 달라 다시 읽는다) + * - 쓰기: 고칠 연락처 · 지울 카드를 그 판에서 먼저 읽고(칸 안의 남의 정보 · 되돌리기 원본 — contact-card.ts), ContactCard/set 하나에 + * 만들기 · 고치기 · 지우기를 담아 읽은 상태 그대로일 때만(ifInState) 한다. 어긋나면 stateMismatch → conflict, 동시 고치기 거절은 busy. + * 엔진은 만든 뒤 uid 를 바꾸지 못하게 하고(invalidProperties), 구성원이 없는 그룹에 `members/` 패치를 받지 않는다 — 그래서 uid 는 만들 때 + * 넣고, 구성원은 통째로 바꾼다 (2026-10-29 확인) + * - 이름 · 주소 · 메모 등 카드 내용은 로그에 남기지 않는다 (개수 · 엔진 오류 종류만) + */ +@Injectable() +export class JmapContactsProvider implements ContactsProvider { + private readonly logger = new Logger(JmapContactsProvider.name); + + constructor(private readonly jmap: JmapClient) {} + + async getAddressBookVersion(user: string): Promise { + return encodeVersion(await this.readVersion(user)); + } + + async readAddressBook(user: string): Promise { + const head = await this.readVersion(user); + const { addressBookId } = head; + if (addressBookId === null) { + return { version: encodeVersion(head), contacts: [], groups: [] }; + } + + const contacts: AddressBookContactCard[] = []; + const groups: AddressBookGroupCard[] = []; + // 읽는 사이 카드가 생기거나 지워지면 차례가 밀려 같은 카드가 두 번 올 수 있다 — 한 번만 (판이 달라져 다음 요청이 다시 읽는다) + const seen = new Set(); + let position = 0; + let total: number | null = null; + while (position < ADDRESS_BOOK_READ_MAX_CARDS) { + const limit = Math.min( + READ_PAGE_SIZE, + ADDRESS_BOOK_READ_MAX_CARDS - position, + ); + const responses = await this.call(user, (accountId) => [ + [ + 'ContactCard/query', + { + accountId, + filter: { inAddressBook: addressBookId }, + position, + limit, + calculateTotal: true, + }, + 'q', + ], + [ + 'ContactCard/get', + { + accountId, + '#ids': { + resultOf: 'q', + name: 'ContactCard/query', + path: '/ids', + }, + properties: CARD_PROPERTIES, + }, + 'g', + ], + ]); + const query = expectResult( + responses, + 'q', + 'ContactCard/query', + ); + const page = expectResult>( + responses, + 'g', + 'ContactCard/get', + ); + for (const card of page.list ?? []) { + const read = readContactCard(card); + if (read === null || seen.has(read.card.id)) continue; + seen.add(read.card.id); + if (read.kind === 'contact') contacts.push(read.card); + else groups.push(read.card); + } + + const ids = query.ids ?? []; + position += ids.length; + total = typeof query.total === 'number' ? query.total : null; + if (ids.length === 0 || (total !== null && position >= total)) break; + } + + if (total !== null && total > position) { + this.logger.warn( + `[MAIL_001]${traceTag()} 주소록이 커서 앞의 ${position}장만 읽었습니다: user=${maskEmail(user)}, 카드 ${total}장`, + ); + } + // 판은 읽기 전에 물은 것 — 읽는 사이 다른 곳에서 만든 카드를 빠뜨린 채 "최신"으로 기억하지 않게 (그 카드가 판을 바꿨으니 다음 요청이 다시 읽는다) + return { version: encodeVersion(head), contacts, groups }; + } + + async writeAddressBook( + user: string, + version: string, + changes: AddressBookChanges, + ): Promise { + const { addressBookId, state } = decodeVersion(version); + if (changes.create.length > 0 && addressBookId === null) { + this.logger.error( + `[MAIL_001]${traceTag()} 메일 엔진에 이 직원의 주소록이 없어 연락처를 만들 수 없습니다 — 엔진의 주소록 설정을 확인하세요: user=${maskEmail(user)}`, + ); + throw new BusinessException('MAIL_001', { + context: { reason: 'address-book-missing', user: maskEmail(user) }, + }); + } + + // 고칠 연락처 · 지울 카드를 그 판 그대로 먼저 읽는다 — 칸 안의 남의 정보를 지키고(고치기) 되돌리기 원본을 남긴다(지우기) + const current = await this.readCurrentCards( + user, + state, + changes.update.flatMap((change) => + change.kind === 'contact' ? [change.id] : [], + ), + changes.destroy, + ); + if (current === null) return { kind: 'conflict' }; + const removed = current.originals; + + const create = new Map( + changes.create.map((card) => [ + card.key, + newCardJson(addressBookId ?? '', card), + ]), + ); + const update: Record> = {}; + const cards = new Map(); + for (const change of changes.update) { + if (change.kind === 'group') { + update[change.id] = groupPatch(change); + continue; + } + const card = current.cards.get(change.id) ?? { id: change.id }; + const patch = contactPatch(card, change.fields); + update[change.id] = patch; + const view = readContactCard(patchedCard(card, patch)); + if (view) cards.set(change.id, view); + } + + const responses = await this.call(user, (accountId) => [ + [ + 'ContactCard/set', + { + accountId, + ifInState: state, + ...(create.size > 0 ? { create: Object.fromEntries(create) } : {}), + ...(changes.update.length > 0 ? { update } : {}), + ...(changes.destroy.length > 0 ? { destroy: changes.destroy } : {}), + }, + 'w', + ], + ]); + + const result = findResult(responses, 'w', 'ContactCard/set'); + if (!result.ok) { + if (result.error.type === STATE_MISMATCH) return { kind: 'conflict' }; + if (result.error.type === SERVER_UNAVAILABLE) { + this.warnBusy(user, 'method'); + return { kind: 'busy', removed }; + } + throw jmapFailure(result.error, { method: 'ContactCard/set' }); + } + const set = result.args as JmapSetResult; + const refusals = [ + ...Object.values(set.notCreated ?? {}), + ...Object.values(set.notUpdated ?? {}), + ...Object.values(set.notDestroyed ?? {}), + ]; + if (refusals.some((refusal) => refusal?.type === SERVER_UNAVAILABLE)) { + this.warnBusy(user, 'set-error'); + return { kind: 'busy', removed }; + } + const refusal = + firstSetError(set.notCreated) ?? + firstSetError(set.notUpdated) ?? + firstSetError(set.notDestroyed); + if (refusal) { + // 받은 것은 이미 바뀌었다 — 서비스가 기억한 주소록을 버리고 다시 읽는다 + this.logger.warn( + `[${mapJmapErrorType(refusal.type)}]${traceTag()} 엔진이 주소록 바꾸기의 일부를 받지 않았습니다: user=${maskEmail(user)}, jmapType=${refusal.type ?? 'unknown'}, ` + + `거절 — 만들기 ${Object.keys(set.notCreated ?? {}).length}/${changes.create.length} · 고치기 ${Object.keys(set.notUpdated ?? {}).length}/${changes.update.length} · 지우기 ${Object.keys(set.notDestroyed ?? {}).length}/${changes.destroy.length}`, + ); + // 되돌리기를 엔진이 받지 않은 것은 사용자가 고칠 수 있는 입력 오류가 아니다 — 엔진 장애로 답한다 + const restoring = changes.create.some((card) => card.kind === 'restored'); + if (restoring && firstSetError(set.notCreated)) { + throw new BusinessException('MAIL_001', { + context: { + reason: 'restore-refused', + jmapType: refusal.type ?? 'unknown', + ...(refusal.properties?.length + ? { properties: refusal.properties } + : {}), + }, + }); + } + throw jmapFailure(refusal, { + method: 'ContactCard/set', + reason: 'address-book-change-refused', + }); + } + + const createdIds = new Map(); + for (const card of changes.create) { + const id = set.created?.[card.key]?.id; + if (typeof id !== 'string' || id === '') { + throw new BusinessException('MAIL_001', { + context: { reason: 'created-card-id-missing' }, + }); + } + createdIds.set(card.key, id); + // 만든 카드 — 엔진에 보낸 카드를 읽은 것 (엔진은 보낸 칸을 그대로 둔다 — 2026-10-29 확인) + const view = readContactCard({ ...create.get(card.key), id }); + if (view) cards.set(id, view); + } + return { + kind: 'written', + version: isState(set.newState) + ? encodeVersion({ addressBookId, state: set.newState }) + : null, + createdIds, + cards, + removed, + }; + } + + /** + * 고칠 연락처(우리가 다루는 칸)와 지울 카드(카드 전체 — 되돌리기 원본)를 그 판(state)에서 읽는다 — 엔진 요청 하나. + * 판이 달라졌거나 카드가 없으면 null (그사이 다른 곳에서 바뀌었다 — 서비스가 다시 읽고 다시 정한다) + */ + private async readCurrentCards( + user: string, + state: string, + updateIds: readonly string[], + destroyIds: readonly string[], + ): Promise<{ + cards: ReadonlyMap>; + originals: ReadonlyMap; + } | null> { + if (updateIds.length === 0 && destroyIds.length === 0) { + return { cards: new Map(), originals: new Map() }; + } + const calls = (accountId: string): JmapInvocation[] => [ + ...(updateIds.length > 0 + ? [ + [ + 'ContactCard/get', + { accountId, ids: [...updateIds], properties: CARD_PROPERTIES }, + 'u', + ] as JmapInvocation, + ] + : []), + ...(destroyIds.length > 0 + ? [ + [ + 'ContactCard/get', + // 되돌리기 원본 — 다른 프로그램의 칸까지 모두 + { accountId, ids: [...destroyIds], properties: null }, + 'd', + ] as JmapInvocation, + ] + : []), + ]; + const responses = await this.call(user, calls); + const read = (callId: string, ids: readonly string[]) => { + if (ids.length === 0) return new Map>(); + const got = expectResult>( + responses, + callId, + 'ContactCard/get', + ); + if (got.state !== state) return null; + const byId = new Map>( + (got.list ?? []).map((card) => [card.id, { ...card }]), + ); + return ids.every((id) => byId.has(id)) ? byId : null; + }; + const updating = read('u', updateIds); + const destroying = read('d', destroyIds); + if (updating === null || destroying === null) return null; + return { + cards: updating, + originals: new Map( + [...destroying].map(([id, card]) => [id, cardOriginal(card)]), + ), + }; + } + + /** 엔진이 동시 고치기를 거절했다 — 서비스가 다시 읽고 다시 한다 (엔진 오류의 설명 문장은 남기지 않는다) */ + private warnBusy(user: string, where: 'method' | 'set-error'): void { + this.logger.warn( + `[MAIL_001]${traceTag()} 엔진이 주소록 바꾸기를 잠시 받지 않았습니다(serverUnavailable — 같은 카드를 동시에 고침) — 다시 읽고 다시 합니다: user=${maskEmail(user)}, where=${where}`, + ); + } + + /** 기본 주소록 id 와 카드 상태 — 한 요청 */ + private async readVersion(user: string): Promise { + const responses = await this.call(user, (accountId) => [ + [ + 'AddressBook/get', + { accountId, ids: null, properties: ['isDefault'] }, + 'b', + ], + ['ContactCard/get', { accountId, ids: [] }, 's'], + ]); + const books = expectResult>( + responses, + 'b', + 'AddressBook/get', + ); + const cards = expectResult>( + responses, + 's', + 'ContactCard/get', + ); + if (!isState(cards.state)) { + throw new BusinessException('MAIL_001', { + context: { reason: 'contact-state-missing' }, + }); + } + return { + addressBookId: pickAddressBook(books.list ?? []), + state: cards.state, + }; + } + + private call( + user: string, + build: (accountId: string) => JmapInvocation[], + ): Promise { + return this.jmap.call(user, build, { capabilities: [CONTACTS_CAPABILITY] }); + } +} diff --git a/backend/src/modules/mail/jmap/jmap-directory.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-directory.provider.spec.ts new file mode 100644 index 0000000..fce999d --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-directory.provider.spec.ts @@ -0,0 +1,201 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { JmapClient } from './jmap.client'; +import { + DIRECTORY_READ_MAX_ACCOUNTS, + JmapDirectoryProvider, + toDirectoryAccount, +} from './jmap-directory.provider'; +import type { JmapInvocation, JmapPrincipal } from './jmap.types'; + +/** 대역 엔진 — 계정 목록(Principal/query · get)만 흉내 낸다 (2026-10-29 개발 엔진이 준 칸: id · type · description · email) */ +class FakePrincipalEngine { + principals: JmapPrincipal[] = []; + readonly requests: JmapInvocation[][] = []; + /** 이 메서드는 오류로 답한다 */ + refuse: { method: string; type: string } | null = null; + + readonly client = { + callDirectory: jest.fn((build: (accountId: string) => JmapInvocation[]) => { + const calls = build('dir'); + this.requests.push(calls); + const responses: JmapInvocation[] = []; + for (const [method, args, callId] of calls) { + if (this.refuse?.method === method) { + responses.push(['error', { type: this.refuse.type }, callId]); + continue; + } + if (method === 'Principal/query') { + const position = Number(args.position ?? 0); + const limit = Number(args.limit ?? this.principals.length); + responses.push([ + method, + { + ids: this.principals + .slice(position, position + limit) + .map((principal) => principal.id), + position, + ...(args.calculateTotal === true + ? { total: this.principals.length } + : {}), + }, + callId, + ]); + } else if (method === 'Principal/get') { + const query = responses.find(([name]) => name === 'Principal/query'); + const ids = (query?.[1].ids as string[] | undefined) ?? []; + responses.push([ + method, + { + list: ids.map((id) => + this.principals.find((principal) => principal.id === id), + ), + }, + callId, + ]); + } + } + return Promise.resolve(responses); + }), + }; +} + +describe('JmapDirectoryProvider (엔진의 계정 목록)', () => { + let engine: FakePrincipalEngine; + let provider: JmapDirectoryProvider; + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + engine = new FakePrincipalEngine(); + provider = new JmapDirectoryProvider( + engine.client as unknown as JmapClient, + ); + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('사내 목록 계정으로 Principal/query(500개씩 · total) + get 을 한 요청에 — 개인은 person, 그룹은 shared', async () => { + engine.principals = [ + { + id: 'a', + type: 'individual', + name: 'admin@arasystem.test', + description: 'System administrator', + email: 'admin@arasystem.test', + }, + { + id: 's', + type: 'group', + description: '영업팀', + email: 'sales@arasystem.test', + }, + { + id: 'r', + type: 'resource', + description: '회의실', + email: 'room@arasystem.test', + }, + ]; + + await expect(provider.listAccounts()).resolves.toEqual([ + { + id: 'a', + kind: 'person', + displayName: 'System administrator', + email: 'admin@arasystem.test', + }, + { + id: 's', + kind: 'shared', + displayName: '영업팀', + email: 'sales@arasystem.test', + }, + ]); + const [calls] = engine.requests; + expect(calls?.map(([method]) => method)).toEqual([ + 'Principal/query', + 'Principal/get', + ]); + expect(calls?.[0]?.[1]).toEqual({ + accountId: 'dir', + position: 0, + limit: 500, + calculateTotal: true, + }); + expect(calls?.[1]?.[1]).toMatchObject({ + '#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' }, + properties: ['type', 'description', 'email'], + }); + }); + + it('500개가 넘으면 이어 읽는다', async () => { + engine.principals = Array.from({ length: 1_100 }, (_, index) => ({ + id: `p${index}`, + type: 'individual', + description: `직원 ${index}`, + email: `p${index}@arasystem.test`, + })); + + await expect(provider.listAccounts()).resolves.toHaveLength(1_100); + expect(engine.requests.map((calls) => calls[0]?.[1].position)).toEqual([ + 0, 500, 1_000, + ]); + }); + + it(`${DIRECTORY_READ_MAX_ACCOUNTS}개를 넘으면 앞의 그만큼만 — warn 로그(개수만)`, async () => { + engine.principals = Array.from( + { length: DIRECTORY_READ_MAX_ACCOUNTS + 1 }, + (_, index) => ({ + id: `p${index}`, + type: 'individual', + description: `비밀 이름 ${index}`, + email: `p${index}@arasystem.test`, + }), + ); + + await expect(provider.listAccounts()).resolves.toHaveLength( + DIRECTORY_READ_MAX_ACCOUNTS, + ); + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain(`계정 ${DIRECTORY_READ_MAX_ACCOUNTS + 1}개`); + expect(logged).not.toContain('비밀 이름'); + }); + + it('읽기 권한이 없으면(forbidden) 사내 목록을 읽지 못한 것 — MAIL_001 (AUTH_003 이 아니다)', async () => { + engine.refuse = { method: 'Principal/query', type: 'forbidden' }; + + const error = await provider + .listAccounts() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(BusinessException); + expect(error).toMatchObject({ + code: 'MAIL_001', + context: { + reason: 'directory-read-refused', + method: 'Principal/query', + jmapType: 'forbidden', + }, + }); + }); + + it('계정 하나 — 모양이 틀린 값은 null 로, 사내 목록 종류가 아니면 넣지 않는다', () => { + expect( + toDirectoryAccount({ + id: 'x', + type: 'individual', + description: 7, + email: null, + }), + ).toEqual({ id: 'x', kind: 'person', displayName: null, email: null }); + expect(toDirectoryAccount({ id: 'x', type: 'location' })).toBeNull(); + expect(toDirectoryAccount({ id: '', type: 'individual' })).toBeNull(); + expect(toDirectoryAccount({ id: 'x' })).toBeNull(); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-directory.provider.ts b/backend/src/modules/mail/jmap/jmap-directory.provider.ts new file mode 100644 index 0000000..90de784 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-directory.provider.ts @@ -0,0 +1,140 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { + DirectoryAccount, + DirectoryProvider, +} from '@/modules/contacts/contacts.provider'; +import type { DirectoryEntryKind } from '@/modules/contacts/contacts.types'; +import { traceTag } from '@/shared/logger/request-context'; +import { JmapClient } from './jmap.client'; +import { findResult } from './jmap-results'; +import type { + JmapGetResult, + JmapInvocation, + JmapPrincipal, + JmapQueryResult, +} from './jmap.types'; + +/** 한 번에 읽는 계정 수 — 엔진의 maxObjectsInGet(500) 이하 */ +const READ_PAGE_SIZE = 500; +/** 계정 목록을 읽는 상한 — 회사 하나의 계정 수로 넉넉하다. 넘으면 앞의 이만큼만 (warn 로그) */ +export const DIRECTORY_READ_MAX_ACCOUNTS = 10_000; + +/** 읽는 칸 — 엔진이 주는 칸은 id · type · name(로그인 이름) · description(표시 이름) · email 뿐이다 (2026-10-29 확인) */ +const PRINCIPAL_PROPERTIES = ['type', 'description', 'email']; + +/** 엔진의 계정 종류 → 사내 목록의 종류 — 그 밖(resource · location 등)은 넣지 않는다 */ +const KINDS: ReadonlyMap = new Map([ + ['individual', 'person'], + ['group', 'shared'], +]); + +/** 엔진의 계정 하나 — 모양이 맞지 않거나 사내 목록 종류가 아니면 null */ +export function toDirectoryAccount( + principal: JmapPrincipal | null | undefined, +): DirectoryAccount | null { + if ( + typeof principal !== 'object' || + principal === null || + typeof principal.id !== 'string' || + principal.id === '' + ) { + return null; + } + const kind = + typeof principal.type === 'string' ? KINDS.get(principal.type) : undefined; + if (kind === undefined) return null; + return { + id: principal.id, + kind, + displayName: + typeof principal.description === 'string' ? principal.description : null, + email: typeof principal.email === 'string' ? principal.email : null, + }; +} + +/** 메서드 응답을 꺼낸다 — 메서드 오류(읽기 권한 없음 forbidden 등)는 사내 목록을 읽지 못한 것(MAIL_001)이다 */ +function expectDirectoryResult( + responses: JmapInvocation[], + callId: string, + method: string, +): T { + const result = findResult(responses, callId, method); + if (!result.ok) { + throw new BusinessException('MAIL_001', { + context: { + reason: 'directory-read-refused', + method, + jmapType: result.error.type ?? 'unknown', + }, + }); + } + return result.args as T; +} + +/** + * 메일 엔진(Stalwart)의 계정 목록 — 사내 목록 (계약 3절 "사내 목록"). setup 이 만든 사내 목록 계정으로만 읽는다(JmapClient.callDirectory). + * Principal/query(500개씩 · total) + Principal/get 을 한 요청에 — 읽기만 한다. 걸러 내기(관리자 · 게이트웨이 · 막은 계정)는 서비스가 한다. + * 계정의 이름 · 주소는 로그에 남기지 않는다 (개수 · 엔진 오류 종류만) + */ +@Injectable() +export class JmapDirectoryProvider implements DirectoryProvider { + private readonly logger = new Logger(JmapDirectoryProvider.name); + + constructor(private readonly jmap: JmapClient) {} + + async listAccounts(): Promise { + const accounts: DirectoryAccount[] = []; + let position = 0; + let total: number | null = null; + while (position < DIRECTORY_READ_MAX_ACCOUNTS) { + const limit = Math.min( + READ_PAGE_SIZE, + DIRECTORY_READ_MAX_ACCOUNTS - position, + ); + const responses = await this.jmap.callDirectory((accountId) => [ + [ + 'Principal/query', + { accountId, position, limit, calculateTotal: true }, + 'q', + ], + [ + 'Principal/get', + { + accountId, + '#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' }, + properties: PRINCIPAL_PROPERTIES, + }, + 'g', + ], + ]); + const query = expectDirectoryResult( + responses, + 'q', + 'Principal/query', + ); + const page = expectDirectoryResult>( + responses, + 'g', + 'Principal/get', + ); + for (const principal of page.list ?? []) { + const account = toDirectoryAccount(principal); + if (account) accounts.push(account); + } + + const ids = query.ids ?? []; + position += ids.length; + total = typeof query.total === 'number' ? query.total : null; + if (ids.length === 0 || (total !== null && position >= total)) break; + } + + if (total !== null && total > position) { + this.logger.warn( + `[MAIL_001]${traceTag()} 엔진의 계정이 많아 사내 목록에 앞의 ${position}개만 읽었습니다: 계정 ${total}개`, + ); + } + return accounts; + } +} diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 0fa6fa9..ea9c184 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -54,7 +54,11 @@ const settings: MailSettings = { gatewayPassword: 'gateway-secret', attachmentMaxBytes: 20 * MB, attachmentTotalMaxBytes: 25 * MB, - blockedAccounts: new Set(['admin@arasystem.test']), + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', }; /** 검증된 Stalwart 기본 메일함 (영어 이름 · sortOrder 0) */ diff --git a/backend/src/modules/mail/jmap/jmap.client.spec.ts b/backend/src/modules/mail/jmap/jmap.client.spec.ts index a0037fb..2c79db4 100644 --- a/backend/src/modules/mail/jmap/jmap.client.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.client.spec.ts @@ -12,6 +12,7 @@ import { expandTemplate, toRelativeReference, } from './jmap.client'; +import type { JmapInvocation } from './jmap.types'; const settings: MailSettings = { jmapBaseUrl: 'http://mail:8080', @@ -20,7 +21,11 @@ const settings: MailSettings = { gatewayPassword: 'gateway-secret', attachmentMaxBytes: 20 * 1024 * 1024, attachmentTotalMaxBytes: 25 * 1024 * 1024, - blockedAccounts: new Set(['admin@arasystem.test']), + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', }; const ARA = 'ara.kim@arasystem.test'; @@ -879,6 +884,168 @@ describe('JmapClient', () => { }); }); + describe('사내 목록 계정 호출 (callDirectory)', () => { + const DIRECTORY = 'mail-directory@arasystem.test'; + /** 사내 목록 계정의 세션 — 메일 계정이 없고 계정 목록(principals) 계정만 있다 (2026-10-29 개발 엔진) */ + const directorySession = { + username: DIRECTORY, + apiUrl: 'http://localhost:8180/jmap/', + primaryAccounts: { + 'urn:ietf:params:jmap:principals': 'x', + 'urn:stalwart:jmap': 'x', + }, + accounts: { x: {} }, + }; + const principalCalls = (accountId: string): JmapInvocation[] => [ + ['Principal/query', { accountId }, 'q'], + ]; + + it('<사내 목록 계정>%<게이트웨이> 로 세션을 열고, 계정 목록 계정 id 로 core · principals 만 선언해 부른다 — 막는 계정이어도 이 호출만은 된다', async () => { + const responses = [['Principal/query', { ids: ['a'] }, 'q']]; + fetchMock + .mockResolvedValueOnce(json(directorySession)) + .mockResolvedValueOnce(json({ methodResponses: responses })); + + await expect(client.callDirectory(principalCalls)).resolves.toEqual( + responses, + ); + + const session = requestAt(0); + expect(session.url).toBe('http://mail:8080/jmap/session'); + expect(decodeBasic(session.authorization)).toBe( + `${DIRECTORY}%mail-gateway@arasystem.test:gateway-secret`, + ); + const call = requestAt(1); + expect(call.url).toBe('http://mail:8080/jmap/'); + expect(decodeBasic(call.authorization)).toBe( + `${DIRECTORY}%mail-gateway@arasystem.test:gateway-secret`, + ); + expect(JSON.parse(call.init.body as string)).toEqual({ + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:principals'], + methodCalls: [['Principal/query', { accountId: 'x' }, 'q']], + }); + + // 직원 메일함으로는 대리 접근하지 않는다 (막는 계정) + expect(await codeOf(client.getSession(DIRECTORY))).toBe('AUTH_001'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('세션은 직원 세션과 따로 캐시한다 — 두 번째 호출은 세션을 다시 묻지 않는다', async () => { + fetchMock + .mockResolvedValueOnce(json(directorySession)) + .mockImplementation(() => + Promise.resolve(json({ methodResponses: [] })), + ); + + await client.callDirectory(principalCalls); + await client.callDirectory(principalCalls); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(cache.set).toHaveBeenCalledWith( + 'mail:jmap-directory-session', + expect.objectContaining({ account: DIRECTORY, accountId: 'x' }), + expect.any(Number), + ); + }); + + it('계정 목록 계정이 세션에 없으면 첫 계정, 그것도 없으면 MAIL_001', async () => { + fetchMock + .mockResolvedValueOnce( + json({ + ...directorySession, + primaryAccounts: {}, + accounts: { y: {} }, + }), + ) + .mockResolvedValueOnce(json({ methodResponses: [] })); + await client.callDirectory(principalCalls); + expect( + ( + JSON.parse(requestAt(1).init.body as string) as { + methodCalls: JmapInvocation[]; + } + ).methodCalls[0]?.[1], + ).toEqual({ accountId: 'y' }); + + await cache.del('mail:jmap-directory-session'); + fetchMock.mockResolvedValueOnce( + json({ ...directorySession, primaryAccounts: {}, accounts: {} }), + ); + const error = await client + .callDirectory(principalCalls) + .catch((caught: unknown) => caught); + expect(error).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'directory-session-incomplete' }, + }); + }); + + it('계정이 없음(401) · 로그인 권한 없음(403) · 게이트웨이 거절은 모두 MAIL_001 — AUTH_001 이 아니다(직원 세션과 상관없다), 캐시한 세션을 지운다', async () => { + const reasonOf = async () => { + const error = await client + .callDirectory(principalCalls) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(BusinessException); + expect((error as BusinessException).code).toBe('MAIL_001'); + return (error as BusinessException).context?.reason; + }; + + // 계정 없음 — 게이트웨이로 직접 들어가 보면 된다 + fetchMock + .mockResolvedValueOnce(json({}, 401)) + .mockResolvedValueOnce(json({}, 200)); + expect(await reasonOf()).toBe('directory-account-missing'); + + // 게이트웨이도 거부된다 — 메일 설정 전체의 문제 + fetchMock + .mockResolvedValueOnce(json({}, 401)) + .mockResolvedValueOnce(json({}, 401)); + expect(await reasonOf()).toBe('gateway-rejected'); + + fetchMock.mockResolvedValueOnce(json({}, 403)); + expect(await reasonOf()).toBe('directory-account-forbidden'); + + // 세션을 연 뒤 호출에서 거부돼도 같다 + fetchMock + .mockResolvedValueOnce(json(directorySession)) + .mockResolvedValueOnce(json({}, 403)); + expect(await reasonOf()).toBe('directory-account-forbidden'); + expect(cache.del).toHaveBeenCalledWith('mail:jmap-directory-session'); + }); + + it('엔진 5xx 는 MAIL_001, 429 는 REQ_001, 형식이 깨진 응답은 MAIL_001', async () => { + fetchMock + .mockResolvedValueOnce(json(directorySession)) + .mockResolvedValueOnce(json({}, 502)); + expect(await codeOf(client.callDirectory(principalCalls))).toBe( + 'MAIL_001', + ); + fetchMock.mockResolvedValueOnce(json({}, 429)); + expect(await codeOf(client.callDirectory(principalCalls))).toBe( + 'REQ_001', + ); + fetchMock.mockResolvedValueOnce(json({ unexpected: true })); + expect(await codeOf(client.callDirectory(principalCalls))).toBe( + 'MAIL_001', + ); + }); + + it('사내 목록 계정을 모르면(도메인 없음) 엔진에 가지 않고 MAIL_001', async () => { + const unconfigured = new JmapClient( + { ...settings, directoryAccount: null }, + cache, + ); + const error = await unconfigured + .callDirectory(principalCalls) + .catch((caught: unknown) => caught); + expect(error).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'directory-not-configured' }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + describe('변화 소식 스트림 (openEventSource)', () => { const OPTIONS = { types: ['Mailbox', 'Email'], pingSeconds: 30 } as const; diff --git a/backend/src/modules/mail/jmap/jmap.client.ts b/backend/src/modules/mail/jmap/jmap.client.ts index a87965a..39424e9 100644 --- a/backend/src/modules/mail/jmap/jmap.client.ts +++ b/backend/src/modules/mail/jmap/jmap.client.ts @@ -163,6 +163,11 @@ const EVENT_STREAM_TYPE = /^text\/event-stream\s*(?:;|$)/i; /** 직원별 JMAP 세션 캐시 — 계정 삭제가 늦게 반영되는 상한이기도 하다 */ const SESSION_CACHE_TTL_MS = 5 * 60_000; +/** JMAP Principals — 사내 목록 계정으로 엔진의 계정 목록을 읽는다 (계약 3절 "사내 목록") */ +const PRINCIPALS_CAPABILITY = 'urn:ietf:params:jmap:principals'; +/** 사내 목록 계정의 세션 캐시 키 — 직원 세션(sessionCacheKey)과 따로 둔다 */ +const DIRECTORY_SESSION_CACHE_KEY = 'mail:jmap-directory-session'; + const HTTP_UNAUTHORIZED = 401; const HTTP_BAD_REQUEST = 400; const HTTP_FORBIDDEN = 403; @@ -189,6 +194,17 @@ export interface JmapSession { readonly eventSourcePath?: string | null; } +/** + * 사내 목록 계정의 세션 (캐시에 저장되므로 평범한 객체로 둔다) — 이 세션에는 메일 계정이 없고 계정 목록(principals) 계정만 있다 + * (2026-10-29 개발 엔진에서 확인). 직원 세션(JmapSession)으로 쓰지 않는다 + */ +interface JmapDirectorySession { + /** 이 세션을 연 사내 목록 계정 주소 — 설정이 바뀌면 다시 연다 */ + readonly account: string; + readonly accountId: string; + readonly apiPath: string; +} + /** 엔진의 변화 소식 스트림(JMAP EventSource)을 열 때 정하는 것 */ export interface JmapEventSourceOptions { /** 받을 종류 — Mailbox · Email 등 (RFC 8620 7.3 의 types) */ @@ -319,6 +335,7 @@ function parseContentLength(value: string | null): number | null { * - 로그인 확인: `<직원 주소>:<비밀번호>` 로 세션 조회 (200 / 401) * - 본인 비밀번호 바꾸기: 같은 본인 자격으로 `x:AccountPassword/set` (게이트웨이 대리 접근으로는 바꾸지 않는다) * - 메일함 접근: 게이트웨이 대리 접근 `<직원 주소>%<게이트웨이 주소>` : `<게이트웨이 비밀번호>` + * - 사내 목록: 같은 대리 접근으로 사내 목록 계정 하나만 — 계정 목록(Principals) 읽기에만 쓴다 (callDirectory) * - 세션 응답의 URL 은 공개 주소 기준이므로 경로만 떼어 MAIL_JMAP_URL 에 붙인다 (mail/README.md) * * 인증 헤더·비밀번호는 어떤 경우에도 로그에 남기지 않는다. @@ -704,6 +721,50 @@ export class JmapClient { ); } + /** + * 사내 목록 계정으로 계정 목록(JMAP Principals)을 부른다 (계약 3절 "사내 목록") — `using` 은 core · principals 뿐이다. + * 그 계정(`MAIL_DIRECTORY_LOCAL_PART@MAIL_DOMAIN`)은 비밀번호가 없고 로그인 · 계정 목록 읽기 권한만 있다 — 게이트웨이 대리 접근으로만 쓴다. + * 로그인 · 직원 메일함 대리 접근을 막는 목록(blockedAccounts)에 있으므로, 이 호출만 그 주소 하나를 대리 접근한다(직원 메일함 호출에는 쓰지 않는다). + * - 계정이 없음(세션 401) · 로그인 권한(authenticate)이 없음(403) · 게이트웨이 거절은 모두 MAIL_001 — 직원 세션과 상관없으므로 AUTH_001 로 답하지 않는다 + * - 메서드 오류(읽기 권한이 없으면 forbidden)는 그대로 돌려준다 — 해석은 부른 쪽(jmap-directory.provider.ts)이 한다 + */ + async callDirectory( + build: (accountId: string) => JmapInvocation[], + ): Promise { + const account = this.requireDirectoryAccount(); + const session = await this.getDirectorySession(account); + const body = JSON.stringify({ + using: [CORE_CAPABILITY, PRINCIPALS_CAPABILITY], + methodCalls: build(session.accountId), + }); + + return this.exchange( + session.apiPath, + { + method: 'POST', + headers: { + authorization: this.impersonation(account), + 'content-type': 'application/json', + }, + body, + }, + REQUEST_TIMEOUT_MS, + 'directory', + async (response) => { + await this.assertDirectoryAccepted(response); + await this.assertOk(response, 'directory'); + const parsed = await readJson(response, 'directory'); + if (!Array.isArray(parsed.methodResponses)) { + throw engineFailure({ + reason: 'malformed-response', + call: 'directory', + }); + } + return parsed.methodResponses; + }, + ); + } + /** * 첨부를 업로드한다 → blob id. * 본문은 Blob 으로 받는다 — 임시 파일을 연 Blob(fs.openAsBlob)을 넘기면 파일을 조금씩 읽어 흘려보내므로 @@ -959,7 +1020,92 @@ export class JmapClient { return this.settings; } - /** 막는 계정(관리자·MAIL_BLOCKED_ACCOUNTS)이면 AUTH_001 — 계정을 쓸 수 없는 것과 같게 다룬다 */ + /** 사내 목록 계정 주소 — 메일 엔진 설정 · 도메인이 없으면 MAIL_001 */ + private requireDirectoryAccount(): string { + const { directoryAccount } = this.requireConfigured(); + if (!directoryAccount) { + throw engineFailure({ reason: 'directory-not-configured' }); + } + return directoryAccount; + } + + /** + * 사내 목록 계정의 세션 — 5분 캐시한다(직원 세션과 같은 시간). 계정 id 는 세션의 `primaryAccounts['urn:ietf:params:jmap:principals']` + * (없으면 세션의 첫 계정) + */ + private async getDirectorySession( + account: string, + ): Promise { + const cached = await this.cache.get( + DIRECTORY_SESSION_CACHE_KEY, + ); + if (cached && cached.account === account) return cached; + + const session = await this.exchange( + SESSION_PATH, + { headers: { authorization: this.impersonation(account) } }, + REQUEST_TIMEOUT_MS, + 'directory-session', + async (response): Promise => { + await this.assertDirectoryAccepted(response); + await this.assertOk(response, 'directory-session'); + const resource = await readJson( + response, + 'directory-session', + ); + const accountId = + resource.primaryAccounts?.[PRINCIPALS_CAPABILITY] ?? + Object.keys(resource.accounts ?? {})[0]; + if (!accountId || !resource.apiUrl) { + throw engineFailure({ + reason: 'directory-session-incomplete', + call: 'directory-session', + }); + } + return { + account, + accountId, + apiPath: toRelativeReference(resource.apiUrl), + }; + }, + ); + await this.cache.set( + DIRECTORY_SESSION_CACHE_KEY, + session, + SESSION_CACHE_TTL_MS, + ); + return session; + } + + /** + * 사내 목록 계정의 대리 접근 거부 — 모두 MAIL_001 (사내 목록을 읽지 못함, 계약 3절): + * 403 은 그 계정의 역할에 로그인(authenticate)이 없거나 꺼짐, 401 은 계정이 없음 — 게이트웨이로 직접 들어가 보아 + * 게이트웨이도 거부되면 gateway-rejected(전체 메일 설정 문제)로 남긴다 + */ + private async assertDirectoryAccepted(response: Response): Promise { + if ( + response.status !== HTTP_UNAUTHORIZED && + response.status !== HTTP_FORBIDDEN + ) { + return; + } + await discard(response); + await this.cache.del(DIRECTORY_SESSION_CACHE_KEY); + if (response.status === HTTP_FORBIDDEN) { + throw engineFailure({ + reason: 'directory-account-forbidden', + status: response.status, + }); + } + throw engineFailure({ + reason: (await this.gatewayAccepted()) + ? 'directory-account-missing' + : 'gateway-rejected', + status: response.status, + }); + } + + /** 막는 계정(관리자·사내 목록 계정·MAIL_BLOCKED_ACCOUNTS)이면 AUTH_001 — 계정을 쓸 수 없는 것과 같게 다룬다 */ private assertNotBlocked(address: string): void { if (this.settings.blockedAccounts.has(address.trim().toLowerCase())) { throw new BusinessException('AUTH_001', { diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index 0768ed0..ff573c8 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -232,3 +232,39 @@ export interface JmapUploadResult { type?: string; size?: number; } + +/** JMAP for Contacts — AddressBook (계정마다 기본 주소록 하나 — isDefault, 2026-10-29 확인) */ +export interface JmapAddressBook { + id: string; + name?: string; + isDefault?: boolean; +} + +/** + * JMAP for Contacts — ContactCard (JSContact 카드, RFC 9553). 칸마다 모양이 여러 가지일 수 있어(다른 프로그램 · CardDAV 에서 온 카드) + * 모두 unknown 으로 받고 읽을 때 모양을 확인한다 (contact-card.ts) + */ +export interface JmapContactCard { + id: string; + uid?: unknown; + kind?: unknown; + name?: unknown; + emails?: unknown; + phones?: unknown; + organizations?: unknown; + titles?: unknown; + notes?: unknown; + members?: unknown; +} + +/** + * JMAP Principals — 엔진의 계정 하나 (사내 목록 계정으로 읽는다 — 2026-10-29 확인: 주는 칸은 id · type · name(로그인 이름) · + * description(표시 이름) · email 뿐이다) + */ +export interface JmapPrincipal { + id: string; + type?: unknown; + name?: unknown; + description?: unknown; + email?: unknown; +} diff --git a/backend/src/modules/mail/mail-engine.module.ts b/backend/src/modules/mail/mail-engine.module.ts new file mode 100644 index 0000000..dcd3705 --- /dev/null +++ b/backend/src/modules/mail/mail-engine.module.ts @@ -0,0 +1,41 @@ +import { Logger, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { JmapClient } from './jmap/jmap.client'; +import { + MAIL_SETTINGS, + type MailSettings, + buildMailSettings, + isMailEngineConfigured, +} from './mail.settings'; + +function createMailSettings(config: ConfigService): MailSettings { + const settings = buildMailSettings((key) => config.get(key)); + if (!isMailEngineConfigured(settings)) { + // 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다) — CI e2e 가 이 경로다 + new Logger('MailEngineModule').warn( + '메일 엔진 설정(MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*)이 없어 로그인·메일·주소록 API 가 MAIL_001 을 돌려줍니다.', + ); + } + return settings; +} + +/** + * 메일 엔진 연결 — 설정(MAIL_SETTINGS)과 JMAP 클라이언트(JmapClient) 하나를 메일 모듈과 주소록 모듈이 함께 쓴다. + * 두 모듈이 서로를 가져오지 않게(메일 → 주소록 방향만) 여기로 뺐다 (8단계). + * + * ⚠️ JmapClient 는 엔진 어댑터(`modules/mail/jmap/` 의 구현)만 쓴다 — 서비스 · 컨트롤러는 포트(MAIL_PROVIDER · CONTACTS_PROVIDER · + * DIRECTORY_PROVIDER)만 안다 (JMAP 타입 · 호출은 jmap/ 밖으로 내보내지 않는다) + */ +@Module({ + providers: [ + { + provide: MAIL_SETTINGS, + inject: [ConfigService], + useFactory: createMailSettings, + }, + JmapClient, + ], + exports: [MAIL_SETTINGS, JmapClient], +}) +export class MailEngineModule {} diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index e4f83d7..43ec754 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -1,4 +1,4 @@ -import { Logger, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { MulterModule, @@ -6,11 +6,11 @@ import { } from '@nestjs/platform-express'; import { BusinessException } from '@/common/errors/business.exception'; +import { ContactsModule } from '@/modules/contacts/contacts.module'; import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module'; import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module'; import { isAllowedAttachment } from './attachment.policy'; import { DraftController } from './draft.controller'; -import { JmapClient } from './jmap/jmap.client'; import { JmapFilterRunner } from './jmap/jmap-filter-run'; import { JmapFilterScriptStore } from './jmap/jmap-filter-script'; import { JmapMailProvider } from './jmap/jmap-mail.provider'; @@ -21,18 +21,14 @@ import { MailFilterRunController, } from './mail-filter.controller'; import { MailEventsService } from './mail-events.service'; +import { MailEngineModule } from './mail-engine.module'; import { MailUploadTempCleanup, mailUploadStorage, } from './mail-upload-storage'; import { MAIL_PROVIDER } from './mail.provider'; import { MailService } from './mail.service'; -import { - MAIL_SETTINGS, - type MailSettings, - buildMailSettings, - isMailEngineConfigured, -} from './mail.settings'; +import { type MailSettings, buildMailSettings } from './mail.settings'; import { MailboxController, MailQuotaController, @@ -48,17 +44,6 @@ function readMailSettings(config: ConfigService): MailSettings { return buildMailSettings((key) => config.get(key)); } -function createMailSettings(config: ConfigService): MailSettings { - const settings = readMailSettings(config); - if (!isMailEngineConfigured(settings)) { - // 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다) — CI e2e 가 이 경로다 - new Logger('MailModule').warn( - '메일 엔진 설정(MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*)이 없어 로그인·메일 API 가 MAIL_001 을 돌려줍니다.', - ); - } - return settings; -} - /** * 메일 첨부 업로드 설정 — 임시 디스크에 받아 메일 엔진으로 흘려보내고 지운다 (mail-upload-storage.ts). * 메모리에 통째로 올리지 않는다 (운영 메모리 한도 1GiB — 동시 업로드에 버티게). @@ -92,10 +77,11 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { /** * 메일 모듈 — 웹메일 API 와 메일 엔진 포트. * 엔진을 바꿀 때는 `MAIL_PROVIDER` 의 구현(useClass)만 바꾼다. - * 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정을 내보낸다. + * 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정(MailEngineModule)을 내보낸다. * - * 서명(그림 포함)은 MailSignatureModule, 받는 사람 추천의 사내 목록은 MailDirectoryModule 이 맡는다 — - * 두 모듈은 메일 모듈을 가져오지 않는다 (순환 없음). + * 서명(그림 포함)은 MailSignatureModule, 받는 사람 추천의 주소록 · 사내 목록은 ContactsModule(엔진의 주소록 · 계정 목록 — 8단계), + * 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록은 MailDirectoryModule 이 맡는다 — 세 모듈은 메일 모듈을 가져오지 않는다 (순환 없음). + * 엔진 연결(설정 · JmapClient)은 MailEngineModule 하나를 메일 · 주소록이 함께 쓴다. */ @Module({ imports: [ @@ -103,9 +89,13 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { inject: [ConfigService], useFactory: attachmentUploadOptions, }), + // 메일 엔진 설정 · JMAP 클라이언트 (주소록 모듈과 함께 쓴다) + MailEngineModule, // 발송 때 서명 그림을 메일 안에 넣고, 임시 저장·발송 정화에 내 그림 id 를 쓴다 (계약 2절 "서명") MailSignatureModule, - // 받는 사람 추천의 사내 목록 (계약 2절 "받는 사람 추천") + // 받는 사람 추천의 주소록 · 사내 목록 (계약 3절 "메일과 함께") + ContactsModule, + // 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록 (계약 2절 "받는 사람 추천") MailDirectoryModule, ], controllers: [ @@ -123,12 +113,6 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { RecipientSuggestionController, ], providers: [ - { - provide: MAIL_SETTINGS, - inject: [ConfigService], - useFactory: createMailSettings, - }, - JmapClient, // 필터 · 부재중 자동 응답의 저장 자리(엔진의 Sieve 스크립트 하나) — 어댑터 안에서만 쓴다 JmapFilterScriptStore, // 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다 @@ -140,6 +124,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { RecipientSuggestionService, MailUploadTempCleanup, ], - exports: [MAIL_PROVIDER, MAIL_SETTINGS], + // 메일 엔진 설정(MAIL_SETTINGS)은 MailEngineModule 을 그대로 내보낸다 — 인증 모듈이 주소 정책에 쓴다 + exports: [MAIL_PROVIDER, MailEngineModule], }) export class MailModule {} diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 50c52dc..62d709d 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -40,7 +40,11 @@ const settings: MailSettings = { gatewayPassword: 'secret', attachmentMaxBytes: 20 * 1024 * 1024, attachmentTotalMaxBytes: 25 * 1024 * 1024, - blockedAccounts: new Set(['admin@arasystem.test']), + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', }; /** 포트의 각 메서드를 타입이 있는 jest.fn 속성으로 — 메서드가 아니라 속성이라 그대로 expect 에 넘길 수 있다 */ diff --git a/backend/src/modules/mail/mail.settings.spec.ts b/backend/src/modules/mail/mail.settings.spec.ts index e646285..fe67d81 100644 --- a/backend/src/modules/mail/mail.settings.spec.ts +++ b/backend/src/modules/mail/mail.settings.spec.ts @@ -1,6 +1,7 @@ import { DEFAULT_ATTACHMENT_MAX_SIZE_MB, DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB, + DEFAULT_MAIL_DIRECTORY_LOCAL_PART, buildMailSettings, isMailEngineConfigured, parseAccountList, @@ -100,7 +101,7 @@ describe('buildMailSettings', () => { expect([ ...buildMailSettings(reader({ MAIL_DOMAIN: 'AraSystem.Test' })) .blockedAccounts, - ]).toEqual(['admin@arasystem.test']); + ]).toEqual(['admin@arasystem.test', 'mail-directory@arasystem.test']); expect([ ...buildMailSettings( @@ -111,6 +112,7 @@ describe('buildMailSettings', () => { ).blockedAccounts, ]).toEqual([ 'admin@arasystem.test', + 'mail-directory@arasystem.test', 'ops@arasystem.test', 'audit@arasystem.test', ]); @@ -127,6 +129,7 @@ describe('buildMailSettings', () => { expect([...blocked]).toEqual([ 'mail-admin@arasystem.test', + 'mail-directory@arasystem.test', 'ops@arasystem.test', ]); expect(blocked.has('admin@arasystem.test')).toBe(false); @@ -137,14 +140,45 @@ describe('buildMailSettings', () => { ...buildMailSettings( reader({ MAIL_DOMAIN: 'arasystem.test', MAIL_ADMIN_LOCAL_PART: ' ' }), ).blockedAccounts, - ]).toEqual(['admin@arasystem.test']); + ]).toEqual(['admin@arasystem.test', 'mail-directory@arasystem.test']); }); - it('도메인이 없으면 목록에 적은 계정만 막는다', () => { - expect([ - ...buildMailSettings(reader({ MAIL_BLOCKED_ACCOUNTS: 'ops@x.test' })) - .blockedAccounts, - ]).toEqual(['ops@x.test']); + it(`사내 목록 계정은 기본 ${DEFAULT_MAIL_DIRECTORY_LOCAL_PART}@<도메인> — 넘어오지 않거나 빈 값이면 기본값, 로그인 · 대리 접근은 막는다`, () => { + const variants: Record[] = [ + { MAIL_DOMAIN: 'AraSystem.Test' }, + { MAIL_DOMAIN: 'arasystem.test', MAIL_DIRECTORY_LOCAL_PART: '' }, + { MAIL_DOMAIN: 'arasystem.test', MAIL_DIRECTORY_LOCAL_PART: ' ' }, + ]; + for (const values of variants) { + const settings = buildMailSettings(reader(values)); + expect(settings.directoryAccount).toBe('mail-directory@arasystem.test'); + expect( + settings.blockedAccounts.has('mail-directory@arasystem.test'), + ).toBe(true); + } + }); + + it('사내 목록 계정 아이디를 MAIL_DIRECTORY_LOCAL_PART 로 바꾸면 그 주소를 쓰고 막는다 (소문자)', () => { + const settings = buildMailSettings( + reader({ + MAIL_DOMAIN: 'arasystem.test', + MAIL_DIRECTORY_LOCAL_PART: ' Company-Directory ', + }), + ); + + expect(settings.directoryAccount).toBe('company-directory@arasystem.test'); + expect([...settings.blockedAccounts]).toEqual([ + 'admin@arasystem.test', + 'company-directory@arasystem.test', + ]); + }); + + it('도메인이 없으면 목록에 적은 계정만 막는다 — 사내 목록 계정도 없다', () => { + const settings = buildMailSettings( + reader({ MAIL_BLOCKED_ACCOUNTS: 'ops@x.test' }), + ); + expect([...settings.blockedAccounts]).toEqual(['ops@x.test']); + expect(settings.directoryAccount).toBeNull(); expect(buildMailSettings(reader({})).blockedAccounts.size).toBe(0); }); }); diff --git a/backend/src/modules/mail/mail.settings.ts b/backend/src/modules/mail/mail.settings.ts index c1e7983..d5f3814 100644 --- a/backend/src/modules/mail/mail.settings.ts +++ b/backend/src/modules/mail/mail.settings.ts @@ -25,12 +25,20 @@ export interface MailSettings { */ readonly attachmentTotalMaxBytes: number; /** - * 로그인·대리 접근을 막는 계정(소문자 주소) — 메일 관리자 `@<도메인>`(기본 admin) + MAIL_BLOCKED_ACCOUNTS. + * 로그인·대리 접근을 막는 계정(소문자 주소) — 메일 관리자 `@<도메인>`(기본 admin) + + * 사내 목록 계정 `@<도메인>`(기본 mail-directory) + MAIL_BLOCKED_ACCOUNTS. * 게이트웨이는 관리자 계정도 대리 접근할 수 있고, 그 세션은 **관리 권한**을 가진다(Stalwart 0.16 에서 확인). * 웹메일이 관리자 계정을 다루지 않게 하고, 공개된 로그인 창이 관리자 비밀번호를 시험해 보는 창구가 되지 않게 - * 메일 엔진에 묻기 **전에** 거절한다. + * 메일 엔진에 묻기 **전에** 거절한다. 사내 목록(계약 3절)도 같은 목록으로 거른다 (contacts/directory-policy.ts) */ readonly blockedAccounts: ReadonlySet; + /** + * 사내 목록 계정 주소 전체 (소문자, 도메인이 없으면 null) — 서버만 쓰는 계정이다: 비밀번호가 없고, 역할에 로그인과 계정 목록 읽기 + * (authenticate · jmapPrincipalGet · jmapPrincipalQuery)만 있다. 게이트웨이 대리 접근으로 엔진의 계정 목록을 읽는 데만 쓴다 + * (jmap/jmap.client.ts 의 callDirectory — 계약 3절 "사내 목록"). 로그인 · 직원 메일함 대리 접근은 막는다(blockedAccounts). + * 메일 엔진 설정 스크립트(`mail/scripts/setup.mjs`)가 같은 env 로 이 계정을 만든다 + */ + readonly directoryAccount: string | null; } /** 메일 엔진에 붙을 수 있는 설정 — 필수값이 모두 있는 상태 */ @@ -57,6 +65,12 @@ export const DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB = 25; */ export const DEFAULT_MAIL_ADMIN_LOCAL_PART = 'admin'; +/** + * 사내 목록 계정 아이디의 기본값 — 루트 env `MAIL_DIRECTORY_LOCAL_PART`(선택)로 바꾼다. compose 가 넘기지 않거나 빈 값이면 이 값이다. + * 메일 엔진 설정 스크립트(`mail/scripts/setup.mjs`)가 같은 env 로 그 계정을 만든다 — 두 쪽이 한 값을 읽는다 + */ +export const DEFAULT_MAIL_DIRECTORY_LOCAL_PART = 'mail-directory'; + const MEGABYTE = 1024 * 1024; function readTrimmed( @@ -97,6 +111,11 @@ export function buildMailSettings( const adminLocalPart = ( readTrimmed(read, 'MAIL_ADMIN_LOCAL_PART') ?? DEFAULT_MAIL_ADMIN_LOCAL_PART ).toLowerCase(); + const directoryLocalPart = ( + readTrimmed(read, 'MAIL_DIRECTORY_LOCAL_PART') ?? + DEFAULT_MAIL_DIRECTORY_LOCAL_PART + ).toLowerCase(); + const directoryAccount = domain ? `${directoryLocalPart}@${domain}` : null; return { jmapBaseUrl: toOrigin(readTrimmed(read, 'MAIL_JMAP_URL')), @@ -111,8 +130,10 @@ export function buildMailSettings( ), blockedAccounts: new Set([ ...(domain ? [`${adminLocalPart}@${domain}`] : []), + ...(directoryAccount ? [directoryAccount] : []), ...parseAccountList(read('MAIL_BLOCKED_ACCOUNTS')), ]), + directoryAccount, }; } diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 8cc8caa..7a9c86b 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -1,5 +1,7 @@ import type { Readable } from 'stream'; +import type { ContactEmailLabel } from '@/modules/contacts/contacts.types'; + /** * 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다. * @@ -514,17 +516,24 @@ export interface Correspondence { readonly at: string; } -/** 사내 목록의 한 사람 — 웹메일에 로그인한 적 있는 직원 */ +/** 사내 목록의 한 사람 — 엔진의 사내 목록(8단계, 직원 · 공용 주소) 또는 그것을 읽지 못할 때 웹메일에 로그인한 적 있는 직원 */ export interface DirectoryContact { readonly email: string; readonly name: string; } +/** 받는 사람 추천의 묶음 — contact 내 주소록(8단계) · directory 사내 · recent 최근 주고받은 주소 */ +export type RecipientSuggestionSource = 'contact' | 'directory' | 'recent'; + /** 받는 사람 추천 (계약 2절 "받는 사람 추천") */ export interface RecipientSuggestion { readonly name: string | null; readonly email: string; - readonly source: 'directory' | 'recent'; + readonly source: RecipientSuggestionSource; + /** contact 만 — 그 연락처의 회사 (그 밖에는 null) */ + readonly company: string | null; + /** contact 만 — 그 주소의 종류 (그 밖에는 null) */ + readonly emailLabel: ContactEmailLabel | null; } /** diff --git a/backend/src/modules/mail/recipient-match.spec.ts b/backend/src/modules/mail/recipient-match.spec.ts new file mode 100644 index 0000000..0ca2c7f --- /dev/null +++ b/backend/src/modules/mail/recipient-match.spec.ts @@ -0,0 +1,156 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { + recipientEmailMatcher, + recipientMatcher, + recipientNameMatcher, +} from './recipient-match'; + +/** + * 받는 사람 맞추기 표본 — [찾는 글(q), 이름(없으면 null), 주소, 맞는가] (계약 2절 "받는 사람 추천"). + * frontend shared/utils/recipient-match.ts 의 matchName · matchEmail 과 **같은 표**다 (행과 순서까지 그대로 — 답은 frontend 함수로 만들었다). + * ⚠️ 맞추기를 바꾸면 두 파일(recipient-match.ts 두 쪽)과 두 표를 함께 고친다 — 서버가 다르게 맞추면 쓰기 화면이 받아 둔 것에서 찾은 것과 서버의 답이 어긋난다 + */ +const RECIPIENT_MATCH_SAMPLES: readonly (readonly [ + query: string, + name: string | null, + email: string, + matches: boolean, +])[] = [ + ['도윤', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['DOYUN', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['Lee', 'Doyun Lee', 'd.lee@example.com', true], + ['lEE', 'Doyun Lee', 'd.lee@example.com', true], + ['윤도', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['박', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['ㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['ㅇㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['ㅂㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['ㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['ㅎㅈ', '박지훈', 'jihoon.park@hanbit.co.kr', false], + ['ㄲ', '꽃님', 'flower@example.com', true], + ['ㄱ', '꽃님', 'flower@example.com', false], + ['ㅃ', '빵집', 'bread@example.com', true], + ['도ㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['ㄷ윤', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['이ㄷ', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['도유', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['이도유', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['동', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['이동', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['돈', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['도요', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['유', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['윤', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['도윤이', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['지후', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['박지후', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['박짛', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['박지', '박지훈', 'jihoon.park@hanbit.co.kr', true], + ['닭', '달걀', 'egg@example.com', true], + ['넋', '너서', 'neo@example.com', false], + ['값', '가비', 'gabi@example.com', false], + ['값', '갑시다', 'gapsi@example.com', true], + ['갑', '갑시다', 'gapsi@example.com', true], + ['ㄳ', '넋', 'neok@example.com', false], + ['ㄱ', '넋', 'neok@example.com', false], + ['김 아', '김 아라', 'ara.kim@arasystem.it.kr', true], + ['김아', '김 아라', 'ara.kim@arasystem.it.kr', false], + ['ㄱ ㅇ', '김 아라', 'ara.kim@arasystem.it.kr', true], + ['ㄱㅇ', '김 아라', 'ara.kim@arasystem.it.kr', false], + [' 도윤 ', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['arasystem', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['DOYUN.LEE@', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['doyun.lee@arasystem.it.kr', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['doyun.lee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', true], + ['doyun.leee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['@arasystem', '', 'doyun.lee@arasystem.it.kr', true], + ['lee@', 'Doyun', 'doyun.lee@arasystem.it.kr', true], + ['a@b@c', '', 'a@x.example', true], + ['Doyun.Lee@AraSystem.it.kr', null, 'doyun.lee@arasystem.it.kr', true], + ['kr', null, 'doyun.lee@arasystem.it.kr', true], + ['istanbul', null, 'İstanbul@example.com', false], + ['example', null, 'İstanbul@example.com', false], + ['도윤', null, 'doyun.lee@arasystem.it.kr', false], + ['도윤', '', 'x@example.com', false], + ['😀', '팀😀', 'team@example.com', true], + ['팀😀', '팀😀', 'team@example.com', true], + ['😀팀', '팀😀', 'team@example.com', false], + ['𠀋', '𠀋田', 'kanji@example.com', true], + ['ㅏ', '아라', 'ara@example.com', false], + ['ㅇㅏ', '아라', 'ara@example.com', false], + ['2팀', '영업2팀', 'sales2@example.com', true], + ['(주)', '(주)한빛', 'hanbit@example.com', true], + ['도윤'.normalize('NFD'), '이도윤', 'doyun.lee@arasystem.it.kr', false], + ['도윤', '이도윤'.normalize('NFD'), 'x@example.com', false], + [ + 'ㄱ'.repeat(33), + 'ㄱㄱㄱㄱㄱ가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가', + 'long@example.com', + true, + ], + ['가'.repeat(32) + '나', '가'.repeat(40) + '나', 'long@example.com', true], + [ + '가'.repeat(31) + '나다', + '가'.repeat(40) + '나다', + 'long@example.com', + true, + ], + [ + '가'.repeat(31) + '나라', + '가'.repeat(40) + '나다', + 'long@example.com', + false, + ], +]; + +describe('받는 사람 맞추기 (recipient-match — 쓰기 화면과 같은 규칙)', () => { + it.each(RECIPIENT_MATCH_SAMPLES)( + 'q=%j 이름=%j 주소=%j → %s', + (query, name, email, matches) => { + expect(recipientMatcher(query)({ name, email })).toBe(matches); + }, + ); + + it('빈 글은 아무것에도 맞지 않는다 (거르지 않는 것은 부르는 쪽이 정한다)', () => { + expect(recipientNameMatcher('')('이도윤')).toBe(false); + expect(recipientNameMatcher(' ')('이도윤')).toBe(false); + expect(recipientEmailMatcher(' ')('a@b.example')).toBe(false); + }); + + it('한 쿼리로 여러 이름을 견줘도 답이 섞이지 않는다 (글자마다 기억한 표)', () => { + const matches = recipientNameMatcher('ㄷ윤'); + expect(['이도윤', '박지훈', '도윤', '두윤', '도요'].map(matches)).toEqual([ + true, + false, + true, + true, + false, + ]); + }); + + it('긴 이름 × 긴 글도 이름 길이에 비례한다 — 제곱이 아니다 (CPU 시간)', () => { + // 시작 자리마다 다시 견주면(화면의 방법) 이름 100만 자 × 글 100자 — 1억 번 비교로 수십 초 + const name = `${'가'.repeat(1_000_000)}나`; + const names = Array.from( + { length: 3_000 }, + (_, index) => `${'가'.repeat(99)}${index}`, + ); + const cpuMs = startCpuTimer(); + expect(recipientNameMatcher(`${'가'.repeat(99)}다`)(name)).toBe(false); + expect(recipientNameMatcher(`${'가'.repeat(99)}나`)(name)).toBe(true); + const matches = recipientNameMatcher(`${'가'.repeat(99)}다`); + expect(names.filter(matches)).toEqual([]); + // 지금 0.06~0.14초 — 한도는 10배쯤 (testing 규칙). 옛 방법(1억 번 비교)은 확실히 넘는다 + expect(cpuMs()).toBeLessThan(1_500); + }); + + it('추천 줄 거르기 — 같은 이름은 한 번만 견준다 (연락처 하나의 주소 여러 줄)', () => { + const matches = recipientMatcher('ㄷㅇ'); + const lines = Array.from({ length: 20 }, (_, index) => ({ + name: '이도윤', + email: `doyun${index}@x.example`, + })); + expect(lines.every(matches)).toBe(true); + expect(matches({ name: '박지훈', email: 'jihoon@x.example' })).toBe(false); + }); +}); diff --git a/backend/src/modules/mail/recipient-match.ts b/backend/src/modules/mail/recipient-match.ts new file mode 100644 index 0000000..6d4c010 --- /dev/null +++ b/backend/src/modules/mail/recipient-match.ts @@ -0,0 +1,262 @@ +/** + * 받는 사람 추천의 맞추기 (순수 함수) — 계약 2절 "받는 사람 추천" 의 `q`. **쓰기 화면과 같은 맞추기**다: + * 짝은 frontend `shared/utils/recipient-match.ts` 의 matchName · matchEmail 이고, 두 쪽 테스트가 같은 표본 표를 쓴다 + * (recipient-match.spec.ts 의 RECIPIENT_MATCH_SAMPLES — 한쪽을 고치면 다른 쪽과 표도 함께). + * + * - 이름: 친 글자가 이름 어딘가에 이어서 들어 있으면 맞다(대소문자 무시 — `toLowerCase`). 친 글자가 초성(호환 자모)이면 그 초성의 글자에 맞는다 + * (ㄷㅇ → 이"도윤", 도ㅇ → 이"도윤"). 마지막으로 친 글자는 아직 조합 중일 수 있다 — 받침 없는 글자(유)는 같은 초성 · 중성의 글자(윤)에, + * 받침이 있는 글자(동)는 그 받침이 다음 글자의 초성으로 넘어간 것(도 + ㅇ윤)에도 맞는다(겹받침은 뒤 자음이 넘어간다 — 닭 → 달 + ㄱ). + * 글자는 코드 포인트로 센다(서로게이트 쌍 하나가 한 글자). + * - 주소: 친 글자가 든 주소(대소문자 무시). 완전한 주소를 쳤으면 같은 아이디(@ 앞, @ 포함)의 주소도 맞다. 대소문자를 바꾸면 길이가 달라지는 글자가 든 주소는 맞추지 않는다. + * + * 화면은 받은 추천 100줄에서 이 규칙으로 고르고, 서버는 전체에서 고른다 — 같은 규칙이라 서버에 물어도 받아 둔 것에서 찾은 것이 그대로 나온다. + * 화면은 맞은 범위(굵게 보일 곳)를 찾지만 서버는 맞는지만 본다 — 이름은 비트 병렬(shift-and)로 한 번 훑어 이름 길이에 비례한다 + * (화면처럼 시작 자리마다 다시 견주면 긴 이름 × 긴 글이 제곱이 된다 — 다른 프로그램이 넣은 긴 이름도 받는다). + */ + +const SYLLABLE_START = 0xac00; +const SYLLABLE_END = 0xd7a3; +const MEDIAL_COUNT = 21; +const FINAL_COUNT = 28; +/** 초성 — 호환 자모 */ +const INITIALS = Array.from('ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ'); +const INITIAL_SET: ReadonlySet = new Set(INITIALS); +/** 받침 — 0 은 받침 없음 */ +const FINALS = [ + '', + ...Array.from('ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ'), +]; +/** 겹받침을 나눈다 — 뒤 자음이 다음 글자의 초성으로 넘어가는 순간이 있다 (닭 → 달 + ㄱ) */ +const SPLIT_FINALS: Readonly< + Record +> = { + ㄳ: ['ㄱ', 'ㅅ'], + ㄵ: ['ㄴ', 'ㅈ'], + ㄶ: ['ㄴ', 'ㅎ'], + ㄺ: ['ㄹ', 'ㄱ'], + ㄻ: ['ㄹ', 'ㅁ'], + ㄼ: ['ㄹ', 'ㅂ'], + ㄽ: ['ㄹ', 'ㅅ'], + ㄾ: ['ㄹ', 'ㅌ'], + ㄿ: ['ㄹ', 'ㅍ'], + ㅀ: ['ㄹ', 'ㅎ'], + ㅄ: ['ㅂ', 'ㅅ'], +}; + +/** 비트 병렬 상태의 한 칸 (32비트) */ +const WORD_BITS = 32; +/** 글자마다 기억하는 비트 표의 수 — 한 요청 안에서만 쓰고, 넘으면 비운다(메모리 상한) */ +const MASK_CACHE_LIMIT = 10_000; + +interface Syllable { + readonly initial: number; + readonly medial: number; + readonly final: number; +} + +function syllableOf(char: string): Syllable | null { + const code = char.codePointAt(0) ?? 0; + if (char.length !== 1 || code < SYLLABLE_START || code > SYLLABLE_END) { + return null; + } + const offset = code - SYLLABLE_START; + return { + initial: Math.floor(offset / (MEDIAL_COUNT * FINAL_COUNT)), + medial: Math.floor((offset % (MEDIAL_COUNT * FINAL_COUNT)) / FINAL_COUNT), + final: offset % FINAL_COUNT, + }; +} + +function composeSyllable(syllable: Syllable): string { + return String.fromCharCode( + SYLLABLE_START + + (syllable.initial * MEDIAL_COUNT + syllable.medial) * FINAL_COUNT + + syllable.final, + ); +} + +function initialOf(char: string): string | null { + const syllable = syllableOf(char); + return syllable ? (INITIALS[syllable.initial] ?? null) : null; +} + +/** 친 글자 하나가 이름 글자 하나에 맞는가 — 같은 글자(대소문자 무시) 또는 초성 */ +function matchesChar(typed: string, char: string): boolean { + if (typed.toLowerCase() === char.toLowerCase()) return true; + return INITIAL_SET.has(typed) && initialOf(char) === typed; +} + +/** + * 마지막으로 친 글자 — 조합 중일 수 있다. 맞으면 이름에서 쓴 글자 수(1 · 2), 아니면 0. + * - 받침 없는 글자(유)는 같은 초성 · 중성의 글자(윤 · 율 …)에 맞는다 + * - 받침이 있는 글자(동)는 받침이 다음 글자의 초성으로 넘어갈 수 있다 — "도" + 다음 글자 초성 "ㅇ"(도윤) + */ +function matchesLastChar( + typed: string, + char: string, + next: string | undefined, +): number { + if (matchesChar(typed, char)) return 1; + const typedSyllable = syllableOf(typed); + const syllable = syllableOf(char); + if (!typedSyllable || !syllable) return 0; + if ( + typedSyllable.initial !== syllable.initial || + typedSyllable.medial !== syllable.medial + ) { + return 0; + } + if (typedSyllable.final === 0) return 1; + if (next === undefined) return 0; + const finalJamo = FINALS[typedSyllable.final] ?? ''; + const [rest, moved] = SPLIT_FINALS[finalJamo] ?? ['', finalJamo]; + const restSyllable = composeSyllable({ + ...typedSyllable, + final: FINALS.indexOf(rest), + }); + return restSyllable === char && initialOf(next) === moved ? 2 : 0; +} + +/** + * 마지막으로 친 글자의 맞추기를 글자마다 한 번만 — 다음 글자와 상관없는 몫(matchesLastChar 의 1)은 글자(코드 포인트)마다 기억하고, + * 받침이 다음 글자의 초성으로 넘어간 몫(2)은 그 글자가 "받침을 뗀 글자"일 때만 다음 글자의 초성을 본다 + */ +function lastCharMatcher( + last: string, +): (point: number, next: number | undefined) => boolean { + const simple = new Map(); + const typedSyllable = syllableOf(last); + let carry: { readonly rest: number; readonly moved: string } | null = null; + if (typedSyllable && typedSyllable.final !== 0) { + const finalJamo = FINALS[typedSyllable.final] ?? ''; + const [rest, moved] = SPLIT_FINALS[finalJamo] ?? ['', finalJamo]; + carry = { + rest: composeSyllable({ + ...typedSyllable, + final: FINALS.indexOf(rest), + }).charCodeAt(0), + moved, + }; + } + return (point, next) => { + let matched = simple.get(point); + if (matched === undefined) { + matched = + matchesLastChar(last, String.fromCodePoint(point), undefined) > 0; + if (simple.size >= MASK_CACHE_LIMIT) simple.clear(); + simple.set(point, matched); + } + if (matched) return true; + if (carry === null || point !== carry.rest || next === undefined) { + return false; + } + return initialOf(String.fromCodePoint(next)) === carry.moved; + }; +} + +/** + * 이름 맞추기 — 친 글자(초성 · 조합 중 포함)가 이름 어딘가에 맞는가. 쿼리 하나로 여러 이름을 견주는 함수를 돌려준다. + * 마지막 글자 앞까지는 비트 병렬(shift-and — 상태의 k 번째 비트: 앞의 k+1 글자가 지금 자리에서 끝나게 맞았다)로 훑고, + * 그것이 다 맞은 다음 자리에서 마지막 글자를 견준다 — 화면의 matchName 과 같은 답이다. + * 이름은 코드 포인트로 한 번 훑는다(글자를 배열로 만들지 않는다) — 글자마다의 비트 표 · 마지막 글자 답은 쿼리 안에서 기억한다 + */ +export function recipientNameMatcher(query: string): (name: string) => boolean { + const typed = Array.from(query.trim()); + const last = typed.at(-1); + if (last === undefined) return () => false; + const lastMatches = lastCharMatcher(last); + const prefix = typed.slice(0, -1); + const prefixLower = prefix.map((char) => char.toLowerCase()); + const length = prefix.length; + const words = Math.ceil(length / WORD_BITS); + const finalWord = Math.floor((length - 1) / WORD_BITS); + const finalBit = 1 << ((length - 1) % WORD_BITS); + const masks = new Map(); + const maskOf = (point: number): Uint32Array => { + const known = masks.get(point); + if (known) return known; + const char = String.fromCodePoint(point); + const lower = char.toLowerCase(); + const initial = initialOf(char); + const mask = new Uint32Array(words); + prefix.forEach((typedChar, index) => { + // matchesChar 와 같다 — 같은 글자(대소문자 무시) 또는 초성 + if ( + prefixLower[index] === lower || + (INITIAL_SET.has(typedChar) && initial === typedChar) + ) { + mask[Math.floor(index / WORD_BITS)] |= 1 << (index % WORD_BITS); + } + }); + if (masks.size >= MASK_CACHE_LIMIT) masks.clear(); + masks.set(point, mask); + return mask; + }; + + return (name: string) => { + const state = length === 0 ? null : new Uint32Array(words); + let at = 0; + while (at < name.length) { + const point = name.codePointAt(at) ?? 0; + const nextAt = at + (point > 0xffff ? 2 : 1); + const next = nextAt < name.length ? name.codePointAt(nextAt) : undefined; + // 앞 글자들이 바로 앞 자리에서 끝나게 맞았다(앞 글자가 없으면 늘) — 이 자리에서 마지막 글자를 견준다 + const ready = + state === null || ((state[finalWord] ?? 0) & finalBit) !== 0; + if (ready && lastMatches(point, next)) return true; + if (state !== null) { + const mask = maskOf(point); + let carry = 1; + for (let word = 0; word < words; word += 1) { + const value = state[word] ?? 0; + state[word] = ((value << 1) | carry) & (mask[word] ?? 0); + carry = value >>> (WORD_BITS - 1); + } + } + at = nextAt; + } + return false; + }; +} + +/** 주소 맞추기 — 친 글자가 든 주소(대소문자 무시), 완전한 주소를 쳤으면 같은 아이디(@ 포함)의 주소도 */ +export function recipientEmailMatcher( + query: string, +): (email: string) => boolean { + const typed = query.trim().toLowerCase(); + const localEnd = typed.indexOf('@'); + const local = localEnd > 0 ? typed.slice(0, localEnd + 1) : null; + return (email: string) => { + const target = email.toLowerCase(); + // 대소문자를 바꾸며 길이가 달라지는 글자(드물다)면 화면이 범위를 믿을 수 없어 맞추지 않는다 — 같게 + if (typed === '' || target.length !== email.length) return false; + if (target.includes(typed)) return true; + return local !== null && target.includes(local); + }; +} + +/** + * 추천 한 줄이 친 글자에 맞는가 — 이름(있으면) · 주소 가운데 하나라도. + * 이름의 답은 이 쿼리 안에서 기억한다 — 주소록 한 연락처의 주소 여러 줄은 이름이 같다(연락처 3,000 × 주소 20 = 6만 줄도 이름은 3,000번만 본다) + */ +export function recipientMatcher( + query: string, +): (suggestion: { + readonly name: string | null; + readonly email: string; +}) => boolean { + const name = recipientNameMatcher(query); + const email = recipientEmailMatcher(query); + const names = new Map(); + const nameMatches = (text: string): boolean => { + let matched = names.get(text); + if (matched === undefined) { + matched = name(text); + names.set(text, matched); + } + return matched; + }; + return (suggestion) => + (suggestion.name ? nameMatches(suggestion.name) : false) || + email(suggestion.email); +} diff --git a/backend/src/modules/mail/recipient-ranking.spec.ts b/backend/src/modules/mail/recipient-ranking.spec.ts index 713bb8b..ce50bc5 100644 --- a/backend/src/modules/mail/recipient-ranking.spec.ts +++ b/backend/src/modules/mail/recipient-ranking.spec.ts @@ -1,8 +1,10 @@ -import type { Correspondence } from './mail.types'; +import type { ContactSuggestionLine } from '@/modules/contacts/contacts.types'; +import type { Correspondence, RecipientSuggestion } from './mail.types'; import { MAX_RECIPIENT_SUGGESTIONS, filterRecipientSuggestions, rankRecipientSuggestions, + withContactSuggestions, } from './recipient-ranking'; const NOW = new Date('2026-10-27T12:00:00.000Z'); @@ -26,6 +28,28 @@ const sent = ( at: ago(days), }); +/** 사내 · 최근 줄 — 주소록 칸(company · emailLabel)은 null */ +const directoryItem = ( + name: string | null, + email: string, +): RecipientSuggestion => ({ + name, + email, + source: 'directory', + company: null, + emailLabel: null, +}); +const recentItem = ( + name: string | null, + email: string, +): RecipientSuggestion => ({ + name, + email, + source: 'recent', + company: null, + emailLabel: null, +}); + const rank = ( correspondence: Correspondence[], directory: { email: string; name: string }[] = [], @@ -49,17 +73,9 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => { ); expect(result).toEqual([ - { - name: '이도윤', - email: 'doyun.lee@arasystem.test', - source: 'directory', - }, - { - name: '박서준', - email: 'seojun.park@arasystem.test', - source: 'directory', - }, - { name: null, email: 'partner@vendor.example', source: 'recent' }, + directoryItem('이도윤', 'doyun.lee@arasystem.test'), + directoryItem('박서준', 'seojun.park@arasystem.test'), + recentItem(null, 'partner@vendor.example'), ]); }); @@ -118,9 +134,7 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => { [{ email: 'admin@arasystem.test', name: '관리자' }], ); - expect(result).toEqual([ - { name: null, email: 'kept@service.example', source: 'recent' }, - ]); + expect(result).toEqual([recentItem(null, 'kept@service.example')]); }); it('이름은 사내 목록의 이름, 없으면 가장 최근에 본 표시 이름을 쓴다', () => { @@ -135,12 +149,8 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => { ); expect(result).toEqual([ - { - name: '메일에 적힌 이름', - email: 'colleague@arasystem.test', - source: 'directory', - }, - { name: '새 이름', email: 'partner@vendor.example', source: 'recent' }, + directoryItem('메일에 적힌 이름', 'colleague@arasystem.test'), + recentItem('새 이름', 'partner@vendor.example'), ]); }); @@ -155,14 +165,100 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => { }); }); +describe('withContactSuggestions (주소록 묶음을 앞에)', () => { + const line = ( + name: string | null, + email: string, + overrides: Partial = {}, + ): ContactSuggestionLine => ({ + name, + email, + company: null, + emailLabel: 'work', + ...overrides, + }); + + it('주소록 → 사내 → 최근 — 주소록에 있는 주소는 뒤 묶음에서 빠진다(대소문자 무시), 회사 · 종류는 주소록 줄에만', () => { + const ranked = [ + directoryItem('이도윤', 'doyun.lee@arasystem.test'), + directoryItem('박서준', 'seojun.park@arasystem.test'), + recentItem('한빛', 'jihoon.park@hanbit.example'), + recentItem(null, 'vendor@supply.example'), + ]; + + const result = withContactSuggestions( + [ + line('박지훈', 'Jihoon.Park@hanbit.example', { company: '한빛상사' }), + line('박지훈', 'jihoon.home@mail.example', { + company: '한빛상사', + emailLabel: 'personal', + }), + line('이도윤', 'doyun.lee@arasystem.test'), + ], + ranked, + ME, + EXCLUDED, + ); + + expect(result).toEqual([ + { + name: '박지훈', + email: 'Jihoon.Park@hanbit.example', + source: 'contact', + company: '한빛상사', + emailLabel: 'work', + }, + { + name: '박지훈', + email: 'jihoon.home@mail.example', + source: 'contact', + company: '한빛상사', + emailLabel: 'personal', + }, + { + name: '이도윤', + email: 'doyun.lee@arasystem.test', + source: 'contact', + company: null, + emailLabel: 'work', + }, + directoryItem('박서준', 'seojun.park@arasystem.test'), + recentItem(null, 'vendor@supply.example'), + ]); + }); + + it('같은 주소가 연락처 둘에 있으면 앞의 것 하나 — 나 · 시스템 계정(관리자 등)은 주소록에 있어도 빼고, noreply 류는 직원이 넣은 것이라 둔다', () => { + const result = withContactSuggestions( + [ + line('거래처 A', 'sales@partner.example'), + line('거래처 B', 'SALES@partner.example'), + line('나', 'Ara.Kim@arasystem.test'), + line('관리자', 'admin@arasystem.test'), + line(null, 'no-reply@bank.example'), + line(' ', 'nameless@partner.example', { emailLabel: 'other' }), + ], + [], + ME, + EXCLUDED, + ); + + expect(result.map((item) => [item.name, item.email])).toEqual([ + ['거래처 A', 'sales@partner.example'], + [null, 'no-reply@bank.example'], + [null, 'nameless@partner.example'], + ]); + }); + + it('주소록이 비면 받은 목록 그대로', () => { + const ranked = [recentItem(null, 'vendor@supply.example')]; + expect(withContactSuggestions([], ranked, ME, EXCLUDED)).toEqual(ranked); + }); +}); + describe('filterRecipientSuggestions (q 로 거르기)', () => { const list = [ - { - name: '이도윤', - email: 'doyun.lee@arasystem.test', - source: 'directory' as const, - }, - { name: null, email: 'Partner@Vendor.example', source: 'recent' as const }, + directoryItem('이도윤', 'doyun.lee@arasystem.test'), + recentItem(null, 'Partner@Vendor.example'), ]; it('이름·주소에 그 글자가 든 것만 준다 (대소문자 무시, 앞뒤 공백 무시)', () => { @@ -172,12 +268,21 @@ describe('filterRecipientSuggestions (q 로 거르기)', () => { expect(filterRecipientSuggestions(list, '없는사람')).toEqual([]); }); - it('조합형(NFD)으로 친 한글도 찾는다 — 맥에서 붙여 넣은 글자', () => { - expect(filterRecipientSuggestions(list, '도윤'.normalize('NFD'))).toEqual([ - list[0], + it('쓰기 화면과 같은 맞추기 — 초성 · 조합 중 글자 · 완전한 주소의 아이디 (recipient-match.ts)', () => { + expect(filterRecipientSuggestions(list, 'ㄷㅇ')).toEqual([list[0]]); + expect(filterRecipientSuggestions(list, '도유')).toEqual([list[0]]); + expect(filterRecipientSuggestions(list, '동')).toEqual([list[0]]); + expect(filterRecipientSuggestions(list, 'partner@other.example')).toEqual([ + list[1], ]); }); + it('조합형(NFD)으로 친 한글은 같은 글자가 아니다 — 쓰기 화면과 같게 (받아 둔 것에서 찾은 것과 서버의 답이 같아야 한다)', () => { + expect(filterRecipientSuggestions(list, '도윤'.normalize('NFD'))).toEqual( + [], + ); + }); + it('q 가 없거나 공백뿐이면 그대로 준다', () => { expect(filterRecipientSuggestions(list, undefined)).toBe(list); expect(filterRecipientSuggestions(list, ' ')).toBe(list); diff --git a/backend/src/modules/mail/recipient-ranking.ts b/backend/src/modules/mail/recipient-ranking.ts index cb2d204..7480485 100644 --- a/backend/src/modules/mail/recipient-ranking.ts +++ b/backend/src/modules/mail/recipient-ranking.ts @@ -1,3 +1,5 @@ +import type { ContactSuggestionLine } from '@/modules/contacts/contacts.types'; +import { recipientMatcher } from './recipient-match'; import type { Correspondence, DirectoryContact, @@ -5,16 +7,20 @@ import type { } from './mail.types'; /** - * 받는 사람 추천 순서 (순수 함수) — 계약 2절 "받는 사람 추천". + * 받는 사람 추천 순서 (순수 함수) — 계약 2절 "받는 사람 추천" · 3절 "메일과 함께". * - * - 사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소 순. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다 - * - 묶음 안에서는 주고받은 횟수와 최근성으로 — 주고받을 때마다 점수를 더하되 오래된 것일수록 적게 더한다(반감기) - * - 나·메일 엔진 관리자·게이트웨이·막은 계정(excluded)·noreply 류는 뺀다 + * - 주소록(8단계) → 사내(엔진의 사내 목록 — 읽지 못하면 웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소 순. + * 여러 묶음에 있는 주소는 앞 묶음으로 한 번만 나온다 + * - 주소록은 연락처의 이름 차례(주소록이 정한 차례 그대로 — 대표 주소가 먼저), 사내 · 최근은 주고받은 횟수와 최근성으로 — + * 주고받을 때마다 점수를 더하되 오래된 것일수록 적게 더한다(반감기) + * - 나·메일 엔진 관리자·게이트웨이·사내 목록 계정·막은 계정(excluded)은 어느 묶음에서도 뺀다. noreply 류는 사내 · 최근에서만 뺀다 — + * 주소록은 직원이 넣은 것이라 그대로 둔다 (계약 3절 "메일과 함께") + * - q 로 거르기는 쓰기 화면과 같은 맞추기(recipient-match.ts — 초성 · 조합 중 글자 · 완전한 주소의 아이디) */ /** 최근성 반감기 — 30일 전에 주고받은 한 번은 오늘의 한 번의 절반으로 센다 */ const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000; -/** 한 사람에게 기억하는 추천 수 상한 */ +/** 한 사람에게 기억하는 추천 수 상한 (사내 · 최근 — 주소록 묶음은 기억하지 않고 요청마다 붙인다) */ export const MAX_RECIPIENT_SUGGESTIONS = 1000; /** 자동 발송 주소 — 답장을 받지 않는 주소는 추천하지 않는다 (noreply@ · no-reply+… · donotreply@ · mailer-daemon@) */ @@ -48,16 +54,25 @@ function cleanName(name: string | null | undefined): string | null { return trimmed ? trimmed : null; } -/** 추천할 수 있는 주소인가 — 형식(로컬@도메인), 나·제외 목록·noreply 류가 아닌지 */ -export function isSuggestable( +/** 받는 사람이 될 수 있는 주소인가 — 형식(로컬@도메인), 나·제외 목록(시스템 계정)이 아닌지 */ +function isAddressable( email: string, me: string, excluded: ReadonlySet, ): boolean { const at = email.lastIndexOf('@'); if (at <= 0 || at === email.length - 1) return false; - if (email === me || excluded.has(email)) return false; - return !NOREPLY_LOCAL_PART.test(email.slice(0, at)); + return email !== me && !excluded.has(email); +} + +/** 추천할 수 있는 주소인가 (사내 · 최근) — isAddressable 이고 noreply 류가 아닌지 */ +export function isSuggestable( + email: string, + me: string, + excluded: ReadonlySet, +): boolean { + if (!isAddressable(email, me, excluded)) return false; + return !NOREPLY_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@'))); } function tally( @@ -92,7 +107,7 @@ function tally( const byName = new Intl.Collator('ko'); -/** 사내 → 최근 순으로 줄 세운 추천 목록 (MAX_RECIPIENT_SUGGESTIONS 까지) */ +/** 사내 → 최근 순으로 줄 세운 추천 목록 (MAX_RECIPIENT_SUGGESTIONS 까지) — 주소록 묶음은 withContactSuggestions 가 앞에 붙인다 */ export function rankRecipientSuggestions( input: RankingInput, ): RecipientSuggestion[] { @@ -113,7 +128,13 @@ export function rankRecipientSuggestions( } const colleagues: RecipientSuggestion[] = [...directory] - .map(([email, name]) => ({ name, email, source: 'directory' as const })) + .map(([email, name]) => ({ + name, + email, + source: 'directory' as const, + company: null, + emailLabel: null, + })) .sort( (a, b) => scoreOf(b.email) - scoreOf(a.email) || @@ -136,26 +157,53 @@ export function rankRecipientSuggestions( name: entry.name, email, source: 'recent' as const, + company: null, + emailLabel: null, })); return [...colleagues, ...recent].slice(0, MAX_RECIPIENT_SUGGESTIONS); } -/** 대소문자·유니코드 정규형을 맞춘 비교용 글자 */ -function folded(text: string): string { - return text.normalize('NFC').toLowerCase(); +/** + * 주소록 묶음을 앞에 붙인다 — 연락처의 주소마다 한 줄(받은 차례 그대로 — 이름 차례, 대표 주소가 먼저). + * 주소록에 있는 주소는 뒤 묶음(사내 · 최근)에서 뺀다. 같은 주소가 연락처 둘에 있으면 앞의 것 하나만. + * 나와 시스템 계정(관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정 — excluded)만 주소록에 있어도 뺀다 — noreply 류는 직원이 넣은 것이라 그대로 둔다 + */ +export function withContactSuggestions( + contacts: readonly ContactSuggestionLine[], + ranked: readonly RecipientSuggestion[], + me: string, + excluded: ReadonlySet, +): RecipientSuggestion[] { + const self = normalizeEmail(me); + const seen = new Set(); + const lines: RecipientSuggestion[] = []; + for (const contact of contacts) { + const key = normalizeEmail(contact.email); + if (seen.has(key) || !isAddressable(key, self, excluded)) continue; + seen.add(key); + lines.push({ + name: cleanName(contact.name), + email: contact.email.trim(), + source: 'contact', + company: contact.company, + emailLabel: contact.emailLabel, + }); + } + if (lines.length === 0) return [...ranked]; + return [ + ...lines, + ...ranked.filter((item) => !seen.has(normalizeEmail(item.email))), + ]; } -/** 이름·주소에 q 가 든 것만 (대소문자 무시). q 가 없으면 그대로 */ +/** 이름·주소가 q 에 맞는 것만 — 쓰기 화면과 같은 맞추기(recipient-match.ts). q 가 없으면 그대로 */ export function filterRecipientSuggestions( suggestions: readonly RecipientSuggestion[], query: string | undefined, ): readonly RecipientSuggestion[] { - const q = query ? folded(query.trim()) : ''; + const q = query?.trim() ?? ''; if (q === '') return suggestions; - return suggestions.filter( - (suggestion) => - folded(suggestion.email).includes(q) || - (suggestion.name !== null && folded(suggestion.name).includes(q)), - ); + const matches = recipientMatcher(q); + return suggestions.filter(matches); } diff --git a/backend/src/modules/mail/recipient-suggestion-cursor.ts b/backend/src/modules/mail/recipient-suggestion-cursor.ts index 512e3c9..5d4cd80 100644 --- a/backend/src/modules/mail/recipient-suggestion-cursor.ts +++ b/backend/src/modules/mail/recipient-suggestion-cursor.ts @@ -1,45 +1,19 @@ -import { createHash } from 'crypto'; - import { BusinessException } from '@/common/errors/business.exception'; +import { + decodeOffsetCursor, + encodeOffsetCursor, +} from '@/shared/utils/offset-cursor'; /** * 받는 사람 추천 목록의 커서 (클라이언트에게는 불투명 문자열) — 걸러진 목록에서 다음 항목의 위치. - * 다른 검색어(q)의 커서를 섞어 쓰지 못하도록 검색어의 해시를 함께 넣는다 (jmap-cursor.ts 와 같은 방식). + * 다른 검색어(q)의 커서를 섞어 쓰지 못하도록 검색어의 해시를 함께 넣는다 (shared/utils/offset-cursor.ts — 주소록 목록과 같은 형식). * 목록은 서버가 10분 캐시하므로 그 안에서는 위치가 그대로다 — 다시 모으면 조금 어긋날 수 있다(추천이라 문제 삼지 않는다). */ -const CURSOR_VERSION = 1; -const MAX_CURSOR_LENGTH = 256; -const SCOPE_HASH_LENGTH = 16; - -interface EncodedCursor { - v: number; - o: number; - s: string; -} - -function scopeHash(query: string | undefined): string { - return createHash('sha256') - .update(query ?? '') - .digest('base64url') - .slice(0, SCOPE_HASH_LENGTH); -} - -function invalidCursor(reason: string): BusinessException { - return new BusinessException('VAL_001', { - context: { reason: `invalid-suggestion-cursor:${reason}` }, - }); -} - export function encodeSuggestionCursor( offset: number, query: string | undefined, ): string { - const payload: EncodedCursor = { - v: CURSOR_VERSION, - o: offset, - s: scopeHash(query), - }; - return Buffer.from(JSON.stringify(payload)).toString('base64url'); + return encodeOffsetCursor(offset, query ?? ''); } /** 커서를 해석한다 — 형식이 틀리거나 다른 검색어의 커서면 VAL_001 */ @@ -47,23 +21,11 @@ export function decodeSuggestionCursor( cursor: string, query: string | undefined, ): number { - if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long'); - - let parsed: unknown; - try { - parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); - } catch { - throw invalidCursor('not-json'); + const reading = decodeOffsetCursor(cursor, query ?? ''); + if ('problem' in reading) { + throw new BusinessException('VAL_001', { + context: { reason: `invalid-suggestion-cursor:${reading.problem}` }, + }); } - - const { v, o, s } = (parsed ?? {}) as Partial; - const valid = - v === CURSOR_VERSION && - typeof o === 'number' && - Number.isSafeInteger(o) && - o >= 0 && - typeof s === 'string'; - if (!valid) throw invalidCursor('malformed'); - if (s !== scopeHash(query)) throw invalidCursor('other-query'); - return o; + return reading.offset; } diff --git a/backend/src/modules/mail/recipient-suggestion.controller.ts b/backend/src/modules/mail/recipient-suggestion.controller.ts index ede8672..cc429c1 100644 --- a/backend/src/modules/mail/recipient-suggestion.controller.ts +++ b/backend/src/modules/mail/recipient-suggestion.controller.ts @@ -14,7 +14,7 @@ import { RecipientSuggestionPageDto } from './dto/mail-response.dto'; import { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto'; import { RecipientSuggestionService } from './recipient-suggestions.service'; -// 받는 사람 추천 (계약 2절 "받는 사람 추천") — 요청한 직원 자신의 메일함과 사내 목록에서만 모은다. +// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 3절 "메일과 함께") — 요청한 직원 자신의 주소록 · 메일함과 사내 목록에서만 모은다. @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) @@ -29,9 +29,14 @@ export class RecipientSuggestionController { @ApiOperation({ summary: '받는 사람 추천 (커서 페이지네이션)', description: - '사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소(보낸편지함 최근 500통의 받는 사람·참조·숨은참조, 받은편지함 최근 500통의 보낸 사람) 순, ' + - '묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다. ' + - 'q 가 있으면 이름·주소에 그 글자가 든 것만 (대소문자 무시). 서버는 사용자마다 10분 기억하고, 메일을 보내면 다시 모은다. Cache-Control: private, no-store.', + '내 주소록(source contact — 연락처의 주소마다 한 줄, 이름 차례 · 대표 주소가 먼저, company · emailLabel 포함) → ' + + '사내(엔진의 사내 목록의 직원 · 공용 주소 — 읽지 못하면 최근 90일 안에 웹메일에 로그인한 직원) → ' + + '최근 주고받은 주소(보낸편지함 최근 500통의 받는 사람·참조·숨은참조, 받은편지함 최근 500통의 보낸 사람) 순, ' + + '사내 · 최근 안에서는 주고받은 횟수와 최근성으로. 여러 묶음에 있는 주소는 앞 묶음으로 한 번만 나온다. 그룹은 넣지 않는다. ' + + '나·메일 엔진 관리자·게이트웨이·사내 목록 계정·막은 계정은 뺀다(noreply 류는 사내 · 최근에서만 — 주소록은 직원이 넣은 것이라 그대로). ' + + 'q 가 있으면 쓰기 화면과 같은 맞추기 — 이름은 초성 · 조합 중 글자를 섞어도(ㄷㅇ · 도ㅇ · 도유 · 동 → 이도윤), 주소는 그 글자가 든 것(완전한 주소면 같은 아이디도), 대소문자 무시. ' + + '서버는 사내 · 최근을 사용자마다 10분 기억하고 메일을 보내면 다시 모은다 — ' + + '주소록은 요청마다 엔진 상태를 확인한 주소록에서 붙인다(연락처를 바꾸면 바로 보인다). Cache-Control: private, no-store.', }) @ApiStandardResponse(RecipientSuggestionPageDto, { description: '추천 목록 (total 은 걸러진 개수)', diff --git a/backend/src/modules/mail/recipient-suggestions.service.spec.ts b/backend/src/modules/mail/recipient-suggestions.service.spec.ts index 13688c0..ec0e1d0 100644 --- a/backend/src/modules/mail/recipient-suggestions.service.spec.ts +++ b/backend/src/modules/mail/recipient-suggestions.service.spec.ts @@ -1,11 +1,21 @@ import { Logger } from '@nestjs/common'; import { BusinessException } from '@/common/errors/business.exception'; +import type { ContactsService } from '@/modules/contacts/contacts.service'; +import type { + ContactSuggestionLine, + DirectoryEntry, +} from '@/modules/contacts/contacts.types'; +import type { DirectoryService } from '@/modules/contacts/directory.service'; import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service'; import type { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto'; import type { MailProvider } from './mail.provider'; import type { MailSettings } from './mail.settings'; -import type { Correspondence, DirectoryContact } from './mail.types'; +import type { + Correspondence, + DirectoryContact, + RecipientSuggestion, +} from './mail.types'; import { RecipientSuggestionService, SUGGESTION_CACHE_MAX_USERS, @@ -21,7 +31,11 @@ const settings: MailSettings = { gatewayPassword: 'secret', attachmentMaxBytes: 20 * 1024 * 1024, attachmentTotalMaxBytes: 25 * 1024 * 1024, - blockedAccounts: new Set(['admin@arasystem.test']), + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', }; const NOW = Date.parse('2026-10-27T03:00:00Z'); @@ -32,14 +46,46 @@ const query = ( overrides: Partial = {}, ): RecipientSuggestionQueryDto => ({ limit: 20, ...overrides }); +const directoryItem = ( + name: string | null, + email: string, +): RecipientSuggestion => ({ + name, + email, + source: 'directory', + company: null, + emailLabel: null, +}); +const recentItem = ( + name: string | null, + email: string, +): RecipientSuggestion => ({ + name, + email, + source: 'recent', + company: null, + emailLabel: null, +}); + +/** 엔진의 사내 목록 한 줄 */ +const entry = ( + id: string, + name: string, + email: string, + kind: DirectoryEntry['kind'] = 'person', +): DirectoryEntry => ({ id, name, email, kind }); + describe('RecipientSuggestionService', () => { let listCorrespondents: jest.Mock< Promise, Parameters >; let listContacts: jest.Mock, [number?]>; + let suggestionLines: jest.Mock, [string]>; + let directoryEntries: jest.Mock, []>; let service: RecipientSuggestionService; let debugSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; beforeEach(() => { jest.spyOn(Date, 'now').mockReturnValue(NOW); @@ -59,6 +105,7 @@ describe('RecipientSuggestionService', () => { { name: null, email: 'admin@arasystem.test', at: daysAgo(0) }, { name: null, email: 'no-reply@service.test', at: daysAgo(0) }, ]); + // 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록 listContacts = jest .fn, [number?]>() .mockResolvedValue([ @@ -66,14 +113,30 @@ describe('RecipientSuggestionService', () => { { email: 'doyun.lee@arasystem.test', name: '이도윤' }, { email: 'ara.kim@arasystem.test', name: '김아라' }, ]); + // 엔진의 사내 목록 — 걸러 내기는 사내 목록 서비스가 이미 했다 + directoryEntries = jest + .fn, []>() + .mockResolvedValue([ + entry('b', '박서준', 'seojun.park@arasystem.test'), + entry('d', '이도윤', 'doyun.lee@arasystem.test'), + entry('a', '김아라', 'ara.kim@arasystem.test'), + ]); + suggestionLines = jest + .fn, [string]>() + .mockResolvedValue([]); service = new RecipientSuggestionService( { listCorrespondents } as unknown as MailProvider, settings, { listContacts } as unknown as MailDirectoryService, + { suggestionLines } as unknown as ContactsService, + { entries: directoryEntries } as unknown as DirectoryService, ); debugSpy = jest .spyOn(Logger.prototype, 'debug') .mockImplementation(() => undefined); + warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); }); afterEach(() => jest.restoreAllMocks()); @@ -83,19 +146,11 @@ describe('RecipientSuggestionService', () => { expect(page.items).toEqual([ // 사내 묶음 안에서는 주고받은 사람이 먼저 - { - name: '이도윤', - email: 'doyun.lee@arasystem.test', - source: 'directory', - }, - { - name: '박서준', - email: 'seojun.park@arasystem.test', - source: 'directory', - }, + directoryItem('이도윤', 'doyun.lee@arasystem.test'), + directoryItem('박서준', 'seojun.park@arasystem.test'), // 대소문자만 다른 주소는 한 사람 — 두 번 주고받았다 - { name: '고객사', email: 'client@partner.test', source: 'recent' }, - { name: null, email: 'vendor@supply.test', source: 'recent' }, + recentItem('고객사', 'client@partner.test'), + recentItem(null, 'vendor@supply.test'), ]); expect(page.pageInfo).toEqual({ hasNext: false, @@ -106,6 +161,144 @@ describe('RecipientSuggestionService', () => { sent: 500, received: 500, }); + // 사내는 엔진의 사내 목록 — 로그인 기록은 읽지 않는다 + expect(directoryEntries).toHaveBeenCalledTimes(1); + expect(listContacts).not.toHaveBeenCalled(); + }); + + it('주소록 → 사내 → 최근 — 주소록은 주소마다 한 줄(회사 · 종류), 주소록에 있는 주소는 뒤 묶음에서 빠진다', async () => { + suggestionLines.mockResolvedValue([ + { + name: '고객사 담당', + email: 'Client@Partner.test', + company: '파트너', + emailLabel: 'work', + }, + { + name: '고객사 담당', + email: 'home@partner.test', + company: '파트너', + emailLabel: 'personal', + }, + { + name: '이도윤', + email: 'doyun.lee@arasystem.test', + company: null, + emailLabel: 'work', + }, + ]); + + const page = await service.suggest(ARA, query()); + + expect(page.items).toEqual([ + { + name: '고객사 담당', + email: 'Client@Partner.test', + source: 'contact', + company: '파트너', + emailLabel: 'work', + }, + { + name: '고객사 담당', + email: 'home@partner.test', + source: 'contact', + company: '파트너', + emailLabel: 'personal', + }, + { + name: '이도윤', + email: 'doyun.lee@arasystem.test', + source: 'contact', + company: null, + emailLabel: 'work', + }, + directoryItem('박서준', 'seojun.park@arasystem.test'), + recentItem(null, 'vendor@supply.test'), + ]); + expect(page.pageInfo.total).toBe(5); + expect(suggestionLines).toHaveBeenCalledWith('ara.kim@arasystem.test'); + }); + + it('연락처를 바꾸면 다음 추천에 바로 보인다 — 주소록 묶음은 요청마다 붙이고, 최근 주고받은 주소는 다시 모으지 않는다', async () => { + await service.suggest(ARA, query()); + suggestionLines.mockResolvedValue([ + { + name: '새 거래처', + email: 'new@client.test', + company: null, + emailLabel: 'other', + }, + ]); + + const page = await service.suggest(ARA, query({ q: 'client.test' })); + + expect(page.items.map((item) => [item.source, item.email])).toEqual([ + ['contact', 'new@client.test'], + ]); + expect(suggestionLines).toHaveBeenCalledTimes(2); + expect(listCorrespondents).toHaveBeenCalledTimes(1); + }); + + it('주소록을 읽지 못하면 그 묶음만 빼고 답한다 — warn 로그(주소 없이)', async () => { + suggestionLines.mockRejectedValue( + new BusinessException('MAIL_001', { + context: { reason: 'http-error' }, + }), + ); + + const page = await service.suggest(ARA, query()); + + expect(page.pageInfo.total).toBe(4); + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain('주소록을 읽지 못해'); + expect(logged).toContain('MAIL_001/http-error'); + expect(logged).not.toContain('ara.kim@'); + }); + + it('사내 목록을 읽지 못하면 로그인한 직원 기록으로 대신한다 — 알림은 10분에 한 번', async () => { + directoryEntries.mockRejectedValue( + new BusinessException('MAIL_001', { + context: { reason: 'directory-account-missing' }, + }), + ); + + const page = await service.suggest(ARA, query()); + expect(page.items.slice(0, 2)).toEqual([ + directoryItem('이도윤', 'doyun.lee@arasystem.test'), + directoryItem('박서준', 'seojun.park@arasystem.test'), + ]); + expect(listContacts).toHaveBeenCalledTimes(1); + + // 다른 직원이 모아도 10분 안에는 다시 알리지 않는다 + await service.suggest( + { email: 'doyun.lee@arasystem.test', name: '이도윤' }, + query(), + ); + const fallbackWarnings = () => + (warnSpy.mock.calls as unknown[][]) + .map(([message]) => String(message)) + .filter((message) => + message.includes('로그인한 직원 기록으로 대신합니다'), + ); + expect(listContacts).toHaveBeenCalledTimes(2); + expect(fallbackWarnings()).toHaveLength(1); + expect(fallbackWarnings()[0]).toContain( + 'MAIL_001/directory-account-missing', + ); + }); + + it('사내 목록의 공용 주소도 사내로 추천한다', async () => { + directoryEntries.mockResolvedValue([ + entry('s', '영업팀', 'sales@arasystem.test', 'shared'), + ]); + + const page = await service.suggest(ARA, query({ q: 'sales' })); + + expect(page.items).toEqual([ + directoryItem('영업팀', 'sales@arasystem.test'), + ]); }); it('커서 페이지네이션 — 걸러진 목록의 다음 위치, total 은 걸러진 개수', async () => { @@ -165,7 +358,8 @@ describe('RecipientSuggestionService', () => { expect((error as BusinessException).code).toBe('VAL_001'); } expect(listCorrespondents).not.toHaveBeenCalled(); - expect(listContacts).not.toHaveBeenCalled(); + expect(directoryEntries).not.toHaveBeenCalled(); + expect(suggestionLines).not.toHaveBeenCalled(); }); it('사용자마다 10분 기억한다 — 그 안에서는 다시 모으지 않고, 지나면 다시 모은다', async () => { diff --git a/backend/src/modules/mail/recipient-suggestions.service.ts b/backend/src/modules/mail/recipient-suggestions.service.ts index 1bb5e9a..9c5e2c1 100644 --- a/backend/src/modules/mail/recipient-suggestions.service.ts +++ b/backend/src/modules/mail/recipient-suggestions.service.ts @@ -5,8 +5,14 @@ import { type PaginatedResult, toPaginatedResult, } from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { DIRECTORY_FAILURE_LOG_INTERVAL_MS } from '@/modules/contacts/contacts.constants'; +import { ContactsService } from '@/modules/contacts/contacts.service'; +import type { ContactSuggestionLine } from '@/modules/contacts/contacts.types'; +import { DirectoryService } from '@/modules/contacts/directory.service'; import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service'; import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; import type { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto'; import { SUGGESTION_RECEIVED_SCAN, @@ -14,10 +20,11 @@ import { } from './mail.constants'; import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; import { MAIL_SETTINGS, type MailSettings } from './mail.settings'; -import type { RecipientSuggestion } from './mail.types'; +import type { DirectoryContact, RecipientSuggestion } from './mail.types'; import { filterRecipientSuggestions, rankRecipientSuggestions, + withContactSuggestions, } from './recipient-ranking'; import { decodeSuggestionCursor, @@ -50,11 +57,21 @@ function normalizeUser(email: string): string { return email.trim().toLowerCase(); } +/** 로그용 실패 요약 — 코드와 사유만 (엔진의 설명 문구 · 주소는 넣지 않는다) */ +function failureSummary(error: BusinessException): string { + const reason = error.context?.reason; + return typeof reason === 'string' ? `${error.code}/${reason}` : error.code; +} + /** - * 받는 사람 추천 (계약 2절 "받는 사람 추천") — 사내 목록 + 그 사람이 최근 주고받은 주소. + * 받는 사람 추천 (계약 2절 "받는 사람 추천" · 3절 "메일과 함께") — 내 주소록 → 사내 목록 → 그 사람이 최근 주고받은 주소. * * - 주고받은 주소는 **요청한 직원 자신의** 메일함에서만 모은다 (대리 접근이 그 사람으로만 들어간다). 다른 직원의 것과 섞지 않는다 - * - 사용자마다 10분 기억하고, 그 사람이 메일을 보내면 잊는다 — 다음 요청에 다시 모은다 + * - 사내는 엔진의 사내 목록(contacts/directory.service.ts — 모든 직원이 함께 10분 기억), 읽지 못하면 로그인한 직원 기록(최근 90일)으로 대신한다 + * - 사내 · 최근은 사용자마다 10분 기억하고, 그 사람이 메일을 보내면 잊는다 — 다음 요청에 다시 모은다 + * - 주소록 묶음은 기억하지 않고 요청마다 붙인다 — 주소록 모듈이 판(엔진 상태)을 확인한 주소록에서 만든다. 그래서 연락처를 바꾸면 + * (만들기 · 고치기 · 지우기 · 그룹 구성원) 다음 추천에 바로 보인다(계약의 "추천 캐시를 지운다"와 같은 결과 — 최근 주고받은 주소는 다시 모으지 않는다). + * 주소록을 읽지 못하면 그 묶음만 빼고 답한다 * - 로그에는 개수만 남긴다 — 주소·검색어는 남기지 않는다 * * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 따로 기억한다 (틀리지는 않고, 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 줄 수 있다). @@ -66,11 +83,16 @@ export class RecipientSuggestionService { /** 모으는 중인 목록 — 같은 사람의 동시 요청은 한 번만 모은다. 끝나면 빠진다 (줄지 않는 표를 두지 않는다) */ private readonly collecting = new Map(); private readonly excluded: ReadonlySet; + /** 사내 목록을 읽지 못해 로그인 기록으로 대신했다고 마지막으로 알린 때 — 계정이 없는 동안 모을 때마다 남기지 않게 */ + private lastDirectoryFallbackReportAt = Number.NEGATIVE_INFINITY; constructor( @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, @Inject(MAIL_SETTINGS) settings: MailSettings, - private readonly directory: MailDirectoryService, + /** 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록 */ + private readonly loginDirectory: MailDirectoryService, + private readonly contacts: ContactsService, + private readonly directory: DirectoryService, ) { this.excluded = new Set([ ...settings.blockedAccounts, @@ -86,8 +108,12 @@ export class RecipientSuggestionService { // 커서는 엔진에 묻기 전에 확인한다 — 틀린 커서로 목록을 모으지 않게 const start = query.cursor ? decodeSuggestionCursor(query.cursor, q) : 0; + const [ranked, contactLines] = await Promise.all([ + this.suggestionsOf(user.email), + this.contactLinesOf(user.email), + ]); const matched = filterRecipientSuggestions( - await this.suggestionsOf(user.email), + withContactSuggestions(contactLines, ranked, user.email, this.excluded), q, ); const items = matched.slice(start, start + query.limit); @@ -142,7 +168,7 @@ export class RecipientSuggestionService { sent: SUGGESTION_SENT_SCAN, received: SUGGESTION_RECEIVED_SCAN, }), - this.directory.listContacts(), + this.colleagues(), ]); const items = rankRecipientSuggestions({ me: user, @@ -164,6 +190,48 @@ export class RecipientSuggestionService { return items; } + /** 주소록 묶음 — 읽지 못하면(엔진 장애 등) 그 묶음만 빼고 warn 로그를 남긴다 */ + private async contactLinesOf( + userEmail: string, + ): Promise { + try { + return await this.contacts.suggestionLines(userEmail); + } catch (error) { + if (!(error instanceof BusinessException)) throw error; + this.logger.warn( + `[${error.code}]${traceTag()} 주소록을 읽지 못해 받는 사람 추천에서 주소록 묶음을 뺐습니다: user=${maskEmail(userEmail)}, 원인=${failureSummary(error)}`, + ); + return []; + } + } + + /** 사내 — 엔진의 사내 목록, 읽지 못하면(사내 목록 계정이 없음 · 권한 · 엔진 장애) 로그인한 직원 기록 */ + private async colleagues(): Promise { + try { + const entries = await this.directory.entries(); + return entries.map(({ email, name }) => ({ email, name })); + } catch (error) { + if (!(error instanceof BusinessException)) throw error; + this.reportDirectoryFallback(error); + return this.loginDirectory.listContacts(); + } + } + + private reportDirectoryFallback(error: BusinessException): void { + const now = Date.now(); + if ( + now - this.lastDirectoryFallbackReportAt < + DIRECTORY_FAILURE_LOG_INTERVAL_MS + ) { + return; + } + this.lastDirectoryFallbackReportAt = now; + this.logger.warn( + `[${error.code}]${traceTag()} 사내 목록을 읽지 못해 받는 사람 추천의 "사내"를 로그인한 직원 기록으로 대신합니다 — ` + + `사내 목록 계정(MAIL_DIRECTORY_LOCAL_PART)과 메일 엔진 설정(mail/scripts/setup.mjs)을 확인하세요. 원인=${failureSummary(error)} (같은 알림은 10분에 한 번)`, + ); + } + private remember(user: string, items: readonly RecipientSuggestion[]): void { const now = Date.now(); // 만료된 목록은 그때그때 지운다 — 다시 오지 않은 사람의 목록이 상한까지 쌓여 메모리를 잡지 않게 diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index b7c0224..988f083 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -279,6 +279,80 @@ describe('validateEnv', () => { } }); + it('사내 목록 계정 아이디(MAIL_DIRECTORY_LOCAL_PART)는 선택 — 없거나 비면 기본값, 관리자 아이디와 같은 모양 규칙', () => { + for (const value of ['', ' ', 'mail-directory', 'Company.Directory']) { + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: value, + }), + ).not.toThrow(); + } + for (const value of ['mail-directory@arasystem.test', '@dir', 'dir@']) { + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: value, + }), + ).toThrow( + /MAIL_DIRECTORY_LOCAL_PART 는 주소가 아니라 앞부분\(아이디\)만/, + ); + } + for (const value of [ + 'dir%mail-gateway', + 'mail directory', + '.dir', + 'dir.', + '사내목록', + 'a'.repeat(65), + ]) { + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: value, + }), + ).toThrow(/MAIL_DIRECTORY_LOCAL_PART 는 영문·숫자/); + } + }); + + it('사내 목록 계정 아이디가 관리자 · 게이트웨이 아이디와 같으면 기동을 실패시킨다 — 비면 기본값으로, 대소문자 없이 견준다', () => { + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: 'Admin', + }), + ).toThrow(/관리자 아이디\(MAIL_ADMIN_LOCAL_PART\)와 같습니다/); + // 사내 목록 계정 아이디를 비워 두면 기본값(mail-directory)으로 견준다 + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_ADMIN_LOCAL_PART: 'Mail-Directory', + }), + ).toThrow(/관리자 아이디\(MAIL_ADMIN_LOCAL_PART\)와 같습니다/); + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: 'mail-gateway', + MAIL_GATEWAY_USER: 'Mail-Gateway@arasystem.test', + }), + ).toThrow(/게이트웨이 아이디\(MAIL_GATEWAY_USER\)와 같습니다/); + expect(() => + validateEnv( + prod({ MAIL_GATEWAY_USER: 'mail-directory@arasystem.it.kr' }), + ), + ).toThrow(/게이트웨이 아이디\(MAIL_GATEWAY_USER\)와 같습니다/); + + expect(() => + validateEnv({ + NODE_ENV: 'development', + MAIL_DIRECTORY_LOCAL_PART: 'company-directory', + MAIL_ADMIN_LOCAL_PART: 'mail-admin', + MAIL_GATEWAY_USER: 'mail-gateway@arasystem.test', + }), + ).not.toThrow(); + expect(() => validateEnv(prod())).not.toThrow(); + }); + it('로그인을 막는 계정 목록(MAIL_BLOCKED_ACCOUNTS)은 주소 전체여야 한다 — 아이디만 쓰면 아무것도 막지 못한다', () => { for (const value of [ 'admin', diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index 3d51093..4a14a7c 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -14,7 +14,11 @@ import { REFRESH_TTL_MAX_SECONDS, } from '@/modules/auth/auth.constants'; import { isLocalPart } from '@/modules/auth/employee-address'; -import { parseAccountList } from '@/modules/mail/mail.settings'; +import { + DEFAULT_MAIL_ADMIN_LOCAL_PART, + DEFAULT_MAIL_DIRECTORY_LOCAL_PART, + parseAccountList, +} from '@/modules/mail/mail.settings'; import { parseDurationSeconds } from '@/shared/utils/duration'; import { isPositiveIntString } from '@/shared/utils/env'; import { parseOriginList } from '@/shared/utils/request-origin'; @@ -265,6 +269,51 @@ function isAccountAddress(value: string): boolean { ); } +/** + * 사내 목록 계정 아이디(선택, 기본 mail-directory) — 백엔드는 `<아이디>@` 으로 엔진의 계정 목록을 읽고 그 주소의 로그인을 막는다 + * (mail.settings.ts). 메일 엔진 설정 스크립트가 같은 env 로 그 계정을 만든다(같은 규칙). 관리자 · 게이트웨이와 같은 아이디면 + * 그 계정의 권한이 섞이므로 기동을 막는다 — 비교는 실제로 쓰는 값(비면 기본값)으로, 소문자로 한다 + */ +function validateDirectoryLocalPart( + config: Record, + adminLocalPart: string | undefined, + gatewayUser: string | undefined, + errors: string[], +): void { + const raw = readString(config, 'MAIL_DIRECTORY_LOCAL_PART'); + if (raw !== undefined && !isLocalPart(raw)) { + errors.push( + raw.includes('@') + ? `MAIL_DIRECTORY_LOCAL_PART 는 주소가 아니라 앞부분(아이디)만 씁니다 — '@' 와 도메인을 빼 주세요. (예: mail-directory, 현재 값: "${raw}")` + : `MAIL_DIRECTORY_LOCAL_PART 는 영문·숫자·'.'·'_'·'-' 로 된 아이디여야 합니다 (앞뒤는 영문·숫자, 64자 이하). (예: mail-directory, 현재 값: "${raw}")`, + ); + return; + } + + const directory = (raw ?? DEFAULT_MAIL_DIRECTORY_LOCAL_PART).toLowerCase(); + // 관리자 아이디가 형식부터 틀렸으면 그 오류만 알린다 (위에서 이미 알렸다) + if (adminLocalPart === undefined || isLocalPart(adminLocalPart)) { + const admin = ( + adminLocalPart ?? DEFAULT_MAIL_ADMIN_LOCAL_PART + ).toLowerCase(); + if (directory === admin) { + errors.push( + `MAIL_DIRECTORY_LOCAL_PART(${directory})가 관리자 아이디(MAIL_ADMIN_LOCAL_PART)와 같습니다. 서로 다른 아이디를 쓰세요.`, + ); + } + } + const at = gatewayUser?.lastIndexOf('@') ?? -1; + const gatewayLocal = + gatewayUser !== undefined && at > 0 + ? gatewayUser.slice(0, at).toLowerCase() + : null; + if (gatewayLocal !== null && directory === gatewayLocal) { + errors.push( + `MAIL_DIRECTORY_LOCAL_PART(${directory})가 게이트웨이 아이디(MAIL_GATEWAY_USER)와 같습니다. 서로 다른 아이디를 쓰세요.`, + ); + } +} + /** * 메일 설정 형식 검사 (값이 있을 때만, 모든 환경). * 형식이 틀리면 기동은 되는데 로그인이 전부 실패하는 조용한 장애가 되므로 먼저 막는다. @@ -306,6 +355,8 @@ function validateMailSettings( ); } + validateDirectoryLocalPart(config, adminLocalPart, gatewayUser, errors); + // 막는 계정은 주소 전체로 비교한다 — 아이디(admin)만 쓰면 아무것도 막지 못하는 조용한 설정 실수가 된다 const invalidBlocked = parseAccountList( readString(config, 'MAIL_BLOCKED_ACCOUNTS'), diff --git a/backend/src/shared/utils/offset-cursor.spec.ts b/backend/src/shared/utils/offset-cursor.spec.ts new file mode 100644 index 0000000..31b4bda --- /dev/null +++ b/backend/src/shared/utils/offset-cursor.spec.ts @@ -0,0 +1,52 @@ +import { decodeOffsetCursor, encodeOffsetCursor } from './offset-cursor'; + +const encoded = (payload: unknown) => + Buffer.from(JSON.stringify(payload)).toString('base64url'); + +describe('위치(offset) 커서', () => { + it('같은 범위로 되돌리면 위치가 그대로다', () => { + for (const scope of [ + '', + '도윤', + JSON.stringify(['contacts', 'ㅂㅈㅎ', 'g1']), + ]) { + expect(decodeOffsetCursor(encodeOffsetCursor(40, scope), scope)).toEqual({ + offset: 40, + }); + } + }); + + it('다른 범위의 커서는 other-query — 걸러진 목록이 달라 위치가 맞지 않는다', () => { + const cursor = encodeOffsetCursor( + 20, + JSON.stringify(['contacts', null, 'g1']), + ); + expect( + decodeOffsetCursor(cursor, JSON.stringify(['contacts', null, 'g2'])), + ).toEqual({ problem: 'other-query' }); + expect(decodeOffsetCursor(cursor, '')).toEqual({ problem: 'other-query' }); + }); + + it('형식이 틀리면 그 까닭 — 너무 김 · JSON 아님 · 모르는 판 · 음수 · 소수 · 문자열 위치 · 범위 없음', () => { + const { s } = JSON.parse( + Buffer.from(encodeOffsetCursor(0, ''), 'base64url').toString('utf8'), + ) as { s: string }; + + expect(decodeOffsetCursor('x'.repeat(257), '')).toEqual({ + problem: 'too-long', + }); + expect(decodeOffsetCursor('@@@', '')).toEqual({ problem: 'not-json' }); + for (const payload of [ + null, + { v: 2, o: 0, s }, + { v: 1, o: -1, s }, + { v: 1, o: 1.5, s }, + { v: 1, o: '1', s }, + { v: 1, o: 1 }, + ]) { + expect(decodeOffsetCursor(encoded(payload), '')).toEqual({ + problem: 'malformed', + }); + } + }); +}); diff --git a/backend/src/shared/utils/offset-cursor.ts b/backend/src/shared/utils/offset-cursor.ts new file mode 100644 index 0000000..ab1bc8c --- /dev/null +++ b/backend/src/shared/utils/offset-cursor.ts @@ -0,0 +1,72 @@ +import { createHash } from 'crypto'; + +/** + * 위치(offset) 커서 (순수 함수) — 서버가 기억해 두거나 그 자리에서 거른 목록에서 다음 항목의 위치를 클라이언트에게 불투명 문자열로 준다. + * 다른 목록(검색어 · 그룹 등)의 커서를 섞어 쓰지 못하도록 목록 범위(scope)의 해시를 함께 넣는다 — 범위는 부르는 쪽이 정한다. + * 받는 사람 추천(modules/mail/recipient-suggestion-cursor.ts)과 주소록 · 사내 목록(modules/contacts)이 쓴다. + * + * 목록이 그사이 바뀌면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다 (계약 3절 — 위치만 기억한다). + * 해시에는 검색어가 들어간다 — 요청 주소 로그에서는 cursor 를 길이만 남긴다 (mask.ts 의 maskUrlForLog) + */ +const CURSOR_VERSION = 1; +const MAX_CURSOR_LENGTH = 256; +const SCOPE_HASH_LENGTH = 16; + +/** 해석하지 못한 까닭 — 너무 김 · JSON 아님 · 모양이 틀림 · 다른 범위의 커서 */ +export type OffsetCursorProblem = + | 'too-long' + | 'not-json' + | 'malformed' + | 'other-query'; + +export type OffsetCursorReading = + | { readonly offset: number } + | { readonly problem: OffsetCursorProblem }; + +interface EncodedCursor { + v: number; + o: number; + s: string; +} + +function scopeHash(scope: string): string { + return createHash('sha256') + .update(scope) + .digest('base64url') + .slice(0, SCOPE_HASH_LENGTH); +} + +export function encodeOffsetCursor(offset: number, scope: string): string { + const payload: EncodedCursor = { + v: CURSOR_VERSION, + o: offset, + s: scopeHash(scope), + }; + return Buffer.from(JSON.stringify(payload)).toString('base64url'); +} + +/** 커서를 해석한다 — 형식이 틀리거나 다른 범위의 커서면 그 까닭 (부르는 쪽이 VAL_001 로 바꾼다) */ +export function decodeOffsetCursor( + cursor: string, + scope: string, +): OffsetCursorReading { + if (cursor.length > MAX_CURSOR_LENGTH) return { problem: 'too-long' }; + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + return { problem: 'not-json' }; + } + + const { v, o, s } = (parsed ?? {}) as Partial; + const valid = + v === CURSOR_VERSION && + typeof o === 'number' && + Number.isSafeInteger(o) && + o >= 0 && + typeof s === 'string'; + if (!valid) return { problem: 'malformed' }; + if (s !== scopeHash(scope)) return { problem: 'other-query' }; + return { offset: o }; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index bd16bf1..46f397d 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -15,6 +15,19 @@ import { AppModule } from '@/app.module'; import { configureApp } from '@/app.setup'; import { BusinessException } from '@/common/errors/business.exception'; import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings'; +import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants'; +import { + CONTACTS_PROVIDER, + DIRECTORY_PROVIDER, + type AddressBookCard, + type AddressBookChanges, + type AddressBookContactCard, + type AddressBookGroupCard, + type AddressBookSnapshot, + type AddressBookWriteOutcome, + type DirectoryAccount, + type RemovedCardOriginal, +} from '@/modules/contacts/contacts.provider'; import { MAX_BODY_HTML_LENGTH, MAX_BODY_LENGTH, @@ -125,6 +138,9 @@ const CORRECT_PASSWORD = 'correct-password-for-e2e'; /** 메일 엔진 관리자 주소 — 로그인할 수 없는 계정 (mail.settings.ts 의 blockedAccounts) */ const ADMIN_ACCOUNT = 'admin@e2e.arasystem.test'; +/** 사내 목록 계정 — 로그인할 수 없고 엔진의 계정 목록을 읽는 데만 쓴다 (mail.settings.ts 의 directoryAccount — 8단계) */ +const DIRECTORY_ACCOUNT = 'mail-directory@e2e.arasystem.test'; + /** 대역 메일 엔진이 테스트가 문을 열 때까지 답하지 않는 주소 — 동시 로그인 시도 확인용 ("로그인 실패 한도") */ const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test'; @@ -322,9 +338,20 @@ const TEST_MAIL_SETTINGS: MailSettings = { gatewayPassword: null, attachmentMaxBytes: 20 * 1024 * 1024, attachmentTotalMaxBytes: 25 * 1024 * 1024, - blockedAccounts: new Set([ADMIN_ACCOUNT]), + blockedAccounts: new Set([ADMIN_ACCOUNT, DIRECTORY_ACCOUNT]), + directoryAccount: DIRECTORY_ACCOUNT, }; +/** 목록 응답의 data — 계약의 { items, pageInfo } */ +interface Page { + items: T[]; + pageInfo: { + hasNext: boolean; + nextCursor: string | null; + total: number | null; + }; +} + describe('응답 계약 (e2e)', () => { let app: NestExpressApplication; let server: App; @@ -393,6 +420,144 @@ describe('응답 계약 (e2e)', () => { closed: boolean; }[] = []; + /** + * 8단계 — 대역 엔진의 주소록(직원마다 하나) — 판(state)이 같을 때만 쓰고 쓸 때마다 판이 오른다 (ifInState). + * 카드 모양(JSContact) · 엔진의 거절은 jmap-contacts.provider.spec.ts 가 본다 + */ + const addressBooks = new Map< + string, + { + state: number; + contacts: Map; + groups: Map; + } + >(); + let addressBookSequence = 0; + const addressBookOf = (user: string) => { + let book = addressBooks.get(user); + if (!book) { + book = { state: 1, contacts: new Map(), groups: new Map() }; + addressBooks.set(user, book); + } + return book; + }; + const fakeContactsProvider = { + getAddressBookVersion: (user: string) => + Promise.resolve(`v${addressBookOf(user).state}`), + readAddressBook: (user: string): Promise => { + const book = addressBookOf(user); + return Promise.resolve({ + version: `v${book.state}`, + contacts: [...book.contacts.values()], + groups: [...book.groups.values()], + }); + }, + writeAddressBook: ( + user: string, + version: string, + changes: AddressBookChanges, + ): Promise => { + const book = addressBookOf(user); + if (version !== `v${book.state}`) { + return Promise.resolve({ kind: 'conflict' }); + } + const createdIds = new Map(); + const cards = new Map(); + const removed = new Map(); + for (const id of changes.destroy) { + const card = book.contacts.get(id) ?? book.groups.get(id); + if (card) { + const source = JSON.stringify(card); + removed.set(id, { source, bytes: Buffer.byteLength(source) }); + } + } + for (const card of changes.create) { + addressBookSequence += 1; + const id = `card${addressBookSequence}`; + createdIds.set(card.key, id); + if (card.kind === 'contact') { + const stored = { id, uid: card.uid, ...card.fields }; + book.contacts.set(id, stored); + cards.set(id, { kind: 'contact', card: stored }); + } else if (card.kind === 'group') { + const stored = { + id, + uid: card.uid, + name: card.name, + memberUids: [...card.memberUids], + }; + book.groups.set(id, stored); + cards.set(id, { kind: 'group', card: stored }); + } else if (card.card.kind === 'contact') { + const stored = { ...card.card.card, id, uid: card.uid }; + book.contacts.set(id, stored); + cards.set(id, { kind: 'contact', card: stored }); + } else { + const stored = { + ...card.card.card, + id, + uid: card.uid, + memberUids: [...(card.memberUids ?? [])], + }; + book.groups.set(id, stored); + cards.set(id, { kind: 'group', card: stored }); + } + } + for (const update of changes.update) { + if (update.kind === 'contact') { + const current = book.contacts.get(update.id); + if (current) { + const stored = { + ...current, + ...Object.fromEntries( + Object.entries(update.fields).filter( + ([, value]) => value !== undefined, + ), + ), + }; + book.contacts.set(update.id, stored); + cards.set(update.id, { kind: 'contact', card: stored }); + } + } else { + const current = book.groups.get(update.id); + if (current) { + book.groups.set(update.id, { + ...current, + name: update.name ?? current.name, + memberUids: update.memberUids + ? [...update.memberUids] + : current.memberUids, + }); + } + } + } + for (const id of changes.destroy) { + book.contacts.delete(id); + book.groups.delete(id); + } + book.state += 1; + return Promise.resolve({ + kind: 'written', + version: `v${book.state}`, + createdIds, + cards, + removed, + }); + }, + }; + /** 8단계 — 대역 엔진의 계정 목록. null 이면 읽지 못한다(사내 목록 계정이 없음 — 받는 사람 추천은 로그인 기록으로 대신한다) */ + let directoryAccounts: DirectoryAccount[] | null = null; + const fakeDirectoryProvider = { + listAccounts: () => + directoryAccounts + ? Promise.resolve(directoryAccounts) + : Promise.reject( + new BusinessException('MAIL_001', { + context: { reason: 'e2e-directory-account-missing' }, + }), + ), + }; + /** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */ const sign = (payload: object, expiresIn: number) => jwt.sign(payload, { @@ -817,6 +982,10 @@ describe('응답 계약 (e2e)', () => { }) .overrideProvider(MAIL_SETTINGS) .useValue(TEST_MAIL_SETTINGS) + .overrideProvider(CONTACTS_PROVIDER) + .useValue(fakeContactsProvider) + .overrideProvider(DIRECTORY_PROVIDER) + .useValue(fakeDirectoryProvider) .compile(); // CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다) @@ -1107,6 +1276,17 @@ describe('응답 계약 (e2e)', () => { expect(engineChecks.has(ADMIN_ACCOUNT)).toBe(false); }); + it('사내 목록 계정(8단계)도 로그인할 수 없다 — 401 AUTH_004 이고 메일 엔진에 묻지 않는다', async () => { + const res = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '203.0.113.21') + .send({ email: DIRECTORY_ACCOUNT, password: CORRECT_PASSWORD }) + .expect(401); + + expect(body(res).error?.code).toBe('AUTH_004'); + expect(engineChecks.has(DIRECTORY_ACCOUNT)).toBe(false); + }); + it('쿠키 없이 /auth/me 는 401 AUTH_001', async () => { const res = await request(server).get('/api/auth/me').expect(401); @@ -1888,7 +2068,13 @@ describe('응답 계약 (e2e)', () => { .query(params) .set('Cookie', cookieOf(ME)); interface SuggestionPage { - items: { name: string | null; email: string; source: string }[]; + items: { + name: string | null; + email: string; + source: string; + company: string | null; + emailLabel: string | null; + }[]; pageInfo: { hasNext: boolean; nextCursor: string | null; @@ -1914,7 +2100,7 @@ describe('응답 계약 (e2e)', () => { ]); }); - it('사내(로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => { + it('사내(사내 목록을 읽지 못하면 로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => { // 개발 DB 의 사내 목록에는 다른 직원도 있다 — 이 테스트의 도메인만 거른다 const res = await suggest({ q: 'e2e.arasystem.test', @@ -1928,6 +2114,8 @@ describe('응답 계약 (e2e)', () => { name: '가나다', email: COLLEAGUE_A, source: 'directory', + company: null, + emailLabel: null, }); // 주고받은 적 있는 동료가 사내 묶음에서 먼저, 두 쪽에 다 있어도 사내로 한 번만 expect(emails.indexOf(COLLEAGUE_B)).toBeLessThan( @@ -1938,6 +2126,8 @@ describe('응답 계약 (e2e)', () => { name: '협력사', email: 'partner@vendor.e2e.arasystem.test', source: 'recent', + company: null, + emailLabel: null, }); expect(emails).not.toContain(ME); expect(emails).not.toContain('mail-gateway@e2e.arasystem.test'); @@ -3466,6 +3656,726 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 8단계 — 주소록 (계약 3절). 엔진 쪽 판단(JSContact 카드 모양 · ifInState · 계정 목록 읽기)은 jmap-contacts.provider.spec.ts · + * contact-card.spec.ts · jmap-directory.provider.spec.ts, 찾기 · 차례 · 한도 · 그룹 정리는 contacts.service.spec.ts 가 본다 — + * 여기서는 라우트 · 검증(VAL_001) · 상태 코드 · 에러 코드 · 응답 형식 · 머리글을 본다 + */ + describe('8단계 — 주소록 (/api/contacts · /api/contact-groups · /api/directory-entries)', () => { + const ME = 'contacts.me@e2e.arasystem.test'; + interface ContactBody { + id: string; + name: string | null; + emails: { address: string; label: string }[]; + phones: { number: string; label: string }[]; + company: string | null; + department: string | null; + title: string | null; + note: string | null; + groupIds: string[]; + } + interface GroupBody { + id: string; + name: string; + memberCount: number; + } + interface DirectoryEntryBody { + id: string; + name: string; + email: string; + kind: string; + } + const contactInput = (overrides: Record = {}) => ({ + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.e2e.example', label: 'work' }], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '견적 담당', + groupIds: [], + ...overrides, + }); + const asUser = (email: string) => cookieOf(email); + const get = (path: string, user = ME) => + request(server).get(path).set('Cookie', asUser(user)); + const post = (path: string, user = ME) => + request(server).post(path).set('Cookie', asUser(user)); + const patch = (path: string, user = ME) => + request(server).patch(path).set('Cookie', asUser(user)); + const del = (path: string, user = ME) => + request(server).delete(path).set('Cookie', asUser(user)); + const expectCode = (res: Response, code: string) => { + expect(body(res).success).toBe(false); + expect(body(res).error?.code).toBe(code); + expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); + }; + + it('로그인 없이는 401 AUTH_001', async () => { + for (const [method, path] of [ + ['get', '/api/contacts'], + ['post', '/api/contacts'], + ['get', '/api/contacts/abc'], + ['patch', '/api/contacts/abc'], + ['delete', '/api/contacts/abc'], + ['get', '/api/contact-groups'], + ['post', '/api/contact-groups'], + ['patch', '/api/contact-groups/abc'], + ['delete', '/api/contact-groups/abc'], + ['post', '/api/contact-groups/abc/members'], + ['post', '/api/contact-restorations'], + ['get', '/api/directory-entries'], + ['get', '/api/directory-entries/abc'], + ] as const) { + const res = await request(server)[method](path).expect(401); + expectCode(res, 'AUTH_001'); + } + }); + + it('사내 목록을 읽지 못하면(사내 목록 계정이 없음) 503 MAIL_001', async () => { + directoryAccounts = null; + const res = await get('/api/directory-entries').expect(503); + expectCode(res, 'MAIL_001'); + }); + + it('사내 목록 — 관리자 · 게이트웨이 · 사내 목록 계정 · 바깥 주소는 빠지고 이름 차례, kind · q(초성) · 커서, Cache-Control: private, no-store', async () => { + directoryAccounts = [ + { + id: 'a', + kind: 'person', + displayName: 'System administrator', + email: ADMIN_ACCOUNT, + }, + { + id: 'g', + kind: 'person', + displayName: '메일 게이트웨이 (백엔드 대리 접근 전용) [setup.mjs]', + email: 'mail-gateway@e2e.arasystem.test', + }, + { + id: 'm', + kind: 'person', + displayName: '사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs]', + email: DIRECTORY_ACCOUNT, + }, + { + id: 'd', + kind: 'person', + displayName: '이도윤', + email: 'doyun.lee@e2e.arasystem.test', + }, + { + id: 'k', + kind: 'person', + displayName: '김아라', + email: 'ara.kim@e2e.arasystem.test', + }, + { + id: 's', + kind: 'shared', + displayName: '영업팀', + email: 'sales@e2e.arasystem.test', + }, + { + id: 'x', + kind: 'person', + displayName: '바깥', + email: 'outside@partner.e2e.example', + }, + ]; + + const res = await get('/api/directory-entries').expect(200); + expect(res.headers['cache-control']).toBe('private, no-store'); + expect(body(res).success).toBe(true); + expect(dataOf>(res)).toEqual({ + items: [ + { + id: 'k', + name: '김아라', + email: 'ara.kim@e2e.arasystem.test', + kind: 'person', + }, + { + id: 's', + name: '영업팀', + email: 'sales@e2e.arasystem.test', + kind: 'shared', + }, + { + id: 'd', + name: '이도윤', + email: 'doyun.lee@e2e.arasystem.test', + kind: 'person', + }, + ], + pageInfo: { hasNext: false, nextCursor: null, total: 3 }, + }); + + const idsOf = async (query: Record) => + dataOf>( + await get('/api/directory-entries').query(query).expect(200), + ).items.map((entry) => entry.id); + expect(await idsOf({ kind: 'shared' })).toEqual(['s']); + expect(await idsOf({ q: 'ㄷㅇ' })).toEqual(['d']); + expect(await idsOf({ q: 'ARA.KIM' })).toEqual(['k']); + + const first = dataOf>( + await get('/api/directory-entries').query({ limit: 1 }).expect(200), + ); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 3 }); + const cursor = first.pageInfo.nextCursor ?? ''; + expect(await idsOf({ limit: 1, cursor })).toEqual(['s']); + + for (const query of [ + { cursor, kind: 'person' }, + { cursor: 'not-a-cursor' }, + { kind: 'group' }, + { q: 'x'.repeat(255) }, + { limit: 101 }, + ]) { + expectCode( + await get('/api/directory-entries').query(query).expect(400), + 'VAL_001', + ); + } + // 메일 주소 하나(254자)는 찾을 수 있다 + expect(await idsOf({ q: 'x'.repeat(254) })).toEqual([]); + + // 한 항목 — 기억한 목록에서, 빼는 계정 · 없는 id · 형식이 틀린 id 는 404 RES_001 + const one = await get('/api/directory-entries/d').expect(200); + expect(one.headers['cache-control']).toBe('private, no-store'); + expect(dataOf(one)).toEqual({ + id: 'd', + name: '이도윤', + email: 'doyun.lee@e2e.arasystem.test', + kind: 'person', + }); + for (const id of ['a', 'm', 'nope', 'bad.id']) { + expectCode( + await get(`/api/directory-entries/${id}`).expect(404), + 'RES_001', + ); + } + }); + + it('사내 목록 excludeGroupId — 내 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다, 없는 그룹은 404 RES_001', async () => { + const PICKER = 'contacts.picker@e2e.arasystem.test'; + const group = dataOf( + await post('/api/contact-groups', PICKER) + .send({ name: '팀' }) + .expect(201), + ); + const member = dataOf( + await post('/api/contacts', PICKER) + .send( + contactInput({ + emails: [ + { address: 'Doyun.Lee@e2e.arasystem.test', label: 'work' }, + ], + groupIds: [group.id], + }), + ) + .expect(201), + ); + expect(member.groupIds).toEqual([group.id]); + + const ids = dataOf>( + await get('/api/directory-entries', PICKER) + .query({ excludeGroupId: group.id }) + .expect(200), + ).items.map((entry) => entry.id); + expect(ids).toEqual(['k', 's']); + expectCode( + await get('/api/directory-entries', PICKER) + .query({ excludeGroupId: 'nogroup' }) + .expect(404), + 'RES_001', + ); + }); + + it('연락처 추가 201 · 하나 · 목록(초성 · 전화번호 숫자 · 대소문자) · 고치기 · 지우기 — 표준 래퍼, 지운 뒤에는 404 RES_001', async () => { + const created = await post('/api/contacts') + .send( + contactInput({ name: ' 박지훈 ', note: '견적 담당\n월요일 오전' }), + ) + .expect(201); + expect(body(created).success).toBe(true); + const contact = dataOf(created); + expect(contact).toEqual({ + id: contact.id, + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.e2e.example', label: 'work' }], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '견적 담당\n월요일 오전', + groupIds: [], + }); + + const one = await get(`/api/contacts/${contact.id}`).expect(200); + expect(one.headers['cache-control']).toBe('private, no-store'); + expect(dataOf(one)).toEqual(contact); + + for (const q of ['ㅂㅈㅎ', '31925540', 'HANBIT', '구매']) { + const page = dataOf>( + await get('/api/contacts').query({ q }).expect(200), + ); + expect(page.items.map((item) => item.id)).toEqual([contact.id]); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 1, + }); + } + + // 고치기는 바꾼 칸만 — 보내지 않은 칸은 그대로 + const updated = await patch(`/api/contacts/${contact.id}`) + .send({ title: '부장', note: null }) + .expect(200); + expect(dataOf(updated)).toEqual({ + ...contact, + title: '부장', + note: null, + }); + + const removed = await del(`/api/contacts/${contact.id}`).expect(200); + const deleted = dataOf<{ contact: ContactBody; restoreToken: string }>( + removed, + ); + expect(deleted.contact).toMatchObject({ id: contact.id, title: '부장' }); + expect(deleted.restoreToken).toMatch(/^[A-Za-z0-9_-]{22}$/); + expectCode( + await get(`/api/contacts/${contact.id}`).expect(404), + 'RES_001', + ); + expectCode( + await del(`/api/contacts/${contact.id}`).expect(404), + 'RES_001', + ); + + // 되돌리기 — 새 id 로 그대로, 표는 한 번만 · 다른 직원은 쓸 수 없다 + expectCode( + await post( + '/api/contact-restorations', + 'someone.else@e2e.arasystem.test', + ) + .send({ token: deleted.restoreToken }) + .expect(404), + 'RES_001', + ); + const restored = await post('/api/contact-restorations') + .send({ token: deleted.restoreToken }) + .expect(201); + const back = dataOf<{ kind: string; contact: ContactBody }>(restored); + expect(back.kind).toBe('contact'); + expect(back.contact).toEqual({ ...deleted.contact, id: back.contact.id }); + expect(back.contact.id).not.toBe(contact.id); + expectCode( + await post('/api/contact-restorations') + .send({ token: deleted.restoreToken }) + .expect(404), + 'RES_001', + ); + for (const bad of [ + {}, + { token: 'short' }, + { token: deleted.restoreToken, extra: 1 }, + ]) { + expectCode( + await post('/api/contact-restorations').send(bad).expect(400), + 'VAL_001', + ); + } + // 다음 테스트를 위해 비운다 + await del(`/api/contacts/${back.contact.id}`).expect(200); + }); + + it('형식 오류는 400 VAL_001 — 이름도 이메일도 없음 · 같은 주소 두 번 · 전화번호 형식 · 칸이 빠짐 · 모르는 칸 / 형식이 틀린 id 는 404 RES_001', async () => { + const missingNote: Record = contactInput(); + delete missingNote.note; + for (const bad of [ + contactInput({ name: null, emails: [] }), + contactInput({ name: ' ', emails: [] }), + contactInput({ + emails: [ + { address: 'same@x.e2e.example', label: 'work' }, + { address: 'SAME@X.e2e.example', label: 'other' }, + ], + }), + contactInput({ phones: [{ number: '내선 12', label: 'work' }] }), + contactInput({ + emails: [{ address: 'not-an-address', label: 'work' }], + }), + missingNote, + contactInput({ birthday: '1990-05-01' }), + ]) { + expectCode( + await post('/api/contacts').send(bad).expect(400), + 'VAL_001', + ); + } + expectCode(await get('/api/contacts/bad.id').expect(404), 'RES_001'); + expectCode( + await patch('/api/contacts/bad.id').send({ title: 'x' }).expect(404), + 'RES_001', + ); + + // 고치기 — 빈 본문 · 모르는 칸 · 보낸 칸의 형식 오류 · 합친 결과에 이름도 이메일도 없음은 400, PUT 은 없다 + const VALIDATOR = 'contacts.validate@e2e.arasystem.test'; + const target = dataOf( + await post('/api/contacts', VALIDATOR) + .send(contactInput({ emails: [] })) + .expect(201), + ); + for (const bad of [ + {}, + { birthday: '1990-05-01' }, + { phones: [{ number: '내선', label: 'work' }] }, + { emails: null }, + { name: null }, + ]) { + expectCode( + await patch(`/api/contacts/${target.id}`, VALIDATOR) + .send(bad) + .expect(400), + 'VAL_001', + ); + } + expectCode( + await request(server) + .put(`/api/contacts/${target.id}`) + .set('Cookie', asUser(VALIDATOR)) + .send(contactInput()) + .expect(404), + 'RES_001', + ); + }); + + it('그룹 — 만들기 201 · 같은 이름(대소문자 무시) 409 CONTACT_001 · 이름 바꾸기 · 구성원 넣기(내 연락처 · 사내 목록) · 그룹의 구성원 목록 · 지우기와 [되돌리기]', async () => { + const group = dataOf( + await post('/api/contact-groups') + .send({ name: 'Partners' }) + .expect(201), + ); + expect(group).toEqual({ id: group.id, name: 'Partners', memberCount: 0 }); + expectCode( + await post('/api/contact-groups') + .send({ name: ' partners ' }) + .expect(409), + 'CONTACT_001', + ); + expectCode( + await post('/api/contact-groups').send({ name: '' }).expect(400), + 'VAL_001', + ); + + const renamed = await patch(`/api/contact-groups/${group.id}`) + .send({ name: '거래처' }) + .expect(200); + expect(dataOf(renamed)).toEqual({ + id: group.id, + name: '거래처', + memberCount: 0, + }); + + const contact = dataOf( + await post('/api/contacts').send(contactInput()).expect(201), + ); + const added = await post(`/api/contact-groups/${group.id}/members`) + .send({ contactIds: [contact.id], directoryIds: ['d'] }) + .expect(200); + expect(dataOf(added)).toEqual({ + group: { id: group.id, name: '거래처', memberCount: 2 }, + addedCount: 2, + createdCount: 1, + }); + // 다시 넣으면 세지 않는다 + expect( + dataOf<{ addedCount: number }>( + await post(`/api/contact-groups/${group.id}/members`) + .send({ contactIds: [contact.id], directoryIds: ['d'] }) + .expect(200), + ).addedCount, + ).toBe(0); + + const members = dataOf>( + await get('/api/contacts') + .query({ groupId: group.id, limit: 100 }) + .expect(200), + ); + expect(members.items.map((item) => item.emails[0]?.address)).toEqual([ + 'jihoon.park@hanbit.e2e.example', + 'doyun.lee@e2e.arasystem.test', + ]); + expect( + members.items.every((item) => item.groupIds.includes(group.id)), + ).toBe(true); + const groups = dataOf>( + await get('/api/contact-groups').expect(200), + ); + expect(groups.items).toContainEqual({ + id: group.id, + name: '거래처', + memberCount: 2, + }); + + expectCode( + await post(`/api/contact-groups/${group.id}/members`) + .send({}) + .expect(400), + 'VAL_001', + ); + for (const bad of [{ contactIds: ['nope'] }, { directoryIds: ['a'] }]) { + expectCode( + await post(`/api/contact-groups/${group.id}/members`) + .send(bad) + .expect(404), + 'RES_001', + ); + } + expectCode( + await patch('/api/contact-groups/nope').send({ name: 'x' }).expect(404), + 'RES_001', + ); + + const deleted = dataOf<{ + id: string; + name: string; + memberIds: string[]; + restoreToken: string; + }>(await del(`/api/contact-groups/${group.id}`).expect(200)); + expect(deleted).toEqual({ + id: group.id, + name: '거래처', + memberIds: members.items.map((item) => item.id), + restoreToken: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/) as string, + }); + // 연락처는 그대로 — 그 그룹은 이제 없다 + expect( + dataOf>( + await get('/api/contacts').query({ limit: 100 }).expect(200), + ).items.map((item) => item.groupIds), + ).toEqual([[], []]); + expectCode( + await get('/api/contacts').query({ groupId: group.id }).expect(404), + 'RES_001', + ); + + // 만들기는 { name } 만 — 지운 그룹은 되돌리기로 + expectCode( + await post('/api/contact-groups') + .send({ name: deleted.name, memberIds: deleted.memberIds }) + .expect(400), + 'VAL_001', + ); + const restored = dataOf<{ kind: string; group: GroupBody }>( + await post('/api/contact-restorations') + .send({ token: deleted.restoreToken }) + .expect(201), + ); + expect(restored).toMatchObject({ + kind: 'group', + group: { name: '거래처', memberCount: 2 }, + }); + expect(restored.group.id).not.toBe(group.id); + + // excludeGroupId — 그 그룹의 구성원을 빼고 (groupId 와 함께 쓰면 400) + expect( + dataOf>( + await get('/api/contacts') + .query({ excludeGroupId: restored.group.id }) + .expect(200), + ).items, + ).toEqual([]); + expectCode( + await get('/api/contacts') + .query({ + groupId: restored.group.id, + excludeGroupId: restored.group.id, + }) + .expect(400), + 'VAL_001', + ); + }); + + it(`한도 — 연락처 ${MAX_CONTACTS}개면 409 CONTACT_002 (아무것도 만들지 않는다)`, async () => { + const FULL = 'contacts.full@e2e.arasystem.test'; + const book = addressBookOf(FULL); + for (let index = 0; index < MAX_CONTACTS; index += 1) { + book.contacts.set(`f${index}`, { + id: `f${index}`, + uid: `urn:uuid:f${index}`, + name: `연락처 ${index}`, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }); + } + + expectCode( + await post('/api/contacts', FULL).send(contactInput()).expect(409), + 'CONTACT_002', + ); + expect(book.contacts.size).toBe(MAX_CONTACTS); + }); + + it('커서는 받은 그 q · groupId 로만 — 다르면 400 VAL_001, 없는 groupId 는 404 RES_001, limit 범위 밖은 400', async () => { + const LISTER = 'contacts.lister@e2e.arasystem.test'; + for (const name of ['가나다', '나비']) { + await post('/api/contacts', LISTER) + .send(contactInput({ name, emails: [] })) + .expect(201); + } + const first = dataOf>( + await get('/api/contacts', LISTER).query({ limit: 1 }).expect(200), + ); + expect(first.items.map((item) => item.name)).toEqual(['가나다']); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 }); + const cursor = first.pageInfo.nextCursor ?? ''; + expect( + dataOf>( + await get('/api/contacts', LISTER) + .query({ limit: 1, cursor }) + .expect(200), + ).items.map((item) => item.name), + ).toEqual(['나비']); + + expectCode( + await get('/api/contacts', LISTER) + .query({ cursor, q: '나' }) + .expect(400), + 'VAL_001', + ); + expectCode( + await get('/api/contacts', LISTER).query({ limit: 0 }).expect(400), + 'VAL_001', + ); + expectCode( + await get('/api/contacts', LISTER) + .query({ groupId: 'nogroup' }) + .expect(404), + 'RES_001', + ); + }); + + it('받는 사람 추천 — 주소록(회사 · 종류) → 사내(엔진의 사내 목록) → 최근, 연락처를 바꾸면 바로 보인다', async () => { + const SUGGESTER = 'contacts.suggest@e2e.arasystem.test'; + const now = new Date().toISOString(); + correspondents.set(SUGGESTER, [ + { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test', at: now }, + { name: '협력사', email: 'vendor@partner.e2e.example', at: now }, + ]); + const suggestions = async () => + dataOf< + Page<{ + name: string | null; + email: string; + source: string; + company: string | null; + emailLabel: string | null; + }> + >( + await request(server) + .get('/api/recipient-suggestions') + .query({ limit: 100 }) + .set('Cookie', cookieOf(SUGGESTER)) + .expect(200), + ).items; + + expect(await suggestions()).toEqual([ + // 사내 — 주고받은 사람이 먼저, 나머지는 이름 차례 + { + name: '이도윤', + email: 'doyun.lee@e2e.arasystem.test', + source: 'directory', + company: null, + emailLabel: null, + }, + { + name: '김아라', + email: 'ara.kim@e2e.arasystem.test', + source: 'directory', + company: null, + emailLabel: null, + }, + { + name: '영업팀', + email: 'sales@e2e.arasystem.test', + source: 'directory', + company: null, + emailLabel: null, + }, + { + name: '협력사', + email: 'vendor@partner.e2e.example', + source: 'recent', + company: null, + emailLabel: null, + }, + ]); + + await post('/api/contacts', SUGGESTER) + .send( + contactInput({ + emails: [ + { address: 'jihoon.park@hanbit.e2e.example', label: 'work' }, + { address: 'jihoon@home.e2e.example', label: 'personal' }, + ], + }), + ) + .expect(201); + await post('/api/contacts', SUGGESTER) + .send( + contactInput({ + name: '이도윤(거래처)', + company: null, + emails: [ + { address: 'Doyun.Lee@e2e.arasystem.test', label: 'other' }, + ], + }), + ) + .expect(201); + + const after = await suggestions(); + expect(after.slice(0, 3)).toEqual([ + { + name: '박지훈', + email: 'jihoon.park@hanbit.e2e.example', + source: 'contact', + company: '한빛상사', + emailLabel: 'work', + }, + { + name: '박지훈', + email: 'jihoon@home.e2e.example', + source: 'contact', + company: '한빛상사', + emailLabel: 'personal', + }, + { + name: '이도윤(거래처)', + email: 'Doyun.Lee@e2e.arasystem.test', + source: 'contact', + company: null, + emailLabel: 'other', + }, + ]); + // 주소록에 있는 주소는 사내 묶음에서 빠진다 + expect(after.map((item) => item.source)).toEqual([ + 'contact', + 'contact', + 'contact', + 'directory', + 'directory', + 'recent', + ]); + }); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). @@ -3494,6 +4404,8 @@ describe('응답 계약 (e2e)', () => { ['delete', '/api/messages/eaaaaab'], ['post', '/api/message-deletions'], ['post', '/api/mail-attachments'], + ['patch', '/api/contacts/abc'], + ['post', '/api/contact-restorations'], ] as const)( 'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)', async (method, path) => { diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index a9e5f76..19cc4a5 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -26,6 +26,8 @@ const Map kErrorCodeMessages = { 'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', 'MAIL_012': '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', 'MAIL_013': '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', + 'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', + 'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 1470a18..1841bb2 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -26,6 +26,8 @@ export const ERROR_CODE_MESSAGES = { MAIL_011: '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', MAIL_012: '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.', MAIL_013: '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.', + CONTACT_001: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', + CONTACT_002: '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES