generated from ttipo/project-base
feat(frontend): 임시 저장·서명 설정·받는 사람 자동 완성 화면
- 쓰기 화면 자동 저장(3초 멈춤 · 30초 · 10초 간격 · 한 번에 하나)과 상태 글, 저장할 때마다 주소를 ?draft= 로 맞춰 새로고침해도 이어 쓴다. MAIL_008 · PUT 404 는 막지 않고 새 저장본으로 남긴다 - 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 원본을 지우지 않는다 - 휴지통은 휴지통으로 옮기기(PATCH)와 되돌리기 — 이미 휴지통에 있는 메일을 DELETE 로 영구 삭제하지 않게 - 설정 › 메일 › 서명: 서식 · 그림(크기 줄) · 미리 보기 · 넣을 메일, 저장하지 않고 떠나면 묻는다 - 받는 사람 자동 완성: 사내 · 최근 묶음, 초성 검색(조합 중 글자 포함), 완전한 주소는 미리 고르지 않는다 - 본문에 그림으로 보인 첨부(isInline)는 읽기 화면의 첨부 목록에서 빼고 전달에는 붙인다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,10 @@ describe('에러 코드 사전', () => {
|
||||
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
|
||||
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
|
||||
'MAIL_007', // 본문이 너무 김 — 쓰기 화면이 본문 아래 띠로 안내한다 (보내기 전 검사 · 서버 거절 모두)
|
||||
// 웹메일 2단계
|
||||
'MAIL_008', // 다른 곳에서 바뀐 임시 저장본 — 쓰기 화면이 새 저장본으로 남기고 정보 상태 글을 보인다
|
||||
'MAIL_001', // 메일 서버 장애 — 임시 저장이 조금 뒤 저절로 다시 해 본다
|
||||
'MAIL_003', // 메일함 용량 — 임시 저장이 조금 뒤 저절로 다시 해 본다
|
||||
'FILE_001', // 첨부 형식 사전 검사
|
||||
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
|
||||
]
|
||||
|
||||
@@ -411,4 +411,58 @@ describe('useAttachmentUploads', () => {
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('임시 저장 뒤 첨부 id 바꾸기 (계약 2절 "임시 저장" — 저장할 때마다 첨부 id 가 바뀐다)', () => {
|
||||
const SAVED = (id: string, name: string) => ({ id, name, type: 'application/pdf', size: 1 })
|
||||
|
||||
it('저장 본문에 실은 순서대로 응답의 id 로 바꾼다 — 화면의 이름 · 키는 그대로', async () => {
|
||||
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
|
||||
const { uploads, stop } = withUploads()
|
||||
uploads.addFiles([file('회의록.pdf')])
|
||||
await flushPromises()
|
||||
const [forward] = uploads.addExisting([SAVED('orig-1', '체크리스트.xlsx')])
|
||||
const keys = uploads.items.value.map((item) => item.key)
|
||||
|
||||
uploads.applySavedAttachments(keys, [SAVED('up1-v1', '회의록.pdf'), SAVED('orig-1-v1', '체크리스트.xlsx')])
|
||||
|
||||
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['up1-v1', 'orig-1-v1'])
|
||||
expect(uploads.items.value.map((item) => item.key)).toEqual(keys)
|
||||
expect(uploads.items.value[1]?.key).toBe(forward?.key)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('이름 · 크기 · 형식이 같은 것끼리 먼저 맞춘다 — 같은 이름이 여럿이고 응답 순서가 달라도', async () => {
|
||||
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
|
||||
const { uploads, stop } = withUploads()
|
||||
const added = uploads.addExisting([
|
||||
{ id: 'small', name: '보고서.pdf', type: 'application/pdf', size: 10 },
|
||||
{ id: 'large', name: '보고서.pdf', type: 'application/pdf', size: 2048 },
|
||||
{ id: 'sheet', name: '표.xlsx', type: 'application/vnd.ms-excel', size: 5 },
|
||||
])
|
||||
const keys = added.map((item) => item.key)
|
||||
|
||||
uploads.applySavedAttachments(keys, [
|
||||
{ id: 'large-v1', name: '보고서.pdf', type: 'application/pdf', size: 2048 },
|
||||
// 서버가 형식을 고쳐 돌려줬다 — 남은 것끼리 순서대로
|
||||
{ id: 'sheet-v1', name: '표.xlsx', type: 'application/octet-stream', size: 5 },
|
||||
{ id: 'small-v1', name: '보고서.pdf', type: 'application/pdf', size: 10 },
|
||||
])
|
||||
|
||||
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['small-v1', 'large-v1', 'sheet-v1'])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('개수가 다르면 같은 자리 · 같은 이름끼리만 — 저장하는 동안 뺀 첨부는 건너뛴다', async () => {
|
||||
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
|
||||
const { uploads, stop } = withUploads()
|
||||
const added = uploads.addExisting([SAVED('a', '가.pdf'), SAVED('b', '나.pdf')])
|
||||
const keys = added.map((item) => item.key)
|
||||
|
||||
uploads.remove(keys[1] ?? -1)
|
||||
uploads.applySavedAttachments(keys, [SAVED('a-v1', '가.pdf')])
|
||||
|
||||
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['a-v1'])
|
||||
stop()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +51,11 @@ export interface AttachmentUploadOptions {
|
||||
|
||||
const COMPLETE = 100
|
||||
|
||||
/** 같은 파일로 보이는가 — 이름 · 크기 · 형식이 모두 같다 */
|
||||
function isSameFile(a: Attachment, b: Attachment): boolean {
|
||||
return a.name === b.name && a.size === b.size && a.type === b.type
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 상태 — 파일마다 진행률을 보이고 실패한 파일만 표시한다.
|
||||
* 화면(스코프)이 사라지면 올리던 요청을 모두 취소한다.
|
||||
@@ -205,6 +210,43 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
return marked
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장이 끝났다 — 저장본의 첨부 id 로 바꿔 쓴다 (계약 2절 "임시 저장": 저장할 때마다 첨부 id 가 바뀐다).
|
||||
* keys 는 저장 본문에 실은 첨부 항목의 키(실은 순서대로), saved 는 응답의 첨부다.
|
||||
* 이름 · 크기 · 형식이 같은 것끼리 먼저 맞춘다 — 같은 이름이 여럿이어도 응답의 순서에만 기대지 않게.
|
||||
* 남은 것은 순서대로 — 남은 수가 같으면 같은 자리끼리, 다르면 같은 자리 · 같은 이름끼리만 바꾼다.
|
||||
* 저장하는 동안 뺀 항목은 이미 없고(그 자리는 건너뛴다), 새로 붙인 항목은 다음 저장에 실린다.
|
||||
*/
|
||||
function applySavedAttachments(keys: readonly number[], saved: readonly Attachment[]) {
|
||||
const sent = keys.map((key) => items.value.find((candidate) => candidate.key === key))
|
||||
const next = new Map<number, Attachment>()
|
||||
const usedSaved = new Set<number>()
|
||||
const matchedSent = new Set<number>()
|
||||
sent.forEach((item, position) => {
|
||||
const current = item?.attachment
|
||||
if (!item || !current) return
|
||||
const index = saved.findIndex((candidate, at) => !usedSaved.has(at) && isSameFile(current, candidate))
|
||||
const attachment = saved[index]
|
||||
if (!attachment) return
|
||||
usedSaved.add(index)
|
||||
matchedSent.add(position)
|
||||
next.set(item.key, attachment)
|
||||
})
|
||||
const restSent = sent.filter((_, position) => !matchedSent.has(position))
|
||||
const restSaved = saved.filter((_, at) => !usedSaved.has(at))
|
||||
const sameCount = restSent.length === restSaved.length
|
||||
restSent.forEach((item, index) => {
|
||||
const attachment = restSaved[index]
|
||||
if (!attachment || !item?.attachment) return
|
||||
if (sameCount || item.attachment.name === attachment.name) next.set(item.key, attachment)
|
||||
})
|
||||
if (next.size === 0) return
|
||||
items.value = items.value.map((item) => {
|
||||
const attachment = next.get(item.key)
|
||||
return attachment && item.status === 'done' ? { ...item, attachment } : item
|
||||
})
|
||||
}
|
||||
|
||||
/** 첨부 취소 — 올리는 중이면 요청도 끊는다 */
|
||||
function remove(key: number) {
|
||||
controllers.get(key)?.abort()
|
||||
@@ -233,6 +275,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
attachedCount,
|
||||
addFiles,
|
||||
addExisting,
|
||||
applySavedAttachments,
|
||||
markMissing,
|
||||
remove,
|
||||
reset,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { inject, type InjectionKey } from 'vue'
|
||||
|
||||
// 시계 — 기다렸다 하는 일(임시 저장의 입력 멈춤 · 받는 사람 추천의 기다림)이 쓰는 시각과 타이머.
|
||||
// 화면은 기본으로 브라우저 시계를 쓰고, 테스트는 손으로 돌리는 시계를 provide 해 시간을 정확히 넘긴다
|
||||
// (test-utils/manual-clock.ts — 진짜 타이머를 기다리면 느리고, 부하에 따라 결과가 달라진다).
|
||||
|
||||
/** 타이머 손잡이 — 시계마다 모양이 다르므로 열어 보지 않는다 */
|
||||
export type ClockTimer = ReturnType<typeof setTimeout> | number | symbol
|
||||
|
||||
export interface Clock {
|
||||
/** 지금 시각 (밀리초) */
|
||||
now: () => number
|
||||
setTimeout: (callback: () => void, ms: number) => ClockTimer
|
||||
clearTimeout: (timer: ClockTimer) => void
|
||||
}
|
||||
|
||||
export const systemClock: Clock = {
|
||||
now: () => Date.now(),
|
||||
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
||||
clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
||||
}
|
||||
|
||||
/** 화면 트리에 시계를 준다 — 주지 않으면 브라우저 시계 */
|
||||
export const CLOCK: InjectionKey<Clock> = Symbol('clock')
|
||||
|
||||
/** 컴포넌트 setup 에서 부른다 */
|
||||
export function useClock(): Clock {
|
||||
return inject(CLOCK, systemClock)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { saveDraftDetached } from './useDraftSaving'
|
||||
import type { DraftSaved, SendMessagePayload } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 쓰기 화면과 서버 저장본 id 를 잇는다 (계약 2절 "임시 저장" — 저장할 때마다 id 가 바뀐다 · 디자인 README "2단계 › 임시 저장")
|
||||
// - 주소 맞추기(sync): 저장할 때마다 /mail/compose?draft=<새 id> 로 바꿔 끼운다 — 새로고침 · 다시 열기가 마지막 저장본을 이어 쓴다.
|
||||
// 새 메일 · 답장 · 전달 · 옛 draft 매개변수를 모두 이것으로 바꾼다. 스스로 바꾼 주소는 ownsKey 로 알아본다 —
|
||||
// 쓰기 화면은 그 주소로 양식을 다시 채우지도, 떠나는 이동으로 보지도 않는다.
|
||||
// 다른 이동이 진행 중이면 바꾸지 않는다 — 그 이동을 취소해 버린다: 쓰기 화면이 막는 때(canReplace — 나가기 확인 · 떠나기 전 저장 ·
|
||||
// 보내기 · 버리기 등)와, 라우터 가드를 지나 화면이 바뀌는 중인 다른 이동(여기서 센다).
|
||||
// - 옛 id → 새 id(recordSaved): 저장할 때마다 탭 메모리(mailStore)에 적는다 — 옛 주소(?draft=<옛 id>, 떠나기 전 저장처럼
|
||||
// 주소를 맞추지 못한 채 떠난 경우)로 열면 마지막 저장본을 연다(openDraft).
|
||||
// - 보내는 중에 떠났고 보내지 못했으면 보내려던 내용을 조용히 저장본으로 남긴다(keepUnsent).
|
||||
// 쓰기 화면의 setup 에서 부른다 — 라우터 훅을 걸고, 화면을 내리면 뗀다.
|
||||
|
||||
export interface ComposeDraftLinkOptions {
|
||||
/** 지금 주소를 바꿔도 되는가 — 쓰기 화면의 다른 흐름(나가기 확인 · 보내기 · 버리기 · 보낸 뒤 · 버린 뒤)이 아니다 */
|
||||
canReplace: () => boolean
|
||||
}
|
||||
|
||||
/** 이 화면이 주소에 쓰는 저장본 표시 — 쓰기 화면의 draftKeyOf 와 같은 모양 */
|
||||
function draftKeyFor(id: string): string {
|
||||
return `draft:${id}`
|
||||
}
|
||||
|
||||
export function useComposeDraftLink(options: ComposeDraftLinkOptions) {
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/**
|
||||
* 이 양식의 주소 — 연 때의 쓰기 종류 · 원본, 저장한 뒤에는 바꿔 끼운 `draft:<id>`.
|
||||
* 주소가 이것으로 바뀐 것은 이 화면이 스스로 바꾼 것이다
|
||||
*/
|
||||
let ownKey = ''
|
||||
/** 이 화면이 바꿔 끼우는 중인 주소 — 다른 이동을 세는 데서 뺀다 */
|
||||
let ownSyncPath: string | null = null
|
||||
/**
|
||||
* 라우터의 가드를 지난 다른 이동 수 — 화면이 바뀌기 전(지연 로딩한 화면을 받는 중 등)에 주소를 바꿔 끼우면 그 이동이 취소된다.
|
||||
* 이 화면의 이동 가드(떠나기 전 저장 · 대화상자)를 기다리는 동안은 쓰기 화면의 canReplace 가 맡는다 (그 가드가 라우터 전체 가드보다 먼저 돈다)
|
||||
*/
|
||||
let navigationsPending = 0
|
||||
|
||||
const stopCountingStart = router.beforeEach((to) => {
|
||||
if (to.fullPath !== ownSyncPath) navigationsPending += 1
|
||||
})
|
||||
const stopCountingEnd = router.afterEach((to, _from, failure) => {
|
||||
const own = ownSyncPath !== null && to.fullPath === ownSyncPath
|
||||
if (own) ownSyncPath = null
|
||||
// 끝까지 간 이동이 있으면 그 앞의 이동은 남아 있지 않다(라우터가 앞 이동을 취소한다). 실패로 끝난 이동은 하나만 뺀다
|
||||
if (!failure) navigationsPending = 0
|
||||
else if (!own) navigationsPending = Math.max(0, navigationsPending - 1)
|
||||
})
|
||||
// 이동이 오류로 끝나면 afterEach 가 오지 않는다 — 세던 것을 비운다
|
||||
const stopCountingError = router.onError(() => {
|
||||
navigationsPending = 0
|
||||
})
|
||||
|
||||
/** 이 화면이 스스로 바꾼(또는 연) 주소인가 */
|
||||
function ownsKey(key: string): boolean {
|
||||
return key === ownKey
|
||||
}
|
||||
|
||||
/** 이 주소의 양식을 연다 — 이제 이 주소가 이 화면의 것이다 */
|
||||
function adoptKey(key: string) {
|
||||
ownKey = key
|
||||
}
|
||||
|
||||
/** 주소를 /mail/compose?draft=<id> 로 바꿔 끼운다 — 이 화면이 스스로 바꾼 것으로 표시한다. 이미 그 주소면 그대로 */
|
||||
function replace(id: string) {
|
||||
ownKey = draftKeyFor(id)
|
||||
const current = router.currentRoute.value
|
||||
if (current.query.draft === id && Object.keys(current.query).length === 1) return
|
||||
const target = router.resolve({ name: 'mail-compose', query: { draft: id } })
|
||||
ownSyncPath = target.fullPath
|
||||
void router.replace(target)
|
||||
}
|
||||
|
||||
/** 저장한 저장본을 주소에 옮긴다 — 다른 이동이 진행 중이거나 쓰기 화면을 떠났으면 바꾸지 않는다 */
|
||||
function sync(id: string | null) {
|
||||
if (!id || !options.canReplace() || navigationsPending > 0) return
|
||||
if (router.currentRoute.value.name !== 'mail-compose') return
|
||||
replace(id)
|
||||
}
|
||||
|
||||
/** 이어 쓸 저장본 — 이 탭에서 다시 저장해 바뀐 옛 id 면 마지막 저장본을 고르고 주소도 그것으로 바꾼다 */
|
||||
function openDraft(requested: string): string {
|
||||
const latest = mailStore.latestDraftId(requested)
|
||||
if (latest !== requested) replace(latest)
|
||||
return latest
|
||||
}
|
||||
|
||||
/** 저장이 끝났다 — 옛 id → 새 id 를 적는다 (양식이 바뀐 뒤 끝난 저장도 — 탭 메모리 정보라 해가 없다) */
|
||||
function recordSaved(previousId: string | null, saved: DraftSaved) {
|
||||
if (previousId !== null) mailStore.recordDraftSuccessor(previousId, saved.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내지 못한 메일을 저장본으로 남긴다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때(양식이 이미 없다).
|
||||
* 발송 본문의 draftId 가 있으면 그 저장본을 PUT(바뀌었거나 없으면 POST), 없으면 POST — 자동 저장과 같은 규칙. 알리지 않는다
|
||||
*/
|
||||
async function keepUnsent(payload: SendMessagePayload) {
|
||||
const { draftId = null, ...draft } = payload
|
||||
const saved = await saveDraftDetached(draft, draftId)
|
||||
if (!saved) return
|
||||
recordSaved(draftId, saved)
|
||||
mailStore.syncCounts()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCountingStart()
|
||||
stopCountingEnd()
|
||||
stopCountingError()
|
||||
})
|
||||
|
||||
return { ownsKey, adoptKey, sync, openDraft, recordSaved, keepUnsent }
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
|
||||
import { useDraftSaving, type DraftSaveRequest } from '@/composables/useDraftSaving'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { DraftPayload, DraftSaved } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockHandler,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
|
||||
const PAYLOAD: DraftPayload = { to: [], cc: [], bcc: [], subject: '초안', body: '', attachments: [] }
|
||||
|
||||
function savedAs(id: string, at = '2026-10-27T06:12:00Z'): DraftSaved {
|
||||
return { id, attachments: [], updatedAt: at }
|
||||
}
|
||||
|
||||
/** 붙잡아 둔 응답 */
|
||||
interface Held {
|
||||
release: (result: MockResult) => void
|
||||
}
|
||||
|
||||
describe('useDraftSaving (임시 저장 — 계약 2절 "임시 저장")', () => {
|
||||
let scope: EffectScope
|
||||
let clock: ManualClock
|
||||
/** 저장할 바뀐 내용이 있다 — 저장이 끝나면(commit) 지운다 */
|
||||
let changed: boolean
|
||||
let commits: DraftSaved[]
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
scope = effectScope()
|
||||
clock = createManualClock(1_000_000)
|
||||
changed = false
|
||||
commits = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function setup(handler: MockHandler, canSave = () => true) {
|
||||
requests = installMockApi(handler)
|
||||
const drafts = scope.run(() =>
|
||||
useDraftSaving({
|
||||
canSave,
|
||||
hasChanges: () => changed,
|
||||
prepare: (): DraftSaveRequest => ({
|
||||
payload: PAYLOAD,
|
||||
commit: (saved) => {
|
||||
changed = false
|
||||
commits.push(saved)
|
||||
},
|
||||
}),
|
||||
clock,
|
||||
}),
|
||||
)
|
||||
if (!drafts) throw new Error('scope')
|
||||
return drafts
|
||||
}
|
||||
|
||||
/** 바꿨다 — 화면이 하는 것처럼 touch */
|
||||
function edit(drafts: ReturnType<typeof setup>) {
|
||||
changed = true
|
||||
drafts.touch()
|
||||
}
|
||||
|
||||
const saves = () => requests.filter((request) => request.url.startsWith('/drafts'))
|
||||
const calls = () => saves().map((request) => `${request.method} ${request.url}`)
|
||||
|
||||
/** 처음은 d1, 그다음 d2 … 를 돌려주는 서버 */
|
||||
function counting(): MockHandler {
|
||||
let next = 1
|
||||
return (request) => {
|
||||
if (!request.url.startsWith('/drafts')) return ok(null)
|
||||
const id = `d${next}`
|
||||
next += 1
|
||||
return ok(savedAs(id), request.method === 'POST' ? 201 : 200)
|
||||
}
|
||||
}
|
||||
|
||||
it('입력이 3초 멈추면 저장한다 — 계속 쓰는 동안은 미룬다', async () => {
|
||||
const drafts = setup(counting())
|
||||
edit(drafts)
|
||||
clock.advance(2_000)
|
||||
edit(drafts)
|
||||
clock.advance(2_999)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual([])
|
||||
|
||||
clock.advance(1)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
expect(drafts.status.value).toBe('saved')
|
||||
expect(drafts.draftId.value).toBe('d1')
|
||||
expect(drafts.savedAt.value).toBe('2026-10-27T06:12:00Z')
|
||||
expect(commits).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('쉬지 않고 쓰는 동안에도 처음 바꾼 뒤 30초가 지나면 저장한다 — 되돌려 바꾼 것이 없어지면 거둔다', async () => {
|
||||
const drafts = setup(counting())
|
||||
for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) {
|
||||
edit(drafts)
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
if (elapsed + 1_000 < 30_000) expect(calls()).toEqual([])
|
||||
}
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
|
||||
// 바꿨다가 되돌리면(바꾼 것이 없어짐) 30초 타이머도 거둔다
|
||||
edit(drafts)
|
||||
changed = false
|
||||
drafts.touch()
|
||||
clock.advance(60_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
})
|
||||
|
||||
it('저장하는 동안 바꾼 것은 그때(저장하는 동안 처음 바꾼 때)부터 30초를 센다', async () => {
|
||||
let held: Held | null = null
|
||||
let posts = 0
|
||||
const drafts = setup((request) => {
|
||||
if (request.method === 'POST' && posts === 0) {
|
||||
posts += 1
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
held = { release: resolve }
|
||||
})
|
||||
}
|
||||
return ok(savedAs('d2'))
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
|
||||
// 저장이 끝나지 않은 채 25초 동안 쉬지 않고 쓴다 — 30초 타이머는 저장하는 동안 처음 바꾼 때(3초)부터
|
||||
for (let elapsed = 0; elapsed < 25_000; elapsed += 1_000) {
|
||||
edit(drafts)
|
||||
clock.advance(1_000)
|
||||
}
|
||||
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
|
||||
await flushPromises()
|
||||
changed = true
|
||||
for (let elapsed = 0; elapsed < 5_000; elapsed += 1_000) {
|
||||
edit(drafts)
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
}
|
||||
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
|
||||
})
|
||||
|
||||
it('바꾼 내용이 없으면 저장하지 않는다', async () => {
|
||||
const drafts = setup(counting())
|
||||
drafts.touch()
|
||||
clock.advance(60_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual([])
|
||||
expect(drafts.status.value).toBe('idle')
|
||||
})
|
||||
|
||||
it('10초에 한 번까지 — 그다음 저장은 직전 저장을 시작한 지 10초 뒤, 직전 id 로 PUT', async () => {
|
||||
const drafts = setup(counting())
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
|
||||
// 첫 저장을 시작한 때(3초)부터 10초 — 13초에
|
||||
clock.advance(6_999)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
clock.advance(1)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
|
||||
expect(drafts.draftId.value).toBe('d2')
|
||||
})
|
||||
|
||||
it('한 번에 하나씩 — 저장하는 동안 바뀐 것은 끝난 뒤 다시 저장한다', async () => {
|
||||
let held: Held | null = null
|
||||
const drafts = setup((request) => {
|
||||
if (request.method === 'POST') {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
held = { release: resolve }
|
||||
})
|
||||
}
|
||||
return ok(savedAs('d2'))
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(drafts.status.value).toBe('saving')
|
||||
|
||||
// 저장하는 동안 또 바꾸고 3초 · 10초가 지나도 두 번째 요청은 나가지 않는다
|
||||
edit(drafts)
|
||||
clock.advance(15_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
|
||||
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
|
||||
await flushPromises()
|
||||
// 첫 저장의 commit 이 "바꿈"을 지웠지만, 그 뒤 또 바꿨다 — 화면은 다시 touch 한다
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
|
||||
})
|
||||
|
||||
it.each(['MAIL_008', 'RES_001'] as const)(
|
||||
'PUT 이 %s 면 막지 않고 지금 내용을 POST 로 새로 남긴다 — 상태 replaced, 다음은 새 id 로',
|
||||
async (code) => {
|
||||
let posts = 0
|
||||
const drafts = setup((request) => {
|
||||
if (request.method === 'POST') {
|
||||
posts += 1
|
||||
return ok(savedAs(posts === 1 ? 'd1' : 'd9'), 201)
|
||||
}
|
||||
if (request.url === '/drafts/d1') return fail(code === 'MAIL_008' ? 409 : 404, code)
|
||||
return ok(savedAs('d10'))
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
edit(drafts)
|
||||
clock.advance(10_000)
|
||||
await flushPromises()
|
||||
|
||||
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1', 'POST /drafts'])
|
||||
expect(drafts.status.value).toBe('replaced')
|
||||
expect(drafts.draftId.value).toBe('d9')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
edit(drafts)
|
||||
clock.advance(10_000)
|
||||
await flushPromises()
|
||||
expect(calls().at(-1)).toBe('PUT /drafts/d9')
|
||||
expect(drafts.status.value).toBe('saved')
|
||||
},
|
||||
)
|
||||
|
||||
it('자동 저장이 실패하면 알림 없이 failed — 다시 해 보면 나아질 실패는 15초 뒤 저절로 다시 한다', async () => {
|
||||
let fails = 1
|
||||
const drafts = setup(() => {
|
||||
if (fails > 0) {
|
||||
fails -= 1
|
||||
return fail(503, 'MAIL_001')
|
||||
}
|
||||
return ok(savedAs('d1'), 201)
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(drafts.status.value).toBe('failed')
|
||||
expect(drafts.lastError.value?.code).toBe('MAIL_001')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
clock.advance(14_999)
|
||||
await flushPromises()
|
||||
expect(calls()).toHaveLength(1)
|
||||
clock.advance(1)
|
||||
await flushPromises()
|
||||
expect(calls()).toHaveLength(2)
|
||||
expect(drafts.status.value).toBe('saved')
|
||||
})
|
||||
|
||||
it('내용을 고쳐야 하는 실패(MAIL_007 등)는 저절로 다시 하지 않는다 — 다시 바꾸면 저장한다', async () => {
|
||||
const drafts = setup(() => fail(400, 'MAIL_007'))
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
clock.advance(120_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toHaveLength(1)
|
||||
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('「임시 저장」은 바로 저장하고, 실패하면 사전 문구 알림을 띄운다', async () => {
|
||||
const drafts = setup(() => fail(507, 'MAIL_003'))
|
||||
edit(drafts)
|
||||
await drafts.saveNow()
|
||||
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
expect(drafts.status.value).toBe('failed')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_003])
|
||||
})
|
||||
|
||||
it('「임시 저장」 — 아직 저장한 적 없으면 바꾼 것이 없어도 저장하고, 저장한 그대로면 요청하지 않는다', async () => {
|
||||
const drafts = setup(counting())
|
||||
await drafts.saveNow()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
await drafts.saveNow()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
})
|
||||
|
||||
it('보내기 · 버리기 전 — suspend 로 멈추고 settle 로 진행 중인 저장이 끝나기를 기다린다', async () => {
|
||||
let held: Held | null = null
|
||||
const drafts = setup(() => {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
held = { release: resolve }
|
||||
})
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
|
||||
drafts.suspend()
|
||||
let settled = false
|
||||
void drafts.settle().then(() => {
|
||||
settled = true
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(30_000)
|
||||
await flushPromises()
|
||||
expect(settled).toBe(false)
|
||||
expect(calls()).toHaveLength(1)
|
||||
|
||||
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
|
||||
await flushPromises()
|
||||
expect(settled).toBe(true)
|
||||
expect(drafts.draftId.value).toBe('d1')
|
||||
// 멈춘 동안은 자동 저장하지 않는다
|
||||
clock.advance(30_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('떠나기 전 — 남은 변경을 저장하고 성공 여부를 알려 준다 (저장한 그대로면 요청 없이 true)', async () => {
|
||||
const drafts = setup(counting())
|
||||
expect(await drafts.saveBeforeLeave()).toBe(true)
|
||||
expect(calls()).toEqual([])
|
||||
|
||||
edit(drafts)
|
||||
expect(await drafts.saveBeforeLeave()).toBe(true)
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
})
|
||||
|
||||
it('저장할 수 없는 때(canSave false)는 요청하지 않는다', async () => {
|
||||
const drafts = setup(counting(), () => false)
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(await drafts.saveNow()).toBe(false)
|
||||
expect(calls()).toEqual([])
|
||||
})
|
||||
|
||||
it('reset — 이어 쓰는 저장본이면 그 id · 시각으로 시작하고, 늦게 끝난 앞 양식의 저장은 무시한다', async () => {
|
||||
let held: Held | null = null
|
||||
const drafts = setup((request) => {
|
||||
if (request.method === 'POST') {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
held = { release: resolve }
|
||||
})
|
||||
}
|
||||
return ok(savedAs('d8'))
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
|
||||
drafts.reset({ id: 'd7', savedAt: '2026-10-26T01:00:00Z' })
|
||||
expect(drafts.status.value).toBe('saved')
|
||||
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
|
||||
await flushPromises()
|
||||
expect(drafts.draftId.value).toBe('d7')
|
||||
expect(commits).toEqual([])
|
||||
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls().at(-1)).toBe('PUT /drafts/d7')
|
||||
})
|
||||
|
||||
it('reset(keepOriginal) — 원본을 지우지 않는다: 첫 저장은 새 저장본(POST), 그 뒤는 새 id 로 PUT. 그동안 draftId 는 없다', async () => {
|
||||
const drafts = setup(counting())
|
||||
drafts.reset({ id: 'outlook-1', savedAt: '2026-10-26T01:00:00Z', keepOriginal: true })
|
||||
expect(drafts.status.value).toBe('saved')
|
||||
expect(drafts.draftId.value).toBeNull()
|
||||
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
edit(drafts)
|
||||
clock.advance(10_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
|
||||
})
|
||||
|
||||
it('onSaved — 저장이 끝날 때마다 바꾼 id 와 새 저장본을 알린다 (양식이 바뀐 뒤 끝난 저장도)', async () => {
|
||||
let held: Held | null = null
|
||||
const seen: [string | null, string][] = []
|
||||
requests = installMockApi((request) =>
|
||||
request.method === 'PUT'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
held = { release: resolve }
|
||||
})
|
||||
: ok(savedAs('d1'), 201),
|
||||
)
|
||||
const drafts = scope.run(() =>
|
||||
useDraftSaving({
|
||||
canSave: () => true,
|
||||
hasChanges: () => changed,
|
||||
prepare: (): DraftSaveRequest => ({ payload: PAYLOAD, commit: () => (changed = false) }),
|
||||
onSaved: (previousId, saved) => seen.push([previousId, saved.id]),
|
||||
clock,
|
||||
}),
|
||||
)
|
||||
if (!drafts) throw new Error('scope')
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
edit(drafts)
|
||||
clock.advance(10_000)
|
||||
await flushPromises()
|
||||
|
||||
// PUT 이 나가 있는 동안 양식을 새로 채운다 — 늦게 끝난 저장도 알린다
|
||||
drafts.reset()
|
||||
;(held as Held | null)?.release(ok(savedAs('d2')))
|
||||
await flushPromises()
|
||||
expect(seen).toEqual([
|
||||
[null, 'd1'],
|
||||
['d1', 'd2'],
|
||||
])
|
||||
expect(drafts.draftId.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,352 @@
|
||||
import { onScopeDispose, readonly, ref } from 'vue'
|
||||
|
||||
import { toApiError, type ApiError } from './useApi'
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import { useMail, type MailRequestOptions } from './useMail'
|
||||
import {
|
||||
DRAFT_IDLE_SAVE_MS,
|
||||
DRAFT_MAX_WAIT_MS,
|
||||
DRAFT_MIN_SAVE_INTERVAL_MS,
|
||||
DRAFT_RETRY_CODES,
|
||||
DRAFT_RETRY_DELAYS_MS,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { DraftPayload, DraftSaved } from '@/shared/types/mail'
|
||||
|
||||
// 쓰기 화면의 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장")
|
||||
// - 자동 저장: 바꾼 내용이 있으면 입력이 3초 멈출 때, 10초에 한 번까지. 저장은 한 번에 하나씩 — 저장하는 동안 바뀐 것은 끝난 뒤 다시.
|
||||
// 쉬지 않고 쓰는 동안에도 바꾼 내용이 처음 생긴 뒤(마지막 저장을 시작한 뒤 처음 바꾼 때부터) 30초가 지나면 저장한다.
|
||||
// - 처음은 POST /drafts, 그다음은 PUT /drafts/{직전 id} — 응답의 id · 첨부로 바꿔 쓴다(prepare 가 준 commit).
|
||||
// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌으면(409 MAIL_008 · 임시보관함 밖으로 옮겨져 404 RES_001) 막지 않고 POST 로 새로 남긴다
|
||||
// — 상태 'replaced' (임시보관함에 두 통이 남는다).
|
||||
// - 자동 저장 실패는 알림 없이 상태(failed)로만 보이고, 다시 해 보면 나아질 실패(DRAFT_RETRY_CODES)는 조금 뒤 저절로 다시 한다.
|
||||
// 「임시 저장」을 눌러 실패하면 사전 문구 알림도 띄운다 (인터셉터 — MAIL_008 · RES_001 은 위처럼 처리하므로 끈다).
|
||||
// - 보내기 · 버리기 전에는 suspend 로 자동 저장을 멈추고 settle 로 진행 중인 저장을 기다린다 (draftId 가 최신이어야 한다).
|
||||
|
||||
export type DraftSaveStatus = 'idle' | 'saving' | 'saved' | 'failed' | 'replaced'
|
||||
|
||||
/** 저장을 시작한 까닭 — 알림을 띄울지(manual) 정한다 */
|
||||
export type DraftSaveTrigger = 'auto' | 'manual' | 'leave'
|
||||
|
||||
/**
|
||||
* 저장할 수 없는 내용이다 — 보내기 전 검사에 걸렸다 (예: 본문이 한도를 넘음 MAIL_007).
|
||||
* 요청 없이 실패로 두고 저절로 다시 하지 않는다 — 내용이 바뀌면(touch) 다시 잰다
|
||||
*/
|
||||
export interface DraftSaveRejected {
|
||||
rejected: ApiError
|
||||
}
|
||||
|
||||
/** 저장 한 번 — 보낼 본문과, 저장이 끝났을 때 그 내용을 "저장한 것"으로 삼는 함수 */
|
||||
export interface DraftSaveRequest {
|
||||
payload: DraftPayload
|
||||
/** 저장이 끝났다 — 응답의 첨부 id 로 바꿔 쓰고, 이 요청을 만든 때의 양식을 "바꾸지 않은" 기준으로 삼는다 */
|
||||
commit: (saved: DraftSaved) => void
|
||||
}
|
||||
|
||||
export interface DraftSavingOptions {
|
||||
/** 지금 저장해도 되는가 — 편집기 준비 · 로그인 · 보내는 중 아님 등 */
|
||||
canSave: () => boolean
|
||||
/** 마지막으로 저장한(저장한 적 없으면 연) 뒤 바꾼 내용이 있다 */
|
||||
hasChanges: () => boolean
|
||||
/** 지금 내용으로 저장 요청을 만든다 — 만들 수 없으면 null, 저장할 수 없는 내용이면(사전 검사) rejected */
|
||||
prepare: () => DraftSaveRequest | DraftSaveRejected | null
|
||||
/** 저장이 실패했다 — 예: 찾지 못한 첨부(MAIL_004)를 표시한다 */
|
||||
onFailed?: (error: ApiError, trigger: DraftSaveTrigger) => void
|
||||
/**
|
||||
* 저장이 끝났다 — previousId 는 이 저장이 바꾼(PUT 한) 저장본 id, 새로 만들었으면 null.
|
||||
* 양식이 바뀐 뒤 끝난 저장도 부른다 (commit 은 부르지 않는다) — 예: 옛 id → 새 id 를 적어 둔다
|
||||
*/
|
||||
onSaved?: (previousId: string | null, saved: DraftSaved) => void
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
/** 이어 쓰는 저장본 — 쓰기 화면이 임시 저장본을 불러와 채웠을 때 */
|
||||
export interface ExistingDraft {
|
||||
id: string
|
||||
/** 마지막으로 저장한 시각 (ISO 8601) */
|
||||
savedAt: string
|
||||
/**
|
||||
* 원본을 지우지 않는다 — 첫 저장을 PUT 대신 새 저장본(POST)으로 하고, 그 뒤는 새 id 로 PUT 한다.
|
||||
* 다른 메일 프로그램이 만든 저장본에 우리가 실을 수 없는 첨부(사전 검사 실패)가 있을 때 — PUT 하면 서버가 그 첨부째 원본을 지운다.
|
||||
* 그동안 draftId 는 null 이다 (보내기도 원본을 가리키지 않는다)
|
||||
*/
|
||||
keepOriginal?: boolean
|
||||
}
|
||||
|
||||
/** 「임시 저장」을 눌렀을 때 끄는 알림 — 이 둘은 새로 저장해 처리한다 */
|
||||
const REPLACED_CODES: readonly ErrorCode[] = ['MAIL_008', 'RES_001']
|
||||
|
||||
function isRetryable(error: ApiError): boolean {
|
||||
return !error.canceled && DRAFT_RETRY_CODES.includes(error.code)
|
||||
}
|
||||
|
||||
type DraftApi = Pick<ReturnType<typeof useMail>, 'createDraft' | 'updateDraft'>
|
||||
|
||||
/**
|
||||
* 저장 요청 한 번 — id 가 있으면 PUT, 그 저장본이 다른 곳에서 바뀌었거나 없으면(MAIL_008 · RES_001) 지금 내용을 POST 로 새로 남긴다.
|
||||
* silent 는 첫 요청(PUT · POST)과 새로 남기는 POST 각각의 알림 끄기다
|
||||
*/
|
||||
async function putOrCreate(
|
||||
mail: DraftApi,
|
||||
payload: DraftPayload,
|
||||
id: string | null,
|
||||
silent: { first: MailRequestOptions['silent']; fallback: MailRequestOptions['silent'] },
|
||||
): Promise<{ saved: DraftSaved; replaced: boolean }> {
|
||||
if (id === null) return { saved: await mail.createDraft(payload, { silent: silent.first }), replaced: false }
|
||||
try {
|
||||
return { saved: await mail.updateDraft(id, payload, { silent: silent.first }), replaced: false }
|
||||
} catch (error) {
|
||||
if (!REPLACED_CODES.includes(toApiError(error).code)) throw error
|
||||
// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌다(또는 임시보관함 밖으로 옮겨졌다) — 지금 내용을 새 저장본으로 남긴다
|
||||
return { saved: await mail.createDraft(payload, { silent: silent.fallback }), replaced: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식 없이 한 번 조용히 저장한다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때 보내려던 내용을 남긴다.
|
||||
* 저장 규칙은 자동 저장과 같다 (putOrCreate). 알리지 않는다 — 실패하면 null
|
||||
*/
|
||||
export async function saveDraftDetached(payload: DraftPayload, draftId: string | null): Promise<DraftSaved | null> {
|
||||
try {
|
||||
return (await putOrCreate(useMail(), payload, draftId, { first: true, fallback: true })).saved
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useDraftSaving(options: DraftSavingOptions) {
|
||||
const mail = useMail()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
/** 마지막으로 저장한 저장본 id — 다음 저장은 이 id 로 PUT, 보내기는 draftId 로 */
|
||||
const draftId = ref<string | null>(null)
|
||||
const status = ref<DraftSaveStatus>('idle')
|
||||
/** 마지막으로 저장한 시각 — 상태 글에 보인다 */
|
||||
const savedAt = ref<string | null>(null)
|
||||
/** 마지막 저장의 실패 (성공하면 비운다) */
|
||||
const lastError = ref<ApiError | null>(null)
|
||||
|
||||
/** 양식 세대 — reset 마다 올린다. 늦게 끝난 저장이 새 양식을 건드리지 않게 */
|
||||
let generation = 0
|
||||
let inFlight: Promise<boolean> | null = null
|
||||
let idleTimer: ClockTimer | null = null
|
||||
/** 쉬지 않고 쓰는 동안의 저장 — 마지막 저장을 시작한 뒤 처음 바꾼 때 건다 (그 뒤로는 다시 걸지 않는다) */
|
||||
let maxWaitTimer: ClockTimer | null = null
|
||||
let retryTimer: ClockTimer | null = null
|
||||
/** 마지막 저장을 시작한 시각 — 자동 저장은 10초에 한 번까지 */
|
||||
let lastStartedAt = Number.NEGATIVE_INFINITY
|
||||
let retryCount = 0
|
||||
/** 저장하는 동안 자동 저장할 차례가 왔다 — 끝나면 다시 저장한다 */
|
||||
let queued = false
|
||||
let suspended = false
|
||||
let stopped = false
|
||||
|
||||
function clearIdle() {
|
||||
if (idleTimer !== null) clock.clearTimeout(idleTimer)
|
||||
idleTimer = null
|
||||
}
|
||||
|
||||
function clearMaxWait() {
|
||||
if (maxWaitTimer !== null) clock.clearTimeout(maxWaitTimer)
|
||||
maxWaitTimer = null
|
||||
}
|
||||
|
||||
function clearRetry() {
|
||||
if (retryTimer !== null) clock.clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
|
||||
/** 자동 저장 차례 — 10초 안에 이미 저장을 시작했으면 그때까지 미룬다 (입력 멈춤 · 30초 · 다시 해 보기가 모두 여기로 온다) */
|
||||
function autosave() {
|
||||
clearIdle()
|
||||
if (suspended || stopped) return
|
||||
if (inFlight) {
|
||||
queued = true
|
||||
return
|
||||
}
|
||||
if (!options.hasChanges()) return
|
||||
const wait = lastStartedAt + DRAFT_MIN_SAVE_INTERVAL_MS - clock.now()
|
||||
if (wait > 0) {
|
||||
idleTimer = clock.setTimeout(autosave, wait)
|
||||
return
|
||||
}
|
||||
void run('auto')
|
||||
}
|
||||
|
||||
function onMaxWait() {
|
||||
maxWaitTimer = null
|
||||
autosave()
|
||||
}
|
||||
|
||||
/**
|
||||
* 내용이 바뀌었다 — 입력이 3초 멈추면 저장한다 (계속 쓰는 동안은 미룬다).
|
||||
* 다만 바꾼 내용이 처음 생긴 뒤 30초가 지나면 멈춤을 기다리지 않는다 — 그 타이머는 처음 바꾼 때 한 번만 건다.
|
||||
* 바꾼 것이 없어졌으면(되돌림) 둘 다 거둔다.
|
||||
*/
|
||||
function touch() {
|
||||
if (suspended || stopped) return
|
||||
clearIdle()
|
||||
if (!options.hasChanges()) {
|
||||
clearMaxWait()
|
||||
return
|
||||
}
|
||||
idleTimer = clock.setTimeout(autosave, DRAFT_IDLE_SAVE_MS)
|
||||
if (maxWaitTimer === null) maxWaitTimer = clock.setTimeout(onMaxWait, DRAFT_MAX_WAIT_MS)
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
clearRetry()
|
||||
const delay = DRAFT_RETRY_DELAYS_MS[Math.min(retryCount, DRAFT_RETRY_DELAYS_MS.length - 1)] ?? 0
|
||||
retryCount += 1
|
||||
retryTimer = clock.setTimeout(() => {
|
||||
retryTimer = null
|
||||
autosave()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 저장 요청 — PUT 이 MAIL_008 · RES_001 이면 새 저장본으로. 「임시 저장」만 사전 문구 알림을 띄운다 */
|
||||
function send(payload: DraftPayload, trigger: DraftSaveTrigger): Promise<{ saved: DraftSaved; replaced: boolean }> {
|
||||
const manual = trigger === 'manual'
|
||||
return putOrCreate(mail, payload, draftId.value, {
|
||||
first: manual ? REPLACED_CODES : true,
|
||||
fallback: manual ? undefined : true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 저장 한 번 — 끝나면 성공 여부 */
|
||||
async function run(trigger: DraftSaveTrigger): Promise<boolean> {
|
||||
if (stopped || !options.canSave()) return false
|
||||
const request = options.prepare()
|
||||
if (!request) return false
|
||||
clearIdle()
|
||||
clearMaxWait()
|
||||
clearRetry()
|
||||
queued = false
|
||||
if ('rejected' in request) {
|
||||
// 보내도 서버가 거절할 내용이다 — 요청 없이 실패로 둔다. 저절로 다시 하지 않는다 (내용이 바뀌면 touch 가 다시 잡는다)
|
||||
status.value = 'failed'
|
||||
lastError.value = request.rejected
|
||||
options.onFailed?.(request.rejected, trigger)
|
||||
return false
|
||||
}
|
||||
const started = generation
|
||||
lastStartedAt = clock.now()
|
||||
status.value = 'saving'
|
||||
|
||||
// 이 저장이 바꿀 저장본 — send 가 이 id 로 PUT 한다(없으면 POST)
|
||||
const previousId = draftId.value
|
||||
const attempt = (async (): Promise<boolean> => {
|
||||
try {
|
||||
const { saved, replaced } = await send(request.payload, trigger)
|
||||
options.onSaved?.(previousId, saved)
|
||||
if (started !== generation) return false
|
||||
draftId.value = saved.id
|
||||
savedAt.value = saved.updatedAt
|
||||
status.value = replaced ? 'replaced' : 'saved'
|
||||
lastError.value = null
|
||||
retryCount = 0
|
||||
request.commit(saved)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (started !== generation) return false
|
||||
const apiError = toApiError(error)
|
||||
status.value = 'failed'
|
||||
lastError.value = apiError
|
||||
options.onFailed?.(apiError, trigger)
|
||||
if (isRetryable(apiError) && !suspended && !stopped) scheduleRetry()
|
||||
return false
|
||||
}
|
||||
})()
|
||||
inFlight = attempt
|
||||
const ok = await attempt
|
||||
if (inFlight === attempt) inFlight = null
|
||||
// 저장하는 동안 자동 저장 차례가 왔다 — 남은 변경을 다시 (10초 간격은 autosave 가 지킨다)
|
||||
if (started === generation && queued) {
|
||||
queued = false
|
||||
autosave()
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/** 진행 중인 저장이 끝나기를 기다린다 (보내기 · 버리기 전) */
|
||||
async function settle(): Promise<void> {
|
||||
while (inFlight) await inFlight
|
||||
}
|
||||
|
||||
/**
|
||||
* 「임시 저장」 — 진행 중인 저장을 기다린 뒤, 바꾼 것이 있거나 아직 한 번도 저장하지 않았으면 저장한다.
|
||||
* 이미 저장한 그대로면 요청하지 않는다. 실패하면 사전 문구 알림(인터셉터)이 함께 뜬다.
|
||||
*/
|
||||
async function saveNow(): Promise<boolean> {
|
||||
clearIdle()
|
||||
await settle()
|
||||
if (draftId.value !== null && !options.hasChanges()) return true
|
||||
return run('manual')
|
||||
}
|
||||
|
||||
/** 떠나기 전 — 남은 변경을 저장한다. 저장한 그대로면(또는 저장에 성공하면) true */
|
||||
async function saveBeforeLeave(): Promise<boolean> {
|
||||
clearIdle()
|
||||
await settle()
|
||||
if (!options.hasChanges()) return true
|
||||
return run('leave')
|
||||
}
|
||||
|
||||
/** 자동 저장을 멈춘다 — 보내기 · 버리기 동안 */
|
||||
function suspend() {
|
||||
suspended = true
|
||||
clearIdle()
|
||||
clearMaxWait()
|
||||
clearRetry()
|
||||
}
|
||||
|
||||
/** 다시 자동 저장한다 — 보내기가 실패했을 때 등 */
|
||||
function resume() {
|
||||
if (stopped) return
|
||||
suspended = false
|
||||
touch()
|
||||
}
|
||||
|
||||
/** 더 저장하지 않는다 — 보냈거나 버렸다 */
|
||||
function stop() {
|
||||
stopped = true
|
||||
suspend()
|
||||
}
|
||||
|
||||
/** 양식을 새로 채웠다 — 이어 쓰는 저장본이면 그 id 와 저장 시각으로 시작한다 */
|
||||
function reset(existing: ExistingDraft | null = null) {
|
||||
generation += 1
|
||||
clearIdle()
|
||||
clearMaxWait()
|
||||
clearRetry()
|
||||
inFlight = null
|
||||
queued = false
|
||||
suspended = false
|
||||
stopped = false
|
||||
lastStartedAt = Number.NEGATIVE_INFINITY
|
||||
retryCount = 0
|
||||
draftId.value = existing && !existing.keepOriginal ? existing.id : null
|
||||
savedAt.value = existing?.savedAt ?? null
|
||||
status.value = existing ? 'saved' : 'idle'
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
// 화면이 사라지면 더 저장하지 않는다 — 진행 중인 저장은 끝까지 받아 draftId 를 맞춘다
|
||||
// (보내기를 누르고 바로 떠난 경우 — 보내기가 그 저장을 기다렸다가 새 draftId 로 보낸다)
|
||||
onScopeDispose(stop)
|
||||
|
||||
return {
|
||||
draftId: readonly(draftId),
|
||||
status: readonly(status),
|
||||
savedAt: readonly(savedAt),
|
||||
lastError: readonly(lastError),
|
||||
touch,
|
||||
saveNow,
|
||||
saveBeforeLeave,
|
||||
settle,
|
||||
suspend,
|
||||
resume,
|
||||
stop,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getErrorMessage, notifyApiError, toApiError } from './useApi'
|
||||
import { useMail } from './useMail'
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_DISCARDED_MESSAGE, DRAFT_RESTORED_MESSAGE, DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 쓰기 화면의 휴지통 · 되돌리기 (디자인 README "2단계 › 임시 저장" · 계약 2절 "임시 저장")
|
||||
// - 저장본을 휴지통으로 옮긴다 — DELETE 가 아니라 PATCH { mailboxId: 휴지통 } 이다. DELETE 는 이미 휴지통에 있는 메일을 영구 삭제한다 —
|
||||
// 다른 창에서 먼저 휴지통으로 옮긴 저장본이 되돌릴 수 없게 지워지지 않게.
|
||||
// - 휴지통 id 를 모르면 메일함을 먼저 불러온다. 그래도 모르면 지우지 않고 실패다 — 메일함 목록을 못 받았으면 그 오류,
|
||||
// 받았는데 휴지통이 없으면 BIZ_001 을 알린다.
|
||||
// - 옮겼으면 알림 "임시 저장한 메일을 휴지통으로 옮겼습니다." + [되돌리기](임시보관함으로 — "임시보관함으로 되돌렸습니다.").
|
||||
// 이미 없는 저장본(RES_001 — 다른 곳에서 보냈거나 지웠다)이면 버린 것과 같다 — 알리지 않는다.
|
||||
|
||||
/** moved — 옮겼다 · gone — 이미 없다(RES_001) · failed — 옮기지 못했다(알렸다) */
|
||||
export type DraftTrashResult = 'moved' | 'gone' | 'failed'
|
||||
|
||||
export function useDraftTrash() {
|
||||
const mailStore = useMailStore()
|
||||
const { updateMessage } = useMail()
|
||||
const toast = useToast()
|
||||
|
||||
async function moveToTrash(draftId: string): Promise<DraftTrashResult> {
|
||||
let trash = mailStore.mailboxByRole('trash')
|
||||
if (!trash) {
|
||||
// 메일함을 아직 못 받았다(받는 중 · 실패) — 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
|
||||
const loadError = await mailStore.loadMailboxes({ background: true })
|
||||
trash = mailStore.mailboxByRole('trash')
|
||||
if (!trash) {
|
||||
notifyApiError(loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') })
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
try {
|
||||
await updateMessage(draftId, { mailboxId: trash.id }, { silent: ['RES_001'] })
|
||||
return 'moved'
|
||||
} catch (error) {
|
||||
// 그 밖의 실패는 인터셉터가 알렸다
|
||||
return toApiError(error).code === 'RES_001' ? 'gone' : 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
/** 되돌리기 — 버린 저장본을 임시보관함으로 (PATCH { mailboxId }) */
|
||||
async function restore(draftId: string, draftsMailboxId: string) {
|
||||
if (await mailStore.moveMessage(draftId, draftsMailboxId)) toast.success(DRAFT_RESTORED_MESSAGE)
|
||||
}
|
||||
|
||||
/** 저장본을 휴지통으로 옮기고 알린다 — 옮겼으면 [되돌리기] 알림. 옮겼거나 이미 없으면 개수를 서버 기준으로 다시 맞춘다 */
|
||||
async function discard(draftId: string): Promise<DraftTrashResult> {
|
||||
const result = await moveToTrash(draftId)
|
||||
if (result === 'failed') return result
|
||||
// 임시보관함 · 휴지통 개수를 서버 기준으로
|
||||
mailStore.syncCounts()
|
||||
if (result === 'moved') {
|
||||
const draftsMailbox = mailStore.mailboxByRole('drafts')
|
||||
toast.success(
|
||||
DRAFT_DISCARDED_MESSAGE,
|
||||
draftsMailbox ? { label: DRAFT_UNDO_LABEL, run: () => void restore(draftId, draftsMailbox.id) } : undefined,
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return { discard }
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { onBeforeUnmount, readonly, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, readonly, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
// 화면을 떠나기 전 확인 — 쓰던 내용을 잃는 이동을 묻는다 (디자인 README "나가기 전 확인")
|
||||
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 취소 · 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
|
||||
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
|
||||
// 남기로 하면 이동을 취소한다 — 뒤로/앞으로였으면 라우터가 주소와 방문 기록을 되돌린다.
|
||||
// 묻기 전에 prepareLeave(임시 저장 등)를 기다린다 — 그 뒤에도 잃을 것이 남았을 때만 묻는다.
|
||||
// - 창 닫기 · 새로고침 · 다른 사이트로 가기: 지킬 것이 있는 동안만 beforeunload 를 걸어 브라우저 기본 확인창을 띄운다.
|
||||
// 화면 컴포넌트의 setup 에서 부른다 — 이동 가드(onBeforeRouteLeave · onBeforeRouteUpdate)는 RouterView 가 그린 화면에 걸린다.
|
||||
// guarding: 이동 가드가 준비 · 답을 기다리는 동안 true — 그동안 화면이 스스로 주소를 바꾸면(router.replace) 그 이동을 취소해 버린다.
|
||||
|
||||
export interface LeaveConfirmOptions {
|
||||
/**
|
||||
@@ -24,6 +26,16 @@ export interface LeaveConfirmOptions {
|
||||
* 주지 않으면 같은 경로 안의 이동은 묻지 않는다.
|
||||
*/
|
||||
leavesOnUpdate?: (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean
|
||||
/**
|
||||
* 묻기 전에 할 일 — 예: 남은 변경을 임시 저장한다. 끝난 뒤 active 를 다시 읽어 잃을 것이 남았을 때만 묻는다.
|
||||
* 이동은 이것이 끝날 때까지 기다린다 (그사이 다른 이동이 오면 라우터가 앞 이동을 취소한다)
|
||||
*/
|
||||
prepareLeave?: () => Promise<unknown>
|
||||
/**
|
||||
* 묻는 중에 잃을 것이 없어졌을 때(active 가 false) 그대로 떠날지 — 예: 늦게 끝난 떠나기 전 저장(이제 잃을 것이 없다).
|
||||
* 주지 않거나 false 면 그 이동을 취소한다 — 세션이 끝난 때처럼 다른 흐름(로그인 화면으로)이 이어받을 때
|
||||
*/
|
||||
leaveWhenSettled?: () => boolean
|
||||
}
|
||||
|
||||
export function useLeaveConfirm(options: LeaveConfirmOptions) {
|
||||
@@ -52,8 +64,34 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
|
||||
resolve?.(leave)
|
||||
}
|
||||
|
||||
function confirmLeave(): true | Promise<boolean> {
|
||||
return options.active() ? ask() : true
|
||||
/** 떠나기 전 준비 — 이동이 겹쳐도 한 번만 한다 */
|
||||
let preparing: Promise<unknown> | null = null
|
||||
function prepare(): Promise<unknown> {
|
||||
if (!options.prepareLeave) return Promise.resolve()
|
||||
if (!preparing) {
|
||||
// 준비가 실패해도 이동 가드를 깨뜨리지 않는다 — 그다음 active 로 물을지 정한다
|
||||
preparing = options
|
||||
.prepareLeave()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
preparing = null
|
||||
})
|
||||
}
|
||||
return preparing
|
||||
}
|
||||
|
||||
/** 답을 기다리는 이동 가드 수 — 이동이 겹치면 여럿이다 */
|
||||
const guards = ref<number>(0)
|
||||
|
||||
async function confirmLeave(): Promise<boolean> {
|
||||
if (!options.active()) return true
|
||||
guards.value += 1
|
||||
try {
|
||||
await prepare()
|
||||
return options.active() ? await ask() : true
|
||||
} finally {
|
||||
guards.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => confirmLeave())
|
||||
@@ -78,7 +116,7 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
|
||||
watch(options.unloadActive ?? options.active, listenBeforeUnload, { immediate: true })
|
||||
|
||||
watch(options.active, (active) => {
|
||||
if (!active && pending) answer(false)
|
||||
if (!active && pending) answer(options.leaveWhenSettled?.() ?? false)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -89,6 +127,8 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
|
||||
|
||||
return {
|
||||
confirming: readonly(confirming),
|
||||
/** 이동 가드가 준비(prepareLeave) · 답을 기다리는 중 */
|
||||
guarding: computed(() => guards.value > 0),
|
||||
/** 계속 쓰기 — 이동을 취소한다 */
|
||||
stay: () => answer(false),
|
||||
/** 나가기 — 이동을 그대로 보낸다 */
|
||||
|
||||
@@ -8,13 +8,21 @@ import {
|
||||
} from '@/shared/constants/mail'
|
||||
import type {
|
||||
Attachment,
|
||||
Draft,
|
||||
DraftPayload,
|
||||
DraftSaved,
|
||||
MailQuota,
|
||||
MailSignature,
|
||||
Mailbox,
|
||||
MessageDetail,
|
||||
MessageListQuery,
|
||||
MessageSummary,
|
||||
RecipientSuggestion,
|
||||
RecipientSuggestionQuery,
|
||||
SaveSignaturePayload,
|
||||
SendMessagePayload,
|
||||
SendMessageResult,
|
||||
SignatureImage,
|
||||
UpdateMessagePayload,
|
||||
} from '@/shared/types/mail'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
@@ -86,13 +94,71 @@ export function useMail() {
|
||||
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
function updateMessage(id: string, changes: UpdateMessagePayload): Promise<MessageSummary> {
|
||||
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes)
|
||||
function updateMessage(
|
||||
id: string,
|
||||
changes: UpdateMessagePayload,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<MessageSummary> {
|
||||
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes, options)
|
||||
}
|
||||
|
||||
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 (204) */
|
||||
async function deleteMessage(id: string): Promise<void> {
|
||||
await api.delete(`/messages/${segment(id)}`)
|
||||
async function deleteMessage(id: string, options?: MailRequestOptions): Promise<void> {
|
||||
await api.delete(`/messages/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
// ── 임시 저장 (계약 2절 "임시 저장") ─────────────────────
|
||||
|
||||
/** 이어 쓸 임시 저장본 — 없거나 초안이 아니면 404 RES_001 */
|
||||
function getDraft(id: string, options?: MailRequestOptions): Promise<Draft> {
|
||||
return api.get<Draft, Draft>(`/drafts/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/** 처음 저장 — 201 DraftSaved. 본문 · 첨부는 발송과 같은 한도라 시간 한도도 발송과 같다 */
|
||||
function createDraft(payload: DraftPayload, options?: MailRequestOptions): Promise<DraftSaved> {
|
||||
return api.post<DraftSaved, DraftSaved>('/drafts', payload, { timeout: MAIL_SEND_TIMEOUT_MS, ...options })
|
||||
}
|
||||
|
||||
/**
|
||||
* 다시 저장 — 서버가 새 초안을 만들고 옛것을 지운다(응답의 id · attachments 로 바꿔 쓴다).
|
||||
* 옛 초안이 다른 곳에서 먼저 바뀌었거나 지워졌으면 409 MAIL_008 — 부르는 쪽이 createDraft 로 새로 남긴다.
|
||||
*/
|
||||
function updateDraft(id: string, payload: DraftPayload, options?: MailRequestOptions): Promise<DraftSaved> {
|
||||
return api.put<DraftSaved, DraftSaved>(`/drafts/${segment(id)}`, payload, {
|
||||
timeout: MAIL_SEND_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 서명 (계약 2절 "서명") ─────────────────────────────
|
||||
|
||||
/** 저장한 적 없으면 기본값(둘 다 넣기 · 빈 서명)이 온다 */
|
||||
function getSignature(options?: MailRequestOptions): Promise<MailSignature> {
|
||||
return api.get<MailSignature, MailSignature>('/mail-signature', options)
|
||||
}
|
||||
|
||||
function saveSignature(payload: SaveSignaturePayload, options?: MailRequestOptions): Promise<MailSignature> {
|
||||
return api.put<MailSignature, MailSignature>('/mail-signature', payload, options)
|
||||
}
|
||||
|
||||
/** 서명 그림 한 장 (multipart `file`) — PNG · JPEG · GIF, 300KB 이하, 한 사람 3장까지 */
|
||||
function uploadSignatureImage(file: File, options?: MailRequestOptions): Promise<SignatureImage> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post<SignatureImage, SignatureImage>('/mail-signature/images', form, options)
|
||||
}
|
||||
|
||||
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ──────────────
|
||||
|
||||
/** 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만 */
|
||||
function listRecipientSuggestions(
|
||||
query: RecipientSuggestionQuery,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<PaginatedResult<RecipientSuggestion>> {
|
||||
return api.get<PaginatedResult<RecipientSuggestion>, PaginatedResult<RecipientSuggestion>>(
|
||||
'/recipient-suggestions',
|
||||
{ ...options, params: query },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,5 +226,12 @@ export function useMail() {
|
||||
uploadAttachment,
|
||||
attachmentDownloadUrl,
|
||||
downloadAttachment,
|
||||
getDraft,
|
||||
createDraft,
|
||||
updateDraft,
|
||||
getSignature,
|
||||
saveSignature,
|
||||
uploadSignatureImage,
|
||||
listRecipientSuggestions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
|
||||
import {
|
||||
RECIPIENT_SEARCH_DELAY_MS,
|
||||
useRecipientSuggestions,
|
||||
} from '@/composables/useRecipientSuggestions'
|
||||
import type { RecipientSuggestion } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
} from '@/test-utils/mock-api'
|
||||
|
||||
const DOYUN: RecipientSuggestion = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }
|
||||
const JUNHO: RecipientSuggestion = { name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' }
|
||||
const VENDOR: RecipientSuggestion = { name: '협력사 담당', email: 'ops@vendor.example', source: 'recent' }
|
||||
|
||||
function page(items: RecipientSuggestion[], hasNext: boolean) {
|
||||
return ok({ items, pageInfo: { hasNext, nextCursor: hasNext ? 'next' : null, total: null } })
|
||||
}
|
||||
|
||||
describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () => {
|
||||
let scope: EffectScope
|
||||
let clock: ManualClock
|
||||
let requests: MockRequest[]
|
||||
let excluded: Set<string>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
scope = effectScope()
|
||||
clock = createManualClock()
|
||||
excluded = new Set()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function setup(complete: boolean, searchResult: RecipientSuggestion[] = []) {
|
||||
requests = installMockApi((request) =>
|
||||
request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false),
|
||||
)
|
||||
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
|
||||
if (!handle) throw new Error('scope')
|
||||
return handle
|
||||
}
|
||||
|
||||
const searches = () => requests.filter((request) => request.params.q !== undefined).map((request) => request.params.q)
|
||||
|
||||
it('쓰기 화면을 열 때 q 없이 100개를 한 번 받아 두고 화면에서 거른다', async () => {
|
||||
const handle = setup(true)
|
||||
handle.preload()
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => request.params)).toEqual([{ limit: 100 }])
|
||||
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
|
||||
|
||||
excluded = new Set([DOYUN.email])
|
||||
expect(handle.matchesFor('ㄷㅇ')).toEqual([])
|
||||
})
|
||||
|
||||
it('받아 둔 목록이 전부면 서버에 다시 묻지 않는다', async () => {
|
||||
const handle = setup(true)
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
handle.search('vendor')
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual([])
|
||||
})
|
||||
|
||||
it('모자라면 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 — 초성은 묻지 않는다', async () => {
|
||||
const handle = setup(false, [VENDOR])
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
handle.search('ven')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
|
||||
handle.search('vendor')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual([])
|
||||
clock.advance(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(searches()).toEqual(['vendor'])
|
||||
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
|
||||
|
||||
handle.search('ㄷㅇ')
|
||||
handle.search('vendor')
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual(['vendor'])
|
||||
})
|
||||
|
||||
it('미리 받기가 실패하면 치는 대로 q 로 묻고(자모만 친 글자는 묻지 않는다), 다음 preload(칸에 초점)가 다시 받는다', async () => {
|
||||
let failPreload = true
|
||||
requests = installMockApi((request) => {
|
||||
if (request.params.q !== undefined) return page([VENDOR], false)
|
||||
if (failPreload) return fail(503, 'MAIL_001')
|
||||
return page([DOYUN, JUNHO], false)
|
||||
})
|
||||
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
|
||||
if (!handle) throw new Error('scope')
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
handle.search('ㄷㅇ')
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual([])
|
||||
handle.search('vendor')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual(['vendor'])
|
||||
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
|
||||
|
||||
failPreload = false
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.params.q === undefined)).toHaveLength(2)
|
||||
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
|
||||
})
|
||||
|
||||
it('추천을 못 받아도 조용히 넘어간다 (주소를 그대로 칠 수 있다)', async () => {
|
||||
installMockApi(() => fail(503, 'MAIL_001'))
|
||||
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
|
||||
handle?.preload()
|
||||
await flushPromises()
|
||||
expect(handle?.matchesFor('도윤')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { onScopeDispose, ref, shallowRef, type InjectionKey } from 'vue'
|
||||
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import { useMail } from './useMail'
|
||||
import type { RecipientSuggestion } from '@/shared/types/mail'
|
||||
import { normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import {
|
||||
RECIPIENT_SUGGESTION_LIMIT,
|
||||
filterRecipientSuggestions,
|
||||
hasHangulJamo,
|
||||
type RecipientMatch,
|
||||
} from '@/shared/utils/recipient-match'
|
||||
|
||||
// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 디자인 README "2단계 › 받는 사람 자동 완성")
|
||||
// - 쓰기 화면을 열 때 q 없이 limit=100 으로 한 번 받아 두고, 치는 대로 화면에서 거른다(초성 포함 — recipient-match.ts)
|
||||
// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다.
|
||||
// 받은 것은 받아 둔 목록에 더한다. 서버는 초성을 모르므로 초성 · 홀로 쓴 자모가 든 글자는 묻지 않는다
|
||||
// - 추천은 보조 정보다 — 못 받아도 알리지 않는다 (silent). 주소는 그대로 칠 수 있다.
|
||||
// 미리 받기가 실패하면 치는 대로 q 로 묻고(초성 · 자모만 친 글자는 여전히 묻지 않는다), 다음에 칸에 초점이 올 때 다시 받아 본다
|
||||
// 쓰기 화면이 만들어 provide 하고(RECIPIENT_SUGGESTIONS), 받는 사람 칸(RecipientField)이 inject 해 쓴다.
|
||||
|
||||
/** 처음 받아 두는 개수 — 계약의 최대 limit */
|
||||
export const RECIPIENT_SUGGESTION_PRELOAD = 100
|
||||
/** 서버에 다시 묻기 전에 기다리는 시간 */
|
||||
export const RECIPIENT_SEARCH_DELAY_MS = 250
|
||||
/** 서버가 받는 검색 글자 길이 (계약: q 1~100자) */
|
||||
const SEARCH_QUERY_MAX_LENGTH = 100
|
||||
|
||||
export interface RecipientSuggestionsOptions {
|
||||
/** 이미 넣은 주소 — 받는 사람 · 참조 · 숨은참조 모두 (정규화한 주소) */
|
||||
excluded: () => ReadonlySet<string>
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
export interface RecipientSuggestionsHandle {
|
||||
/** 쓰기 화면을 열 때 · 받는 사람 칸에 초점이 올 때 — 받는 중이거나 받았으면 아무것도 하지 않는다 (실패했으면 다시 받는다) */
|
||||
preload: () => void
|
||||
/** 친 글자에 맞는 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
|
||||
matchesFor: (query: string) => RecipientMatch[]
|
||||
/** 맞는 것이 모자라면 서버에 묻는다 (기다렸다가 — 그사이 또 치면 마지막 글자만) */
|
||||
search: (query: string) => void
|
||||
}
|
||||
|
||||
export const RECIPIENT_SUGGESTIONS: InjectionKey<RecipientSuggestionsHandle> = Symbol('recipient-suggestions')
|
||||
|
||||
export function useRecipientSuggestions(options: RecipientSuggestionsOptions): RecipientSuggestionsHandle {
|
||||
const { listRecipientSuggestions } = useMail()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
/** 받아 둔 추천 — 처음 받은 것 + 검색으로 더 받은 것 (주소가 겹치지 않게) */
|
||||
const pool = shallowRef<readonly RecipientSuggestion[]>([])
|
||||
/** 처음 받은 목록이 전부다 — 서버에 더 물을 것이 없다 */
|
||||
const complete = ref<boolean>(false)
|
||||
/** 미리 받기 — 실패했으면 다음 preload 가 다시 받는다 */
|
||||
let preloadState: 'idle' | 'loading' | 'done' | 'failed' = 'idle'
|
||||
let searchTimer: ClockTimer | null = null
|
||||
let controller: AbortController | null = null
|
||||
/** 이미 물어본 글자 — 같은 글자를 두 번 묻지 않는다 */
|
||||
const asked = new Set<string>()
|
||||
|
||||
function merge(items: readonly RecipientSuggestion[]) {
|
||||
const known = new Set(pool.value.map((item) => normalizeEmail(item.email)))
|
||||
const added = items.filter((item) => {
|
||||
const key = normalizeEmail(item.email)
|
||||
if (known.has(key)) return false
|
||||
known.add(key)
|
||||
return true
|
||||
})
|
||||
if (added.length > 0) pool.value = [...pool.value, ...added]
|
||||
}
|
||||
|
||||
function preload() {
|
||||
if (preloadState === 'loading' || preloadState === 'done') return
|
||||
preloadState = 'loading'
|
||||
void listRecipientSuggestions({ limit: RECIPIENT_SUGGESTION_PRELOAD }, { silent: true })
|
||||
.then((page) => {
|
||||
preloadState = 'done'
|
||||
merge(page.items)
|
||||
complete.value = !page.pageInfo.hasNext
|
||||
})
|
||||
.catch(() => {
|
||||
// 치는 대로 서버에 묻는다(search) — 다음에 칸에 초점이 오면 다시 받아 본다
|
||||
preloadState = 'failed'
|
||||
})
|
||||
}
|
||||
|
||||
function matchesFor(query: string): RecipientMatch[] {
|
||||
return filterRecipientSuggestions(query, pool.value, options.excluded())
|
||||
}
|
||||
|
||||
function cancelSearch() {
|
||||
if (searchTimer !== null) clock.clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
controller?.abort()
|
||||
controller = null
|
||||
}
|
||||
|
||||
async function ask(query: string) {
|
||||
searchTimer = null
|
||||
asked.add(query)
|
||||
controller = new AbortController()
|
||||
const { signal } = controller
|
||||
try {
|
||||
const page = await listRecipientSuggestions(
|
||||
{ q: query, limit: RECIPIENT_SUGGESTION_LIMIT * 2 },
|
||||
{ silent: true, signal },
|
||||
)
|
||||
if (!signal.aborted) merge(page.items)
|
||||
} catch {
|
||||
// 보조 정보 — 못 받으면 받아 둔 목록만 보인다
|
||||
asked.delete(query)
|
||||
}
|
||||
}
|
||||
|
||||
function search(query: string) {
|
||||
cancelSearch()
|
||||
const text = query.trim()
|
||||
if (text === '' || text.length > SEARCH_QUERY_MAX_LENGTH || complete.value) return
|
||||
if (hasHangulJamo(text) || asked.has(text)) return
|
||||
if (matchesFor(text).length >= RECIPIENT_SUGGESTION_LIMIT) return
|
||||
searchTimer = clock.setTimeout(() => void ask(text), RECIPIENT_SEARCH_DELAY_MS)
|
||||
}
|
||||
|
||||
onScopeDispose(cancelSearch)
|
||||
|
||||
return { preload, matchesFor, search }
|
||||
}
|
||||
Reference in New Issue
Block a user