From 5ce614cc0c197d3139014602312ac2c1026a1a33 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 19:37:01 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=EC=9D=84=20=EA=B3=84=EC=95=BD=207=EC=A0=88?= =?UTF-8?q?=20=C2=B7=20=EB=94=94=EC=9E=90=EC=9D=B8=20README=20=C2=B7=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=EC=97=90=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계약 7절(API 둘 · 글 파일 규칙)과 머리말의 달력 날짜 예외, 웹 · 앱 디자인 README 의 업데이트 내역 절, 세 스택 스킬 행을 더했다. 보드의 목록 최대 개수를 구현(100)에 맞추고, 편집 도구가 보이지 않는 글자의 이스케이프를 실제 글자로 푼 일을 공통 규칙에 적는다. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/coding-common.md | 1 + .claude/skills/flutter/SKILL.md | 7 +- .claude/skills/nestjs/SKILL.md | 2 + .claude/skills/vue3/SKILL.md | 3 +- docs/design/app/README.md | 33 ++++++++- docs/design/webmail/README.md | 89 ++++++++++++++++++++++++ docs/design/webmail/ReleaseNotes.dc.html | 2 +- docs/mail/api-contract.md | 81 +++++++++++++++++++++ 8 files changed, 213 insertions(+), 5 deletions(-) diff --git a/.claude/rules/coding-common.md b/.claude/rules/coding-common.md index 2e248cd..36244b3 100644 --- a/.claude/rules/coding-common.md +++ b/.claude/rules/coding-common.md @@ -18,6 +18,7 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙 - 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양 — backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`) - **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]]) +- 소스에 보이지 않는 글자(nbsp · U+2028 · U+FEFF 등)는 `\uXXXX` 이스케이프로 적되, **저장한 뒤 바이트로 확인한다** — 편집 도구가 U+00A0 이상의 이스케이프를 실제 글자로 풀어 저장한 일이 있다(2026-11-10 — 정규식 안의 U+2028 이 줄 끝이 되어 컴파일 실패, 16단계에서는 U+FEFF). 풀리면 `String.fromCharCode(0x2028)` 처럼 글자 코드로 만든다 ## 네이밍 규약 diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index dfb7c1a..c01bf4a 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -40,7 +40,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) | | **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 | | **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 | -| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) | +| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · `/settings/release-notes`(+ `/:noteId`) · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · 업데이트 내역(목록 → 글, 아래 행) · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) | +| **업데이트 내역 (`ReleaseNotes` B — 2026-11-10)** | `lib/features/settings/` — data(`models/release_note.dart` · `repositories/release_notes_repository.dart`) · presentation(`pages/release_notes_page.dart` · `release_note_page.dart` · providers `release_notes_providers.dart` · `release_notes_list_controller.dart` · `release_note_controller.dart` · widgets `release_notes_parts.dart` · 글 `release_notes_texts.dart`), 달력 날짜 `core/utils/calendar_date.dart`(`CalendarDate`) + `korean_format.dart` 의 `koreanCalendarDate`, 시험 도구 `test/support/release_notes_fakes.dart`(`FakeReleaseNotesBackend` · 보드의 네 글 `kBoardReleaseNotes`) | 계약 7절(`GET /api/release-notes?cursor=&limit=` · `GET /api/release-notes/{id}` — 올리는 API 없음, 글은 서버의 글 파일). 설정 › 앱 정보의 앱 버전 다음 줄 → 목록 `/settings/release-notes` → 글 `/settings/release-notes/:noteId`(둘 다 `parentNavigatorKey: rootNavigatorKey`, 글은 목록 라우트의 자식이라 주소로 바로 열어도 아래에 목록). 목록은 `releaseNotesListProvider`(autoDispose — 화면을 열 때마다 새로 받는다 · 첫 쪽 · [다시 시도]는 `silent`, [더 보기]는 알림 + 받은 줄 그대로 · 겹친 id 는 건너뜀), 글은 `releaseNoteProvider(id)`(autoDispose family · `silent` — 없는 글 404 `RES_001` 은 [목록으로], 그 밖은 [다시 시도]). 둘 다 `releaseNotesAccountProvider`(세션의 주소 — 메일의 `mailAccountProvider` 와 같은 규칙, 모듈끼리 import 하지 않아 따로)를 듣고 세션이 바뀌면 처음부터 · 세대(`_seq`)와 시작할 때의 Ref 로 늦은 응답을 버린다. 응답 모양이 틀리면 `ReleaseNotesMalformedResponse`(`SYS_001` — 인터셉터를 다 지난 뒤라 [더 보기]만 컨트롤러가 `errorNotifierProvider` 로 알린다, 메일 `DownloadInterrupted` 와 같은 결). **날짜는 `CalendarDate` 로만**(`YYYY-MM-DD` 를 글로 나눠 쥔다 — `DateTime` 으로 바꾸지 않는다, 없는 날은 모양 틀림). 다른 글 줄은 `context.pushReplacement`(쌓지 않는다 — 뒤로는 늘 목록, go_router 17 은 void 를 돌려준다). 글은 서버 글 그대로(마크다운 해석 · 링크 없음) · 한글 줄바꿈 `KeepAllText`(시험은 `findCopy`). [더 보기]는 받는 동안 누름만 무시한다(초점 지키기 — 메일 검색 [더 찾기]와 같다) — 도는 표시가 있어 시험은 `pumpAndSettle` 대신 `pump(100ms)` | +| 불러오기 상태 틀 | `lib/shared/widgets/load_states.dart` · `load_state_texts.dart` | 가운데 상태 `StateLayout`(아이콘 원 72 · 제목 · 글 · 단추) · 받지 못함 `LoadErrorView`(사전 문구 · 문의 코드 · [다시 시도] — `RES_001` + `onLeave` 면 `leaveLabel` 단추, 둘은 함께 준다) · 뼈대 `LoadingSkeleton`(화면 낭독기 한 줄 + 막대는 읽지 않음) · `SkeletonBar`(메일은 모서리 4, 업데이트 내역은 `rounded`) · `traceCodeOf`. 2026-11-10 메일 목록(`mail_list_states.dart` 의 `MailStateLayout` · `MailListError` · `_SkeletonBar`)에서 모양 · 동작 그대로 옮겼다 — 메일 목록 · 검색 · 읽기 · 쓰기와 업데이트 내역이 쓴다. 메일 줄 꼴의 뼈대(`MailListSkeleton`) · 빈 메일함 · 목록 끝 · 30일 줄은 메일에 남았다. `MailListTexts.retry` · `traceCode` 는 `LoadStateTexts` 를 가리킨다 | | 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 | | 예제 feature(참조용) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남겼다. 앱 API 에 `/users` 는 없다 | | 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) | @@ -103,7 +105,7 @@ lib/ │ ├── settings/ ← 모듈 — 주소 /settings/... (다른 모듈의 설정 절을 받아 붙인다) │ └── user/ ← 예제(참조용) └── shared/ - └── widgets/ ← 앱 공통 위젯 (자리 화면 등) + └── widgets/ ← 앱 공통 위젯 (자리 화면 · 확인 창 · 불러오기 상태 틀 등) ``` --- @@ -310,6 +312,7 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser 가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다. InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄). 두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`). + go_router 17 의 `context.pushReplacement` · `replace` 는 void 다(`push` 만 Future) — 바꿔 끼울 화면은 `pushReplacement`(새 page key — 앞 화면의 PageStorage 스크롤 자리를 물려받지 않는다, 업데이트 내역의 다른 글 줄). - [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`). - [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`). `.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다. diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index ac8a079..f2873e8 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -47,6 +47,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | 앱 새 메일 알림 (앱 1단계 — FCM) | `modules/push` · `modules/mail/new-mail-push.service.ts` · `MailEventsService.hold` · `common/guards/app-client.guard.ts` | 계약 1절 "앱 새 메일 알림 · 앱 설정". **등록** `PUT`·`DELETE /api/push-registrations/current` · `DELETE …/current/unseen`(`push-registrations.controller.ts` — `@UseGuards(JwtAuthGuard, AppClientGuard)` + `@ApiBearerAuth()`: 앱 토큰만, 웹 쿠키 세션은 403 `AUTH_003`, 제한된 세션 403 `AUTH_010`). 표 `push_registrations`(마이그레이션 `AddPushRegistrations` — 세션(sid)마다 한 줄 · FID 유일 · 세션 표와 외래 키 없음). PUT 은 한 트랜잭션(`push-registrations.repository.ts` — 세션이 살아 있는 · 제한 없는 앱 세션인지 → 같은 FID 의 다른 줄 지우기(같은 FID 는 `pg_advisory_xact_lock('PSH1', hashtext(fid))` 로 차례) → upsert(새 줄만 `seen_at` = 지금) → 직원마다 10줄). 보낼 대상 · 붙잡을 직원은 늘 세션 표와 함께 고른다(살아 있는 · 제한 없는 앱 세션 — 끊은 세션은 `revoked_at` 으로 바로 빠진다), 끝난 세션의 줄은 5분 정리 `push-cleanup.service.ts`(그 휴대폰을 직원이 건드리지 않고 끝난 세션 — 저절로 끝남 · 다른 곳의 비밀번호 바꾸기 · 앱 갱신 유예 · 토큰 재사용 감지 — 의 휴대폰에만 한 번 session-ended, 끊은 까닭 표는 `push.constants.ts` 의 `SESSION_ENDED_NOTICE` 한 곳(`Record` — 까닭을 더하면 거기서 정해야 컴파일된다) — `DELETE … RETURNING`). 등록이 바뀐 소식은 `PushRegistrationsService.onChanged`(메일 쪽이 듣는다 — 푸시 모듈은 메일을 모른다, 사내 도메인만 `MailEngineModule` 의 `MAIL_SETTINGS` 에서). **보내기** 포트 `PUSH_SENDER`(`push-sender.port.ts` — 알림은 종류 · 개수뿐) · 구현 `configured-push-sender.ts`(env `FCM_SERVICE_ACCOUNT_KEY` — 없으면 보내지 않고 기동은 한다) · `fcm/`: 키 읽기 `fcm-credentials.ts`(env 검사와 같은 함수 — 까닭에 값을 싣지 않는다), OAuth `fcm-auth.ts`(RS256 JWT → `oauth2.googleapis.com/token`, 만료 5분 전까지 기억 · 함께 부르면 한 번), 보내기 `fcm-sender.ts`(`message.fid` — 실패 표는 계약, 시도 3번 · 20초 · 서버 전체 4 · 429 는 `Retry-After` 가 있을 때만 그때 한 번 · 보내기 직전(다시 하기마다) 부른 쪽의 `stillValid` — 그 세션의 줄이 아직 그 FID 인가(`isDeliverable`) · 로그는 종류마다 한 시간에 한 번 · 서버 종료로 끊긴 것은 로그 없이), 메시지 `fcm-message.ts`(순수 함수 한 곳 — 글은 `push-text.ts`), HTTP `fcm-transport.ts`(전역 fetch · 두 주소 고정 · 응답 64KB — 시험만 `forTesting`). 새 의존성 없음. **새 메일** `NewMailPushService`(메일 모듈): 보낼 기기가 있는 사내 직원의 엔진 연결을 붙잡고(`MailEventsService.hold` — SSE 와 같은 연결 · 같은 판정, 신호만 받는다 · 연결 상한에 세지 않는다, 함께 여는 수 4, 실패하면 1 · 5 · 15 · 60분 — 계정 문제는 60분부터), 새 메일 신호 → 같은 직원 30초에 한 번(첫 알림 바로 · 끝에 한 번) → 기기마다 N(`MailProvider.countInboxUnreadSince` — 받은편지함 · 안 읽음 · 초안 아님 · `seen_at` 뒤, 한 요청 8개씩) → N ≥ 1 이면 보낸다 · 받지 않는 기기는 FID 가 그대로일 때만 지운다. 붙잡을 때마다 따라잡기(10분 안 · `seen_at` · `last_pushed_at` 의 **다음 초**부터 — 받은 시각은 초, N 은 `seen_at` 의 초부터 같은 초를 넣는다. 엔진의 `after` 는 그 초를 넣지 않아(0.16.27 — 2026-11-08 확인) 어댑터가 한 초 앞을 넘긴다. 붙잡은 뒤 신호가 먼저 오면 따라잡기는 건너뛰고, 따라잡기 뒤 첫 신호 보내기는 새로 받은 메일이 있는 기기에만 — 붙잡은 직후의 메일 하나에 한 번). 붙잡기가 끝나면(`ended` — 계정 문제 60분 · 그 밖 1분 뒤 다시, 서버 종료가 아닌데 허브가 닫혀도 알린다) 간격 끝 차례도 지운다, 등록이 바뀐 소식은 기다림을 풀고 바로 붙잡아 본다. 같은 직원의 보내기는 `KeyedQueue` 로 차례 | | 앱 설정 (앱 1단계) | `modules/app-config` | `GET /api/app-config`(로그인 없음 · 기본 요청 수 한도 · `Cache-Control: no-store`) → `{ minSupportedVersion: { android, ios: null }, pushAvailable }`. 최소 판은 코드 상수 `app-config.constants.ts` — 옛 앱을 막을 API 변경과 같은 커밋에서 올린다. `pushAvailable` = `PUSH_SENDER.available` | | 휴대폰 앱 받기 (사내 배포 — 2026-11-08) | `modules/app-releases` · `modules/ops/app-release-commands.ts` · `shared/utils/download-headers.ts` | 계약 1절 "휴대폰 앱 받기" — 스토어 없이 직접 빌드한 APK 를 로그인한 직원에게만. `GET /api/app-releases/android/latest`(`{ version, sizeBytes, releasedAt }` · `private, no-store` — 없으면 404 `RES_001`, 컨트롤러 필터가 404 를 debug 로) · `…/latest/file`(GET · HEAD · Range — `downloadHeaders()` 가 대용량 첨부 받기(`serveFile`)와 같은 머리글을 만든다, 형식만 APK · 이름 `ARA-SYSTEM-<버전>.apk`). 제한된 세션은 403 `AUTH_010`. **최신 = 가장 늦게 올린 판**(`created_at` — 버전 번호가 아니다, 옛 APK 를 다시 올리면 되돌리기). 표 `app_releases`(마이그레이션 `AddAppReleases`), 파일은 업로드 볼륨 `app-releases/.apk`(`app-release-storage.ts` — 서버가 만든 UUID 만 경로로, `.part` 에 쓰고 datasync → rename, 표에 넣지 못하면 지운다, 한 시간 넘은 `.part` 는 다음 올리기가 지운다). 표에 있는데 파일이 없거나 크기가 다르면 404 + error 로그. **올리기는 API 가 없다** — 운영 도구 「휴대폰 앱 올리기」 · 「휴대폰 앱 목록」: `apk-file.ts` 의 `inspectApk`(O_NOFOLLOW · O_NONBLOCK 로 한 번 열어 fstat 일반 파일 · `.apk` · `PK\x03\x04` · 1KB~200MB · SHA-256 — 그 연 파일로 들이고, 들이며 다시 센 SHA-256 이 다르면 `AppReleaseFileChangedError`), 버전 규칙은 앱 알림 등록의 `APP_VERSION_PATTERN` 그대로(`app-release-version.ts`), app-config 의 최소 판보다 낮으면 막고 지금 최신보다 낮거나 같으면 묻는다. 실행 기록 `app-release-upload` | +| 업데이트 내역 (2026-11-10) | `modules/release-notes` · 글 폴더 `backend/release-notes/`(src 밖) | 계약 7절 "업데이트 내역" — 직원이 웹메일 · 앱에서 읽는 게시판, 글은 운영자가 저장소에 **글마다 파일 하나**로 쓴다(올리는 API 없음 · 새 에러 코드 · DB 표 · env 없음). `GET /api/release-notes`(`CursorPaginationQueryDto` + `shared/utils/offset-cursor.ts` — 최근 글부터: 날짜 내림차순 · 같은 날은 뒤 번호 먼저, `total` = 글 수) · `GET /api/release-notes/{id}`(sections · previous(바로 더 옛 글) · next(바로 더 새 글) — 없는 글 · 모양이 틀린 id 모두 404 `RES_001`, `release-note-id.pipe.ts` — 길이 32 + 정규식만 보고 경로로 쓰지 않는다, 읽어 둔 목록에서 찾는다). 보호 라우트(`JwtAuthGuard` · 제한된 세션 403 `AUTH_010`) · `private, no-store`. 해석 `release-note-format.ts`(순수 함수 · 입력 길이에 비례 — 파일 이름 → id · 날짜(있는 날짜인지 · `-2`~`-99`), `# 제목`(첫 글 줄 · 한 번) · `## 머리` · `- 한 줄` · 빈칸 두 개(또는 탭) 이어 붙임 · ``(여러 줄 · 줄 앞에서 시작 — 앞 빈칸은 되고 글 뒤에 붙이지 않는다), 한도 100 · 40 · 500(UTF-16 — 다듬은 뒤: 앞뒤 빈칸 · 안의 빈칸 하나로 · NFC), 그 밖의 줄 · 하위 목록 · 글 안 `` · `data-ara-quote-start`)는 서버만 넣는다 — 두 정화기가 사용자 HTML 의 `data-ara-*` · 주석을 지우는지 정화를 고칠 때 함께 본다(지우지 못하면 남이 꾸민 덩이가 첨부처럼 보인다). 보내기에 실패해도 링크는 "확실한 실패"(`SendNotStartedException`)에서만 거둔다 — 결과를 모르면 남긴다(메일이 나갔을 수 있다). +- [ ] **업데이트 내역 글**: 글은 `backend/release-notes/` 의 파일로만 더한다(API 없음). 글 형식을 바꾸면 `release-note-format.ts` · 계약 7절 · 그 폴더의 `README.md` · 보드 C 를 함께 고치고, `release-notes.content.spec.ts` 가 실제 폴더를 그대로 통과하는지 본다. - [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드). diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md index a98d40c..d8a56e7 100644 --- a/.claude/skills/vue3/SKILL.md +++ b/.claude/skills/vue3/SKILL.md @@ -56,7 +56,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 같은 대화 (14단계) | `src/components/mail/MessageConversation.vue`(읽기 화면 첨부 아래 칸) · `ConversationMessageRow.vue`(줄) · `ConversationMessagePanel.vue`(펼친 안) · `stores/mail/conversation.ts` · `composables/useConversationSync.ts`(언제 받을지 — 시계 주입) · `shared/utils/mail-conversation.ts`(이름 자리 · 쪽 맞추기 · 갈 곳) · `shared/constants/mail-conversation.ts` · `shared/utils/format.ts` 의 `formatConversationTime` | 받기 · 다시 받기는 계약 2절 "대화 — 14단계"의 클라이언트 줄(100통 · 이어 묻기 5번 · [더 보기], 실패는 칸 안 안내 — 404 면 칸 없음). **다시 받기 판정은 `mail.store` 의 `mailboxTotalsKey`**(메일함마다 `id:totalCount` — 바뀔 때만 `requestConversationRefresh()`, MailLayout 의 바뀜 소식에서는 부르지 않는다 — 읽음 · 중요 표시만 바뀐 소식으로 대화를 다시 받지 않게 — 칸의 안 읽은 줄은 목록처럼 굵게 그리고, 이 탭이 바꾼 읽음은 `list.ts` 의 `watchLocalReads` 로 바로 적는다: 받는 동안 바꾼 것은 `conversation.ts` 의 `withLocalReads` 가 받은 값보다 앞세우고, 다른 탭의 읽음은 다음 받기 때 — 2026-11-07), 간격은 받기가 끝난 때(`conversationBusy` 가 꺼질 때)부터 10초. 펼친 본문은 `MailBodyFrame` · 강조 쿼리 없이, 안 읽은 메일은 `markRead`(`Promise` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel`. 15단계: 칸 머리 오른쪽 [모두 펼치기] · [모두 접기](36 — `MAIL_CONVERSATION_TEXT.expandAll` · `collapseAll`, 판정 `conversationAllExpanded` — 하나씩 다 펼쳐도 [모두 접기]). 모두 펼친 줄의 본문은 화면에 들어온 줄부터 하나씩(`composables/useConversationPanelVisibility.ts` — IntersectionObserver 하나로 `[data-conversation-panel]` 을 보고 스토어의 `noteConversationPanelVisible` 에 알린다 · 차례 `detailQueue`, 차례 전에 화면 밖으로 나간 줄은 건너뛴다), 읽음은 본문이 화면에 보였을 때만(`lazyRows` — 손으로 펼친 줄은 지금처럼 받는 대로). 펼친 줄 머리 [답장](`data-conversation-head-reply` — 머리 틀 `.conversation-row__head` 안에 펼침 단추와 나란히, 임시 저장본 · 예약 · 지워진 메일엔 없다) | | 대화로 묶어 보기 (14단계) | `src/components/mail/MailConversationToggle.vue`(목록 머리 [대화로 묶기]) · `ConversationListItem.vue`(분할 보기 줄) · `ConversationTableRow.vue`(목록 보기 표 줄) · `composables/useConversationRows.ts` · `shared/utils/mail-conversation-list.ts`(응답 맞추기 · 할 일 id · 참여자 · 머리 글 · 표시 적용 · 맞추기 병합 · 삭제 뒤 갈 곳) · `stores/mail/list.ts`(출처 `conversations`) · 설정 `stores/mail-settings.store.ts` 의 `groupByConversation`(+ 설정 › 보내기 · 읽기 한 줄) · 글 `shared/constants/mail-conversation.ts` 의 `MAIL_CONVERSATION_LIST_TEXT` | 받기 · 맞추기 · 줄에 하는 일은 계약 2절 "대화 — 14단계"의 클라이언트 줄(20줄 · 맞추기는 불러온 줄 수만큼 최대 100줄 `silent` · 할 일 = `messageIds` − 붙잡힌 메일 · 줄 전체 표시는 모두 바꿨을 때만 · 옮기기 · 삭제 뒤 한 번 다시 받기). **첫 진입 짐작**은 localStorage `groupByConversationHintKey(주소)` — 설정을 받기 전 · 받지 못했으면 이 값, 받으면 서버 값, 읽기 · 쓰기가 던져도 끔. 로그아웃 · 세션 만료에 설정 스토어를 비워도 화면이 보는 값은 그대로 둔다(뒤집히면 watcher 가 떠나는 화면의 목록을 다시 부른다 — 2026-11-06 리뷰). **목록을 부르는 watcher 는 `authStore.canUseApp` 이 false 면 부르지 않는다**(소스에도 넣는다). 붙잡힌 메일이 든 줄은 고르기 칸 · 별이 `aria-disabled` + 까닭(`SEND_LATER_TEXT.heldNotSelectable`). 목록 줄의 화면 낭독 글("안 읽음, " · "중요 표시" · "첨부 있음")은 `shared/constants/mail.ts` 의 `MAIL_LIST_ROW_TEXT` — 메일 줄은 아직 직접 적은 글이라 함께 고친다. 읽기 화면 [이전] · [다음] · 위치는 대화 단위(`ListReadNav.vue`), 한 통 삭제 뒤 갈 곳은 `conversationNeighborAfterRemoval`. 분할 보기 머리의 줄인 개수 글 `listHeadShortCountText`(전체 글은 title · 숨긴 글) · 도구 줄 `selectedShort`(숨긴 글 "대화 N개 선택됨") · 표 머리 "참여자"(`participantsColumn`) · 줄 알림 `announceMove(…, { from })` → `MAIL_BULK_TEXT.movedFrom`("{메일함}에 있는 메일 N통을 …"). 영구 삭제는 `MailPurgeDialog` 의 `from` + `announcePurge(…, { from })` → `purgeTitleFrom` · `purgedFrom`(값은 `useBulkMailActions` 의 `conversationFrom` — 묶지 않은 목록 · 읽기 화면은 null). **설정 다시 받기** `composables/useMailSettingsRefresh.ts`(MailLayout — 탭이 보일 때 60초 `MAIL_SETTINGS_REFRESH_AFTER_MS` · `canUseApp`) + 스토어 `refresh()`(silent · 받는 동안 loading 으로 바꾸지 않음 · 저장 중이면 하지 않음 · 받는 사이 고른 것(`choiceSeq`) · 다시 받기(`loadSeq`) · 세션이 바뀌면 버림) · `fetchSeq` — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기" | | 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에". [이동 ▾]의 옮길 곳 · 스팸 표시 · 끌어다 놓기는 아래 「메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계)」 행 | -| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게 — 16단계부터 내용은 편집기, 아래 「부재중 서식 본문 · 회사 밖 응답 (16단계)」 행). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 일곱 칸만 — 16단계의 `htmlBody` · `external` 까지) — 계약 1절 · 2절 "부재중 자동 응답" | +| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계) / 정보 › 업데이트 내역), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게 — 16단계부터 내용은 편집기, 아래 「부재중 서식 본문 · 회사 밖 응답 (16단계)」 행). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 일곱 칸만 — 16단계의 `htmlBody` · `external` 까지) — 계약 1절 · 2절 "부재중 자동 응답" | | 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로 · 좌우가 넘치면 반대 끝으로(15단계 — EditorPopup)). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" | | 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" | | 메일 필터 (6단계) | `src/pages/settings/MailFilterPage.vue` · `components/settings/MailFilter*.vue`(목록 · 행 · 목록 위 줄 `MailFilterNotice` · 빈 상태 · 안내 상자 `MailFilterNotes` · 고치기 창 `MailFilterDialog` + 조건 `MailFilterConditionFields` · `MailFilterConditionRow` · 할 일 `MailFilterActionFields`) · `mail-filter-ui.ts` · `stores/mail-filter.store.ts` · `composables/useMailFilterActions.ts`(켜기 · 순서 · 지우기 · 되돌리기 알림) · `useMailFilterDialog.ts`(창 상태) · `shared/utils/mail-filter.ts`(검사 · 요약 · 옮길 곳 · 되돌릴 자리) · `shared/constants/mail-filter.ts` · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(`MessageMoreMenu.vue` → `/settings/mail/filters?from=`) | 바꾸는 일은 모두 `PUT /api/mail-filters` 전체 교체(받은 `version`)이고 **저장은 한 번에 하나**(`saving`). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면(그사이 목록이 안 바뀌었으면) 되돌린다. 창의 저장은 그 순간 목록의 켜짐 값을 쓴다. [되돌리기]는 지운 필터만 앞 · 뒤 필터 기준 자리에 다시 넣고, 누른 차례대로 앞 저장이 끝난 뒤 보낸다(`restoreQueue` — 기다림과 `send()` 사이에 await 를 두지 않는다 — 2026-10-28 리뷰의 경합). `MAIL_012` 는 목록 위 줄 + 창 안 안내와 [다시 불러오기](쓴 내용을 지키고 다시 받은 목록 위에 저장), 늦게 온 409 는 보낼 때의 순번(seq)이 그대로일 때만 줄을 켠다. 검사는 서버와 같은 규칙(전달 주소의 @ 앞은 dot-atom · 64자로 서버보다 좁게)이고 거절은 칸 아래(`VAL_001` · `MAIL_013` 은 알림을 끈다). 필터 화면이 떠 있지 않을 때 실패한 되돌리기는 알림으로(`attachPage` · `pageVisible`). 세션이 바뀌면 늦은 응답을 버린다(`authStore.generation`) — 계약 2절 "메일 필터", 디자인 README "6단계" | @@ -76,6 +76,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 공용 편집기 (16단계) | `components/mail/editor/signature/SignatureEditor.vue` · `SignatureToolbar.vue` · `signature-extensions.ts` | 서명 칸 · 부재중 자동 응답 내용 칸이 함께 쓴다(TipTap 청크 — 쓰는 화면이 지연 로딩). prop `bodyId` · `toolbarLabel` · `allowImages`(false 면 그림 단추 · 그림 노드 없음 · 파일 무시) · `invalid` · `disabled`, 최소 높이 CSS 변수 `--signature-editor-min-height`, 보이는 글 `getText()` · 보낼 모양 `getContent()`(쓰기 본문과 같은 `MailEditorContent`). 기본값은 서명 칸 그대로 | | 부재중 서식 본문 · 회사 밖 응답 (16단계) | `src/components/settings/VacationForm.vue`(설정 화면 `MailVacationPage` 이 `defineAsyncComponent` 로 양식째 지연 로딩 — `ComposeEditorLoadError` · `MAIL_EDITOR_RETRY`) · `VacationMessageFields.vue`(제목 + 내용 편집기 — `idPrefix` 로 사내 `vacation` · 회사 밖 `vacation-external`) · `VacationExternalFields.vue`(회사 밖에서 온 메일 — 고르기 셋 + 따로 쓰기 칸 슬롯) · `VacationPreview.vue`([사내 \| 회사 밖]) · `shared/utils/mail-vacation.ts`(`vacationExternalOf` · `vacationProblems` · `firstVacationProblem` · `vacationPayloadOf` · `vacationPreviewNote` · `vacationPreviewSendsNothing` · `vacationPreviewUsesExternal` · `emailDomainOf`) · `shared/constants/mail-vacation.ts`(`VACATION_EXTERNAL_MODES` · `VACATION_HTML_MAX_LENGTH`) · 타입 `shared/types/mail.ts` 의 `MailVacationExternal` | 계약 2절 "부재중 자동 응답"(16단계). **내용 칸은 서명 칸과 같은 편집기**(`SignatureEditor` — `allowImages: false` · `bodyId` · `toolbarLabel` · `invalid` · `disabled`, 높이 150 은 `--signature-editor-min-height`) — TipTap 청크라 양식째 지연 로딩한다(양식의 부품은 화면에서 바로 import 하지 않는다). 글자 수(600)는 편집기의 `getText().length`(서버가 받는 `textBody` 와 같다 — 링크는 "글 (주소)"), 켜 둔 동안 넘으면 빨갛게. **서식 본문은 서식이 하나라도 있을 때만**(`VacationMessageFields.getContent` — 편집 중인 문서에 표시(marks: 굵게 · 기울임 · 밑줄 · 글자 색 · 링크)가 있는지 `hasVacationFormatting`, 빈 글자 스타일은 빼고) `getContent().html`, 글만이면 null — 사내 · 회사 밖 따로, 끄면서 하는 저장도(서버가 새 칸을 쓴 직원만 원본 판 3 으로 저장한다 — 되돌리기 계획의 전제, 16단계 리뷰). 서식 본문 10,000자 넘으면 `VAL_001`. 저장 거절 `MAIL_013` 은 알림을 끄고 [저장] 옆에 `VACATION_TEXT.saveTooLong`(서식이 두 번 들어가 필터가 없어도 넘을 수 있다 — 화면을 떠났으면 그 글로 알림). 16단계 전에 저장한 글만 있는 응답은 `textToParagraphHtml` 로 문단을 만들어 연다. **회사 밖 응답**: 고르기 차례 사내와 같은 응답(처음 값) · 보내지 않기 · 따로 쓰기(열리는 칸이 맨 끝) — 따로 쓰기의 두 칸은 `v-show` 로 닫아 다른 고르기로 바꿔도 편집기째 남기고, 저장은 고르기와 상관없이 따로 쓴 제목 · 내용을 함께 보낸다(쓴 것이 없으면 null). 검사는 켤 때만 · 회사 밖 두 칸은 따로 쓰기일 때만, 보는 차례 `VACATION_PROBLEM_ORDER`(기간 · 제목 · 내용 · 회사 밖 제목 · 회사 밖 내용), 600자 넘음은 전용 글(`bodyTooLong`)이고 그 밖은 사전 문구. 바꿈은 칸(`sameVacationForm` — 고르기 · 회사 밖 제목 포함) + 두 편집기의 `isChanged()`(저장하면 `captureSavePoint` → `markSaved`). 미리 보기는 편집 중인 문서를 편집기 스키마로 그린다(`DOMSerializer` — 서명 미리 보기와 같다, v-html 없음). **`useMail().saveVacation` 은 일곱 칸을 늘 보낸다**(`htmlBody` · `external` — 주지 않으면 글만 · 사내와 같은 응답), 응답에 새 칸이 없거나 모양이 틀리면(16단계 전 서버) `vacationStateOf` 가 글만 · 사내와 같은 응답으로 맞춘다 — 타입의 두 칸이 optional 인 까닭(메일함 패널 안내는 4단계 칸만 본다) — 디자인 README "16단계 › 부재중" | | 2단계 인증 휴대폰 바꾸기 (16단계) | `src/components/settings/TwoFactorReplaceDialog.vue`(창 — 두 걸음) · `TwoFactorReplaceSteps.vue`(차례 "새 휴대폰 등록 › 지금 휴대폰으로 확인") · QR · 키는 `TwoFactorEnrollmentScan`(켜기 창과 공용 — `title` · `discardNote` · `keyLabelId` 를 받는다) · 문의 줄 `LoginSupportLine` · API `composables/useAccountSecurity.ts`(`verifyTwoFactorEnrollment` · `replaceTwoFactor`) · 타입 `shared/types/account-security.ts`(`TwoFactorVerificationPayload` · `TwoFactorReplacementPayload`) · 글 `shared/constants/account-security.ts` 의 `TWO_FACTOR_REPLACE_TEXT` · 자리 `pages/settings/AccountTwoFactorPage.vue`([2단계 인증 끄기] · 끌 수 없다는 글 앞) | 계약 5절 "2단계 인증 — 휴대폰 바꾸기". ① `POST /two-factor/enrollments`(켜져 있어도 — 열 때마다 새 키) → `…/enrollments/{id}/verification`(새 휴대폰의 코드) ② `POST /two-factor/replacements`(지금 코드 · 지금 비밀번호 — 한도 30초 `TWO_FACTOR_CHANGE_TIMEOUT_MS`). **요청은 창이 보낸다**(Pinia 액션 금지 — 12단계 규칙), 키 · 코드 · 비밀번호는 창 메모리에만 · 닫을 때와 세션 세대(`authStore.generation`)가 바뀔 때 지운다, `openSeq` 로 닫았다 다시 연 창에 앞 요청의 결과를 넣지 않는다. 칸 아래 거절: ① `AUTH_006` / ② `AUTH_006` · `AUTH_005`(silent). `AUTH_008` 은 상태를 다시 받아(`twoFactorStore.refresh`) 꺼졌으면 창을 닫고 사전 문구 알림, 아니면 ①로 돌아와 창 위 상자 + 새 키. **결과를 모르면**(`MAIL_001` · `SYS_001` — silent) 단정하지 않는 글(`unconfirmed` — 창 위 상자, ② 그대로 · 닫힌 뒤면 `unconfirmedClosed` 알림). 바꾸면 스토어에 `apply` + 알림 `done`(창이 띄운다 — 닫았거나 화면을 떠난 뒤 왔어도), 화면은 초점을 [휴대폰 바꾸기]로. [이전]은 같은 키로 ①(새 코드 칸을 비우고 다시 확인 — 지금 비밀번호는 창을 닫을 때까지 남긴다). 끌 수 없는 직원에게도 같은 단추 — 디자인 README "16단계 › 휴대폰 바꾸기" | +| 업데이트 내역 (2026-11-10) | 목록 `src/pages/settings/InfoReleaseNotesPage.vue`(+ 칸 `components/settings/ReleaseNoteList.vue`) · 글 `pages/settings/InfoReleaseNotePage.vue`(+ `components/settings/ReleaseNoteArticle.vue` · 다른 글 줄 `ReleaseNoteNeighbors.vue`) · `stores/release-notes.store.ts` · API `composables/useReleaseNotes.ts` · 응답 읽기 · 글 id 꼴 · 다른 글 차례 `shared/utils/release-notes.ts` · 타입 `shared/types/release-notes.ts` · 글 `shared/constants/release-notes.ts`(글의 원본 — 보드 ReleaseNotes 그대로) · 달력 날짜 `shared/utils/format.ts` 의 `formatCalendarDate` · 스크롤 `composables/useSettingsPageScroll.ts`(`revealSettingsPageTop`) | 계약 7절 "업데이트 내역" — 글은 운영자가 `backend/release-notes/` 에 파일로 쓰고 웹 · 앱이 같은 글을 읽는다(올리는 화면 · API 없음). 설정 메뉴 셋째 묶음 「정보」 › 「업데이트 내역」(`file-text`), 라우트 `SETTINGS_ROUTES.releaseNotes` · `releaseNote` 를 **화면 없는 상위 라우트로 묶었다**(`info/release-notes` + 하위 `''` · `:id` — 메뉴 링크가 목록 = 빈 하위 경로라 RouterLink 의 `isActive` 가 글에서도 참). 그래서 설정 메뉴(`SettingsLayout`)는 `RouterLink custom` 슬롯으로 `isActive` 면 `is-active`(청록 바탕), `aria-current` 는 `isExactActive` 면 `page` · 아래에 묶은 화면(글)이면 `true`(메일함 패널의 지금 메일함과 같은 결 — 다른 항목은 아래 화면이 없어 그대로). 탭 제목은 상위 meta 하나("업데이트 내역"). `{ path: 'info', redirect }` — `/settings/info` 는 목록으로(주소창의 글 주소 뒤 `..` · `%2E%2E` 를 브라우저가 `/settings/info/` 로 다듬는다), 다른 묶음 주소는 그대로. **날짜는 달력 날짜 글**(`YYYY-MM-DD`)이라 `formatCalendarDate` 로만 쓴다 — `new Date('2026-11-18')` 은 UTC 자정이라 서쪽 시간대에서 하루 앞(시간대를 거치지 않는다 · 없는 날은 ''). 목록 · 글은 들어올 때마다 새로 받고(`silent` — 받지 못함은 `MailLoadError`, 글 없음은 `StateMessage`, 목록의 404 도 빠져나갈 곳이 없어 [다시 시도]), [더 보기]는 20개씩(인터셉터 알림 · 받은 줄 그대로 · 바쁨은 그 단추만), 늦은 응답은 seq + `authStore.generation` 으로 버리고 세대가 바뀌면 스토어를 비운다. 글 화면: [← 목록으로](`MailToolButton` accent · sticky 줄 52) · 다른 글 줄(`RouterLink replace` — 뒤로 가기는 목록) · [목록] — 목록으로 가는 셋은 `useMailNavigation().backOrReplace`(앞 기록이 목록이면 뒤로). 주소의 id 가 글 id 꼴(`isReleaseNoteId` — `2026-11-18(-N)` · 32자)이 아니면 묻지 않고 없는 글("." · ".." 경로 조각이 다른 API 로 가지 않게). 응답은 계약의 모양 그대로일 때만 읽는다(`releaseNotePageOf` · `releaseNoteOf` — 줄 · 묶음 · 다른 글 하나라도 틀리면 `SYS_001`, 앱 `tryParse` 와 같은 규칙). [더 보기]의 모양이 틀린 응답도 `notifyApiError` 로 알리고 받은 줄은 그대로. **스크롤 자리** — 같은 문서 안의 이동이라 앞 화면의 스크롤이 남는다: 글(열기 · 다른 글로)과 목록(받은 뒤 — [더 보기] 뒤는 아님)은 그린 뒤 `revealSettingsPageTop(main, 머리)` — 넓은 화면(70rem 이상)은 글 칸 `scrollTop = 0`, 좁은 화면은 문서를 맨 위로 올려도 머리(h1)가 화면 안이면 0 · 아니면 글 칸 위 끝을 화면 위 끝에, 문서가 맨 위면 그대로(처음 연 화면). 라우터 전역 `scrollBehavior` 는 쓰지 않는다(메일의 뒤로 가기까지 바뀐다). 좁은 폭은 목록 칸 · 다른 글 칸의 컨테이너 쿼리(480 미만 — 머리 줄 감춤 · 날짜를 제목 아래로). 초점: 다른 글로 옮겨 잃은 초점은 새 제목(h1 tabindex -1 · 맞춘 뒤라 `preventScroll`), 위에 붙은 [← 목록으로] 줄 밑에 가리지 않게 글 칸의 제목 · 링크 · 단추는 `scroll-margin-top` 61(줄 52 + 선 1 + 8 — WCAG 2.4.11), 마지막 [더 보기]로 단추가 사라지면 새 첫 줄. 글 내용은 글로만(v-html · 마크다운 해석 없음) — 디자인 README "업데이트 내역" | | 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) | | 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 | | 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) | diff --git a/docs/design/app/README.md b/docs/design/app/README.md index 9d3a984..abac0e4 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -20,7 +20,8 @@ - 탭마다 이동 기록을 따로 지킨다(메일에서 읽던 화면이 설정 탭에 다녀와도 남는다). 읽기 · 쓰기는 탭 막대 없는 전체 화면이다. - 여러 통 고르기 중에는 탭 막대를 숨긴다(잠깐의 상태이고, 다른 탭으로 가면 고른 것을 잃는다). ✕ · 뒤로 가기로 나오면 다시 보인다. - 화면 낭독기 이름은 "앱 메뉴". -- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다. +- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · 업데이트 내역 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다. + 업데이트 내역(2026-11-10 — 아래 「업데이트 내역」)은 앱 버전 다음 줄이고 오픈소스 라이선스 줄과 같은 꼴이다. [새 버전 받기](2026-11-08 — `App1Push` ④ 앱 정보 절)는 끝 아이콘이 바깥으로 여는 아이콘이고, 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 브라우저로 연다(같은 실패 알림). [로그아웃]은 데이터를 지우는 일이 아니라 회색 테두리 단추이고, 누르면 확인 창(처음 초점 [취소]) — 로그아웃하면 이 휴대폰의 새 메일 알림도 오지 않는다고 알린다. @@ -303,5 +304,35 @@ - 2026-11-08 갤럭시 S23(Android 16)에서 본 것: 등록 소식(자동 초기화를 끈 채) · 같은 알림 바꿔 쓰기와 앱으로 돌아올 때 지우기 · 보는 중에는 그리지 않음 · 뒤에 있을 때와 꺼져 있을 때 누르면 받은편지함 · 작은 아이콘(잘 보인다) · 홈 화면 배지 · 바꿔 쓴 알림도 다시 소리를 냄 · 로그아웃 때 FCM 풀기(서버 줄은 서버가 지운다). 서버 쪽은 계약 1절 "실기기 · 실제 키로 확인할 것". - 알려진 한계(2026-11-08): FID 로 등록하는 길은 Google Play 서비스 26.12(versionCode 261200000) 이상에서만 탄다 — 그보다 옛 휴대폰은 SDK(firebase-messaging 26.0.1)가 옛 토큰 길로 돌아가 등록 소식(`onRegistered`)의 값이 FID 가 아닐 수 있고, 서버는 FID 모양만 받으므로 켜기가 실패할 수 있다(아직 고치지 않았다 — 등록에 드는 매니페스트 표시는 flutter/README "새 메일 알림"). +### 업데이트 내역 (`ReleaseNotes` B — 2026-11-10 확정, 캔버스 v80) + +직원이 무엇이 새로 생기거나 바뀌었는지 읽는 게시판(목록 → 글)이다. 웹과 같은 글을 보여 준다(서버가 글 파일을 읽어 준다 — 웹 · 앱을 거르지 않는다). +보드는 웹 · 앱을 한 장에 그린 `docs/design/webmail/ReleaseNotes.dc.html` 의 B(앱)이고, 웹 화면은 웹 디자인 README 「업데이트 내역」, 글 파일 · API 는 계약 7절이다. + +- **자리**: 설정 탭 › 앱 정보 절의 앱 버전 다음 줄 "업데이트 내역"(52 · 끝에 › — 오픈소스 라이선스 줄과 같은 꼴). 누르면 목록 `/settings/release-notes`, 줄을 누르면 글 `/settings/release-notes/{id}` — + 둘 다 탭 막대 없는 전체 화면이고 글은 목록 위에 쌓인다(주소로 바로 열어도 아래에 목록이 있다). +- **목록**(B2): 위 막대 64(뒤로 · "업데이트 내역" 18 · 아래 선). 줄 68(설정의 두 줄 칸과 같다) — 제목 16 · 날짜 13(회색 · 숫자 고른 폭) · ›, 줄 전체를 누른다. 최근 글부터 20개이고 + 더 있으면 목록 아래 [더 보기](폭 전체 48 · 회색 테두리 — 20개를 넘을 때만). 받는 동안은 그 단추만 바쁨(둥근 진행 표시 18 · 회색 글 · 다시 누르지 못한다), + 더 받지 못하면 그 코드의 사전 문구로 알리고 받은 줄은 그대로다. +- **글**(B3 · B4): 위 막대는 뒤로만이고 늘 목록으로 간다. 날짜 13 · 제목 18 · 묶음(머리 15 · 점 목록 15 / 줄 간격 1.6 · 회색 점 5) 차례이고 길면 화면째 스크롤한다. + 끝에 다른 글 줄 64 — "다음 글 · 날짜"(더 새 글)가 위, "이전 글 · 날짜"(더 옛 글)가 아래(목록과 같은 차례), 없는 쪽은 줄이 없다. [목록] 단추는 두지 않는다. +- **상태**: 목록 — 불러오는 중은 줄 자리의 막대(B7 — 줄 68 · 제목 막대 13 · 날짜 막대 104 × 10, 끝이 둥글다), 받지 못함은 그 자리에 사전 문구 · 문의 코드 · [다시 시도](B8 — 알림 없음), + 글이 없으면 문서 그림 원과 "아직 알려 드릴 업데이트가 없습니다." 한 줄(B9). 글 — 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`)은 사전 문구 · 문의 코드 · [목록으로](B5 — 알림 없음), + 그 밖의 실패는 [다시 시도](B6). 받지 못함 · 없는 글의 제목은 긴 문장이라 15, 글이 없음의 제목은 빈 메일함과 같은 크기(18)다. +- **글 규칙**: 서버가 다듬은 글 그대로 그린다(마크다운 해석 · 링크 · 그림 없음 — 대괄호 글도 꾸미지 않는다). 날짜는 달력 날짜 그대로 "2026년 11월 18일"이다(시각으로 바꾸지 않는다 — 시간대와 상관없이 그날). + 새 글 표시(점 · 개수 · NEW) · 번호 · 작성자 · 조회 수는 두지 않는다. +- **구현하며 정한 것**(2026-11-10) + - 받지 못함 · 가운데 상태의 틀과 불러오는 중 막대는 메일 목록에 있던 것을 `shared/widgets/load_states.dart`(`StateLayout` · `LoadErrorView` · `LoadingSkeleton` · `SkeletonBar`)로 옮겨 메일과 함께 쓴다 — + 메일 화면의 모양 · 동작은 그대로다. 목록의 막대 줄은 B7 대로 따로 그렸다(메일 목록의 막대 줄에는 사진 원이 있다). + - 글을 받는 동안의 막대(보드에 없음): 날짜 · 제목 · 머리 · 줄 넷 자리를 목록 막대와 같은 결(끝이 둥근 막대)로 잡아 둔다. 화면 낭독기는 "글 불러오는 중"(웹과 같은 글). + - 다른 글 줄을 누르면 지금 글을 그 글로 바꿔 끼운다(쌓지 않는다 — 뒤로는 늘 목록). 새 화면이 들어오는 넘김이고 스크롤은 맨 위부터다. + - 글이 없을 때의 그림은 Material `description_outlined`(접힌 모서리와 글 줄 — 웹 `file-text` 와 같은 꼴, 보드의 "article_outlined 쯤"). + - [더 보기]는 받는 동안에도 단추를 그대로 두고 누름만 받지 않는다(초점이 옮겨 가지 않게 — 메일 검색의 [더 찾기]와 같다). 응답의 모양이 틀린 [더 보기](서버 쪽 문제)도 `SYS_001` 로 알린다. + - 화면 낭독기: 목록 줄은 "제목, 날짜", 다른 글 줄은 "다음 글, 날짜, 제목"(가운뎃점 대신 쉼표로 쉰다)을 단추 하나로 읽는다. 받는 동안의 [더 보기]는 꺼진 단추 "더 보기, 불러오는 중"이다 + (앱에서 새로 쓴 글 — 보드는 바쁨 표시만). 위 막대의 뒤로는 "뒤로"다. + - 세션이 바뀌면(로그아웃 · 다른 직원) 목록 · 글을 처음부터 받고 앞 세션의 늦은 응답은 버린다. 목록은 화면을 열 때마다 새로 받는다(닫으면 버린다). +- **실기기에서 볼 것**: 큰 글자에서 줄 높이 · 점 위치 · [더 보기], TalkBack 차례(위 막대 → 날짜 → 제목 → 머리 → 줄 → 다른 글 줄)와 [더 보기]를 받는 동안 읽기, + 다른 글로 옮길 때의 넘김 · 뒤로 가기 몸짓, 업데이트 내역 서버(feat/release-notes)와 함께 실제 글(첫 글의 날짜는 배포 날로 고친다). + ### 처음 판에서 미룬 것 서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저). diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index d2b808a..938a7d4 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -1436,6 +1436,70 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에 주소록: 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 엑셀(.xls), 여러 연락처 고르기 · 한꺼번에 지우기, 사내 목록 내보내기. 서명: 받는 사람에 따라 저절로 바꾸기, 휴대폰 앱에서 서명 고르기. 부재중: 「내 연락처에게만」, 그림 넣기. 2단계 인증: 복구 코드, 바꿀 때 알림 메일 · 계정 변경 기록. +## 업데이트 내역 (2026-11-10 확정 — `ReleaseNotes`) + +직원이 웹메일과 휴대폰 앱에 무엇이 새로 생기거나 바뀌었는지 읽는 게시판이다. 업데이트할 때마다 글을 하나씩 더하고, 직원은 목록에서 글을 골라 들어가 읽은 뒤 [목록]이나 뒤로 가기로 돌아온다. +글은 운영자가 저장소의 `backend/release-notes/` 에 글마다 파일 하나(`2026-11-18.md` — 이름이 날짜이자 글 주소)로 쓰고, 서버가 웹 · 앱에 같은 글을 준다. 올리는 화면 · API 는 없다(쓰는 법은 보드 C 와 그 폴더의 `README.md`). +서버 약속은 계약 7절("업데이트 내역")이다 — 목록 `GET /api/release-notes?cursor=&limit=` → `{ items: [{ id, date, title }], pageInfo }`(최근 글부터 · 20개씩), +글 `GET /api/release-notes/{id}` → `{ id, date, title, sections: [{ heading, items }], previous, next }`(없는 글 · 모양이 틀린 id 는 404 `RES_001`). 로그인해야 보이고 제한된 세션은 막힌다. +글의 원본은 화면 상수 `frontend/src/shared/constants/release-notes.ts` 이고 아래는 옮겨 적은 것이다. 글 내용(제목 · 묶음 · 줄)은 서버가 다듬은 글 그대로 그린다 — 마크다운 해석 · v-html 없음, 대괄호 글([설정] → [휴대폰 앱])도 꾸미지 않는다. +새 크기 토큰 · 밀도 예외 · 새 아이콘은 없다(메뉴 아이콘은 이미 있는 문서 그림 `file-text`). 새 글 표시(점 · 개수 · NEW)와 번호 · 작성자 · 조회 수 칸은 두지 않는다. 앱 화면(보드 B)은 앱 디자인 README 가 적는다. + +### 자리 · 주소 + +- 설정 왼쪽 메뉴의 셋째 묶음 「정보」(계정 · 메일 다음) › 「업데이트 내역」. 주소는 목록 `/settings/info/release-notes`, 글 `/settings/info/release-notes/{id}`(id 는 글 파일 이름 — `2026-11-18`, 같은 날 둘째 글은 `2026-11-18-2`). +- 글을 보는 동안에도 메뉴의 「업데이트 내역」이 고른 상태(청록 바탕)다 — 메일함 패널의 지금 메일함과 같은 결. 화면 낭독은 목록이면 `aria-current="page"`, + 글이면 링크가 가리키는 곳(목록)과 지금 쪽이 달라 `aria-current="true"` 다. 탭 제목은 목록 · 글 모두 "업데이트 내역 · ARA SYSTEM". +- `/settings/info` 는 목록으로 바꿔 끼운다 — 주소창에 글 주소 뒤 `..`(`%2E%2E` 도)를 치면 브라우저가 `/settings/info/` 로 다듬는다. 다른 묶음 주소(`/settings/account` · `/settings/mail`)는 그대로다. +- 날짜는 쓴 날짜 그대로 "2026년 11월 18일"이다. 시각이 아니라 달력 날짜라 브라우저 시간대로 바꾸지 않는다(`formatCalendarDate`). + +### 목록 (`ReleaseNotes` A1) + +- 머리 "업데이트 내역" + "웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다." 콘텐츠 열은 다른 설정 화면과 같은 800. +- 표 꼴 상자(로그인 기록 표와 같은 테두리 · 모서리 10): 머리 줄(32 · #FBFBFC · 12 회색 굵게) "제목" · "날짜"(136). 머리 줄은 보이는 꼴만이고, 화면 낭독은 줄 링크가 제목과 날짜를 함께 읽는다. + 글마다 한 줄(48 이상 · 위아래 13): 제목(14 · 500) · 날짜(12.5 회색 · 숫자 고정폭), 최근 글부터. 제목이 길면 줄을 바꾸고 날짜는 오른쪽 칸에 둔다. +- 줄 전체가 그 글로 가는 링크다 — 마우스를 올리면 옅은 청록 바탕(#F3F8F8), 키보드 초점은 줄 안쪽 2px 청록 테두리. +- 처음 20개를 받고, 더 있으면 상자 아래(사이 16) [더 보기](44 · 흰 바탕 테두리)로 20개씩 더 받는다. 받는 동안 그 단추만 바쁨(`aria-busy` · `aria-disabled` · 흐리게)이고, + 더 받지 못하면(응답의 모양이 틀려도 — `SYS_001`) 알림(사전 문구)으로 알리고 받은 줄 · [더 보기]는 그대로 둔다. +- 들어올 때마다 새로 받고, 받은 뒤 목록의 맨 위가 보이게 스크롤을 맞춘다(글에서 돌아와도 — 아래 보완 절 "스크롤 자리"). [더 보기] 뒤에는 맞추지 않는다. + +### 글 (`ReleaseNotes` A2) + +- 위에 [← 목록으로] 줄(52 · 아래 선 — 목록 보기에서 연 메일의 [목록으로]와 같은 꼴, 청록 굵은 글 · 화살표 15). 어떤 상태에서도 위에 남는다. +- 글을 열거나 다른 글로 옮기면 받은 뒤 글의 맨 위(날짜 · 제목)가 보이게 스크롤을 맞춘다(아래 보완 절 "스크롤 자리"). +- 날짜(12.5 · 500 회색) · 제목(h1 18 · 줄 간격 1.4), 사이 4. 그 아래(사이 22) 묶음(묶음 사이 18): 머리(h2 14 굵게) + 점 목록(13 · #344054 · 줄 간격 1.6 · 줄 사이 4 · 회색 점 4). + 머리가 없는 묶음은 점 목록만이다. 묶음 글은 680 까지. +- 글 끝(사이 22)의 다른 글 줄 — 위아래 선 · 680 까지. 줄(48 이상)은 이름(64 · 12.5 회색 굵게) · 제목(14 · 500) · 날짜(12.5 회색)이고 목록 줄처럼 줄 전체를 누른다. + 「다음 글」(바로 더 새 글)이 위, 「이전 글」(바로 더 옛 글)이 아래(목록과 같은 차례)다. 없는 쪽의 줄은 두지 않는다(글이 하나뿐이면 칸째 없다). +- 맨 아래 [목록](44 · 흰 바탕 테두리 · 안쪽 18). 설명 한 줄은 목록 화면에만 둔다. + +### 상태 (`ReleaseNotes` A1 · A2) + +- 목록 · 불러오는 중: 머리 줄은 두고 줄 자리를 막대 넷(제목 막대 54 · 38 · 62 · 46%, 날짜 막대 96)으로 잡아 둔다 — 메일 목록을 불러올 때와 같은 막대. 화면 낭독 "업데이트 내역 불러오는 중". +- 목록 · 받지 못함: 상자 자리에 사전 문구 · [다시 시도] · 문의 코드(다른 설정 화면의 `MailLoadError`). 알림은 띄우지 않는다. +- 목록 · 글이 없을 때: 상자와 [더 보기] 없이 문서 그림 36 · "아직 알려 드릴 업데이트가 없습니다." 한 줄(`StateMessage`). +- 글 · 받는 동안: 글 자리를 막대로 잡아 둔다(날짜 · 제목 · 묶음 자리 — 보드에는 그리지 않았다). 화면 낭독 "글 불러오는 중". +- 글 · 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`): 알림 없이 사전 문구("찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.") · [목록으로] · 문의 코드. + 파일 이름(날짜)을 고치면 글 주소가 바뀌어, 그전 주소로 들어오면 이 화면이다. +- 글 · 받지 못함(그 밖의 실패): 사전 문구 · [다시 시도] · 문의 코드. 알림은 띄우지 않는다. + +### 좁은 폭 (`ReleaseNotes` A3) + +- 화면 폭이 840보다 좁으면(레일 72 · 메뉴 208 · 글 칸 560 이 한 줄에 들지 않으면) 메뉴가 위로 올라가고 목록 · 글은 그 아래로 내려간다 — 지금 다른 설정 화면과 같다(휴대폰 브라우저에서는 메뉴 아래로 내려야 글이 나온다). +- **목록 칸이 480보다 좁으면**(화면 폭이 아니라 그 칸의 폭) 머리 줄을 감추고 날짜를 제목 아래로 내린다(사이 2 · 줄 안쪽 12 16) — 이것만 새로 정한 것이다. + 글 화면은 넓은 화면과 같은 차례 · 글자 크기로 한 칸에 흐른다(좌우 여백 32 그대로 — 폭 360 에서 글 폭 224). + +### 글 규칙 (보드 C 요약 — 쓰는 법의 원본은 `backend/release-notes/README.md`) + +- 글 하나가 파일 하나다(`YYYY-MM-DD.md`, 같은 날 둘째부터 `YYYY-MM-DD-2.md`). 첫 줄 `# 제목`(꼭) · `## 묶음 머리` · `- 한 줄`, 빈칸 두 개로 시작한 줄은 앞 줄에 띄어쓰기 하나로 이어 붙고, + `` 는 화면에 나오지 않는다. 머리 없이 바로 `- ` 를 쓰면 머리 없는 묶음이다. +- 길이는 제목 100자 · 묶음 머리 40자 · 한 줄 500자. 굵게 · 링크 · 그림은 없다 — 단추 · 메뉴 이름은 화면 글처럼 `[설정] → [휴대폰 앱]`. +- 글도 위 「화면 문구 — 사용자에게 말하듯」을 따른다(직원이 읽는 글이다 — 정중하고 부드럽게, 만든 쪽 말 없이). 웹과 앱이 같은 글을 보여 주니 어디 이야기인지는 묶음 머리로 가른다. + +### 처음 판에서 미룬 것 + +새 글 알림(안 읽은 표시 · 한 번 뜨는 안내 줄 — 뱃지를 쓰지 않는 원칙이라 배포 때 직원 공지 메일에 [설정] → [업데이트 내역]을 적는다), 글 안의 링크 · 그림 · 굵게, 웹 · 앱 거르기, 검색. + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). @@ -2002,3 +2066,28 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에 꺼 둔 채로는 넘어도 회색이다(켤 때만 센다). - 부재중 양식은 편집기와 함께 지연 로딩한다 — 청크를 받는 동안은 칸이 비어 있고, 못 받으면 서명 설정과 같은 사전 문구 + [다시 시도]. - 회사 밖 칸을 쓴 적 없으면 제목 · 내용을 null 로 보낸다(빈 글을 남기지 않는다 — 서버도 빈 글을 null 로 저장한다). + +### 업데이트 내역 구현하며 정한 보완 (2026-11-10 — 캔버스에는 아직 없다) + +데이터 · 경로는 위 "업데이트 내역" 머리말(계약 7절)과 같다. + +- **기록**: 다른 글 줄은 기록을 바꿔 끼운다 — 몇 글을 넘겨 봐도 뒤로 가기는 목록으로 간다(메일 읽기 화면의 이전 · 다음과 같다). + [← 목록으로] · [목록] · 없는 글의 [목록으로]는 바로 앞 기록이 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다(메일의 [목록으로]와 같다). 링크라 새 탭으로도 열 수 있다. +- **[← 목록으로] 줄은 스크롤해도 위에 남는다**(읽기 화면의 도구 줄과 같다 — 긴 글을 읽다가도 바로 돌아간다). + 그 줄 밑에 초점이 가리지 않게(WCAG 2.4.11) 글 칸의 제목 · 링크 · 단추는 줄 높이 + 8(61)만큼 띄워 스크롤한다(`scroll-margin-top`). +- **스크롤 자리**(2026-11-10 실사용 시험): 같은 문서 안의 이동이라 앞 화면의 스크롤 자리가 남아, 목록을 내린 채 글을 열면 날짜 · 제목이 위로 지나가 빈 글처럼 보였고 + 글에서 목록으로 돌아오면 목록의 맨 아래 근처가 보였다. 글을 열거나 다른 글로 옮기면 받은 뒤 글의 맨 위가, 목록은 받은 뒤 목록의 맨 위가 보이게 맞춘다 + (`composables/useSettingsPageScroll.ts` — 라우터 전역 `scrollBehavior` 는 메일 등 다른 화면의 뒤로 가기까지 바꿔 쓰지 않는다). 스크롤하는 것이 폭마다 달라 둘 다 맞춘다: + 넓은 화면(1120 이상)은 글 칸을 맨 위로, 좁은 화면은 문서를 — 맨 위로 올려도 제목이 화면 안이면 맨 위로(메뉴가 옆에 있는 폭), 아니면(위에 메뉴가 긴 휴대폰 · 낮은 창) 글 칸의 위 끝을 화면 위 끝에. + 문서가 맨 위에 있으면(처음 연 화면) 그대로 둔다 — 다른 설정 화면처럼 휴대폰에서는 메뉴부터 보인다. 없는 글 · 받지 못함도 그 안내가 보이게 같은 규칙으로 맞춘다. +- **메뉴가 고른 상태**: 목록 · 글 라우트를 화면 없는 상위 라우트로 묶었다(`router/routes.ts`). 설정 메뉴는 그 항목을 고른 상태면 청록 바탕을 그리고, + `aria-current` 는 정확히 그 화면이면 `page` · 아래에 묶은 화면이면 `true` 다(RouterLink 의 `isExactActive` · `isActive`). 다른 메뉴 항목은 아래에 묶은 화면이 없어 그대로다. +- **초점**: 다른 글 줄을 누르면 그 줄이 사라져 초점을 잃는다 — 새 글을 받아 맨 위를 보이게 맞춘 뒤 제목(h1)으로 옮긴다(스크롤 없이 · 키보드로 왔을 때만 고리를 그린다). + [더 보기]로 마지막 쪽을 받아 단추가 사라졌으면 새로 붙은 첫 줄로 옮긴다(단추가 남으면 초점도 그대로). +- **주소의 글 id 가 글 id 꼴이 아니면**(`2026-11-18` · `2026-11-18-2` 꼴이 아님 · 32자 넘음) 묻지 않고 없는 글 화면이다(문의 코드 없음). + "." · ".." 같은 경로 조각은 브라우저가 주소를 다듬으며 다른 경로(목록 API 등)로 보내서 꼴부터 거른다. +- **목록을 받지 못했는데 404 여도**(그 경로가 없는 옛 서버) 빠져나갈 곳이 없어 [다시 시도]다. +- **응답의 모양**: 계약의 모양 그대로일 때만 읽는다 — 줄 · 묶음 · 다른 글 하나라도 틀리면(날짜가 실제 있는 달력 날짜가 아님 포함) 받지 못함(`SYS_001` 사전 문구)으로 본다(앱과 같은 규칙). + 처음 목록 · 글은 칸 자리에 그리고(알림 없음), [더 보기]는 다른 실패와 같은 길로 알린다(받은 줄 · [더 보기]는 그대로). +- **다른 글 칸이 480보다 좁으면** 날짜를 제목 아래로 내린다(보드 A3 에는 그리지 않았다 — 목록 줄과 같은 기준). 이름 칸 64 는 그대로다. +- 목록 · 글은 들어올 때마다 새로 받는다(배포하며 글을 더하거나 고친다). 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 늦게 온 응답을 버리고 받아 둔 목록 · 글을 지운다. diff --git a/docs/design/webmail/ReleaseNotes.dc.html b/docs/design/webmail/ReleaseNotes.dc.html index 07534fc..7c33739 100644 --- a/docs/design/webmail/ReleaseNotes.dc.html +++ b/docs/design/webmail/ReleaseNotes.dc.html @@ -1040,7 +1040,7 @@ a{color:#0E7C86}a:hover{color:#0A5F67}
  • 웹은 설정 메뉴의 셋째 묶음 정보 아래 업데이트 내역입니다(계정, 메일 다음). 아이콘은 이미 있는 문서 그림(file-text)입니다.
  • 웹 주소는 목록 /settings/info/release-notes, 글 /settings/info/release-notes/{id}입니다. id는 파일 이름(예 2026-11-18)이고, 글을 보는 동안에도 메뉴의 업데이트 내역이 고른 상태입니다. 탭 제목은 둘 다 업데이트 내역입니다(다른 화면처럼 라우트마다 정한 이름).
  • 앱은 설정 탭 앱 정보 절의 앱 버전 다음 줄입니다. 목록 /settings/release-notes, 글 /settings/release-notes/{id}이고 둘 다 탭 막대 없는 전체 화면입니다.
  • -
  • 목록 GET /api/release-notes?cursor=&limit= 는 { items: [{ id, date, title }], pageInfo }입니다. 최근 글부터, 기본 20, 최대 50.
  • +
  • 목록 GET /api/release-notes?cursor=&limit= 는 { items: [{ id, date, title }], pageInfo }입니다. 최근 글부터, 기본 20, 최대 100.
  • 글 GET /api/release-notes/{id} 는 { id, date, title, sections: [{ heading, items }], previous, next }입니다. previous(더 옛 글)와 next(더 새 글)는 { id, date, title } 또는 null이고, 없는 글은 404 RES_001입니다. 올리는 API는 없습니다.
  • 웹과 앱이 같은 목록이고 거르지 않습니다. 새 글 표시(점, 개수, NEW)와 번호, 작성자, 조회 수 칸은 두지 않습니다.
  • diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 1cf2989..99cfde3 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -8,6 +8,7 @@ - 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }` - 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }` + (예외: 7절 업데이트 내역의 `date` 는 시각이 아닌 달력 날짜 글 `YYYY-MM-DD` 다 — `new Date()` 로 읽지 않고 시간대를 바꾸지 않은 채 그대로 쓴다) - 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001` (11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다. 12단계 — 5절 "계정 보안"이 `AUTH_006` ~ `AUTH_009` 를, 13단계 — 6절 "전환 준비"가 `AUTH_010` 을 더한다) - **적용 범위: 웹(브라우저)은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.** @@ -3288,3 +3289,83 @@ interface FileUploadCreate { 웹메일 관리자 화면(공통 기반 때), 비밀번호 스스로 재설정(복구 연락처 필요), 직원이 켜진 채 새 휴대폰으로 바꾸기(관리자의 「2단계 해제」로 한다), 공용 주소로 보내기 · 공유 메일함(Group), 새 기기 로그인 알림 메일, 관리자 열람 기능, 주소 바꾸기, 모바일 앱. + +--- + +## 7. 업데이트 내역 (2026-11-10 정함 · 서버) + +> 직원이 웹메일과 휴대폰 앱에서 무엇이 새로 생기거나 바뀌었는지 읽는 게시판이다. 업데이트할 때마다 글을 하나씩 더한다(목록에서 글을 골라 들어가 읽는다). +> 글은 운영자가 저장소의 `backend/release-notes/` 에 **글마다 파일 하나**로 쓴다 — 올리고 고치는 API 는 없다(파일을 더해 커밋 → 배포). 웹과 앱이 같은 글을 보고 거르지 않는다(어디 이야기인지는 묶음 머리가 말한다). +> 보드 `docs/design/webmail/ReleaseNotes.dc.html`(웹 A · 앱 B · 쓰는 법 C), 서버는 `modules/release-notes`. 새 에러 코드 · DB 표 · env 는 없다. + +### 타입 + +```ts +interface ReleaseNoteSummary { + id: string // 글 파일 이름에서 .md 를 뗀 것 — "2026-11-18", 같은 날 두 번째 글부터 "2026-11-18-2" … + date: string // 달력 날짜 "YYYY-MM-DD"(파일 이름에서) — 시각이 아니다 + title: string // 첫 줄의 # 제목 — 100자까지 +} + +interface ReleaseNoteSection { + heading: string | null // ## 머리 — 40자까지. 머리 없이 쓴 묶음은 null(글의 맨 앞에만 온다) + items: string[] // - 한 줄 — 500자까지, 하나 이상 +} + +interface ReleaseNote extends ReleaseNoteSummary { + sections: ReleaseNoteSection[] // 파일에 쓴 차례 — 하나 이상 + previous: ReleaseNoteSummary | null // 바로 더 옛 글(목록에서 이 글 아래) — 없으면 null + next: ReleaseNoteSummary | null // 바로 더 새 글(목록에서 이 글 위) — 없으면 null +} +``` + +### 엔드포인트 + +| 메서드 · 경로 | 요청 | 성공 | 주요 실패 | +| --- | --- | --- | --- | +| `GET /api/release-notes` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: ReleaseNoteSummary[], pageInfo }` — 최근 글부터, `total` 은 글 수 | 400 `VAL_001`(틀린 커서 · limit 범위 밖 · 모르는 쿼리) | +| `GET /api/release-notes/{id}` | — | 200 `ReleaseNote` | 404 `RES_001` — 없는 글 · 모양이 틀린 id | + +- 두 경로 모두 로그인 필요(웹 쿠키 · 앱 Bearer — `JwtAuthGuard`), 없으면 401 `AUTH_001`. 제한된 세션(6절)은 403 `AUTH_010`(허용 목록에 넣지 않는다 — 그 단계를 마치면 본다). + 요청 수는 기본 한도(경로마다 IP 당 1분 100회 — 넘으면 429 `REQ_001`). 응답은 `Cache-Control: private, no-store`(글을 고친 배포가 바로 보이게). +- **차례**: 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`). `previous` · `next` 는 이 차례의 아래 · 위다(목록 화면과 같은 차례). +- `cursor` 는 불투명 문자열(위치)이다. 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다(3절과 같다). +- **id**: 서버는 모양(`YYYY-MM-DD` 또는 `YYYY-MM-DD-N` — N 은 2~99, 32자까지)만 보고 읽어 둔 목록에서 찾는다 — 파일 경로로 쓰지 않는다. + 모양이 틀린 id 도 400 이 아니라 404 `RES_001` 이다(손으로 고친 주소도 "없는 글"). 파일 이름(날짜)을 고치면 글 주소도 바뀌어 옛 주소는 404 다. +- **글은 서버가 다듬은 그대로다** — 앞뒤 빈칸을 지우고, 안의 빈칸 · 탭 여럿은 하나로, 이어 쓴 줄은 띄어쓰기 하나로 붙이고, NFC 로 모은다. + 마크다운(굵게 · 링크 꼴)은 해석하지 않고 쓴 글 그대로다 — 클라이언트도 글로만 그린다(v-html · 마크다운 해석 없음). 단추 · 메뉴 이름은 글에 `[설정] → [휴대폰 앱]` 처럼 쓴다. +- **날짜(`date`)는 달력 날짜 글이다** — 화면은 시간대를 바꾸지 않고 "2026년 11월 18일"로 쓴다. `new Date('2026-11-18')` 로 읽지 않는다(UTC 자정이라 음수 시간대에서 하루 앞이 된다). + +### 글 파일 (`backend/release-notes/`) + +- 글 하나 = 파일 하나. 이름 `YYYY-MM-DD.md`(글의 날짜 — 있는 날짜여야 한다: `2026-02-30` 은 틀림), 같은 날 둘째부터 `YYYY-MM-DD-2.md` … `-99`(`-1` 은 없다 — 첫 글은 번호 없이). 이름이 글 id 다. +- 폴더에는 `README.md`(쓰는 사람용 안내 — 서버는 읽지 않는다)와 글 파일만 둔다. 그 밖의 것(다른 이름 · 폴더 · 링크)은 글이 아니다. +- 파일 안 — 마크다운의 아주 작은 일부(글만). UTF-8, 256KB 까지: + +| 줄 | 뜻 | +| --- | --- | +| `# 제목` | 첫 글 줄(앞의 빈 줄 · 메모는 된다) — 꼭 있어야 하고 한 번만. 100자까지 | +| `## 머리` | 묶음 시작 — 40자까지. 아래에 줄이 하나는 있어야 한다 | +| `- 한 줄` | 그 묶음의 한 줄 — 500자까지(이어 붙인 뒤). 머리 없이 바로 쓰면 머리 없는 묶음(`heading: null` — 제목 바로 아래에만) | +| 빈칸 두 개 이상(또는 탭)으로 시작한 줄 | 앞 `- ` 줄에 띄어쓰기 하나로 이어 붙는다(빈 줄 · 메모를 사이에 두어도). 들여 쓴 `- `(하위 목록)는 틀림 | +| `` | 메모 — 화면에 나오지 않는다. 줄 앞에서 시작한다(앞 빈칸은 괜찮다 · 글 뒤에 붙이지 않는다). 여러 줄도 된다. 닫은 뒤 같은 줄에 글을 쓰지 않는다 | +| 빈 줄 | 어디든 | + + 그 밖의 줄(맨 글 · `###` · `* ` · `1. ` · 표 · 인용 · 한 칸 들여 쓴 줄 등), 글 안의 `