feat(message_bizppurio): 알림 템플릿 편집 통합·검수 의견·관리탭 개선 및 언어팩 번역 보존 결함군 수정

비즈뿌리오 알림톡 UX 개편 후속:
- 코어/게시판/이커머스 알림 템플릿 [편집] 모달에 확장 채널 편집 영역
 (extension_point 2종 + hidden_template_editor 채널 메타)을 신설하고,
 알림톡 템플릿·문자(SMS)·수신자 규칙을 한 창에서 통합 저장하도록 개정
 (행 하단은 상태 요약 전용, 승인 여부 2단 배지)
- 검수 신청에 검수자 전달 의견(comment) 동봉 — kapi request 로만 전달,
 행에는 저장하지 않음 (FormRequest 신설 + API 문서 백필)
- 관리 탭 모바일 카드뷰·도구줄 코어 관례 통일, 사이트맵 알림 정의
 다국어 라벨 결측 수정 + 코어 정의 라벨 패리티 테스트 신설

언어팩 번역 소실·보존 결함군 (보완 실측 → 커밋 전 점검에서 연쇄 확정,
전부 실패 테스트 선행 후 수정):
- [기본값 복원]이 활성 언어팩 번역(ja)을 영구 소실 → 복원 기본값에
 시딩과 같은 주입기(SSoT)로 팩 로케일 병합 (notification·identity)
- 코어 알림/본인인증 시더의 언어팩 주입 전면 불능 → 주입기가 연관/리스트
 양형태 수용 + identity 시더의 config 복합 키 보존
- 실패한 언어팩 업데이트가 active 팩을 installed 로 방치 → 상태 복원 확장
- 병합·제거의 사용자 수정 보존 판정 사문(연관 전제 isset) → dot-path 판정
 교정, 팩 주입 저장의 user_overrides 오염 차단(시딩 바인딩), 템플릿
 2모델 translatableTrackableFields 선언(beta.4 설계 누락), 같은 프로세스
 활성화 시 시더 번역 필터 stale(캡처 인스턴스 캐시) 교정
- 7.0.9 업그레이드 스텝 신설 — 활성 팩 seed 재동기화로 기설치본의 소실
 번역 자동 복구 (멱등·운영자 수정 보존·팩별 실패 격리)
This commit is contained in:
HeuJung
2026-08-24 14:51:37 +09:00
parent 40f62e79aa
commit e7fa1b5535
71 changed files with 7155 additions and 8693 deletions
@@ -13,6 +13,11 @@
### Changed
- 관리자 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 확장이 여러 발송 채널을 하나의 탭으로 묶어 표시할 수 있으며(예: 비즈뿌리오 문자·알림톡 → '비즈뿌리오' 단일 탭), 묶인 채널 중 하나라도 켜져 있으면 탭이 노출됩니다.
- 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
### Fixed
- 알림 설정 화면에서 「사이트맵 재생성 완료」·「사이트맵 재생성 실패」 알림의 이름이 다국어 키 원문으로 표시되던 문제를 수정했습니다.
## [1.0.6] - 2026-08-22
@@ -0,0 +1,138 @@
// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio)
// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다.
/**
* 코어 알림 템플릿 [편집] 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (범용 seam)
*
* 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록:
* - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞)
* - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이)
* - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다
* (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일.
*
* 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은
* 기대값을 함께 고치면 통과한다.
*
* @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections
*/
import { describe, it, expect } from 'vitest';
import modal from '../../layouts/partials/admin_settings/_modal_notification_template_form.json';
type Node = Record<string, any>;
const PREFIX = '';
const STATE_KEY = 'notification_template_form_modal';
const MODAL_ID = 'modal_notification_template_form';
const REFETCH_ID = 'notificationDefinitions';
const findById = (node: unknown, id: string): Node | null => {
if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; }
if (node && typeof node === 'object') {
if ((node as Node).id === id) return node as Node;
for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; }
}
return null;
};
const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => {
if (Array.isArray(node)) {
const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id);
if (idx >= 0) return { arr: node, idx };
for (const n of node) { const r = findParentArray(n, id); if (r) return r; }
return null;
}
if (node && typeof node === 'object') {
for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; }
}
return null;
};
const evalIf = (expr: string, scope: Record<string, unknown>): boolean => {
const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
const names = Object.keys(scope);
// eslint-disable-next-line no-new-func
return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n])));
};
const scope = (channel: string, hidden: boolean | undefined, extra: Record<string, unknown> = {}) => ({
availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } },
_global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } },
});
const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId'];
describe('알림 템플릿 편집 모달 — 확장 슬롯 2종', () => {
it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => {
const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`);
expect(loc).toBeTruthy();
const { arr, idx } = loc!;
expect(arr[idx].type).toBe('extension_point');
expect(arr[idx].name).toBe('notification_template_form_sections');
expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`);
expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`);
});
it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => {
const footer = findById(modal, `${PREFIX}template_modal_footer`)!;
const right = footer.children[1].children as Node[];
expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']);
expect(right[1].name).toBe('notification_template_form_footer_actions');
expect(JSON.stringify(right[0])).toContain('$t:common.cancel');
expect(JSON.stringify(right[2])).toContain('$t:common.save');
});
it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => {
const ep = findById(modal, id)!;
expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort());
expect(ep.props.modalId).toBe(MODAL_ID);
expect(ep.props.stateKey).toBe(STATE_KEY);
expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID);
expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`);
expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`);
expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`);
expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`);
});
});
describe('알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => {
const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info'];
it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => {
const node = findById(modal, `${PREFIX}${id}`)!;
expect(node.if, `${id} if`).toBeTruthy();
expect(evalIf(node.if, scope('alimtalk', true))).toBe(false);
expect(evalIf(node.if, scope('mail', undefined))).toBe(true);
expect(evalIf(node.if, scope('sms', false))).toBe(true);
});
it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => {
const node = findById(modal, `${PREFIX}template_click_url_input`)!;
expect(evalIf(node.if, scope('database', undefined))).toBe(true);
expect(evalIf(node.if, scope('mail', undefined))).toBe(false);
expect(evalIf(node.if, scope('database', true))).toBe(false);
});
it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => {
const node = findById(modal, `${PREFIX}template_variables_info`)!;
const noVars = { ...scope('mail', undefined) } as Node;
noVars._global[STATE_KEY].definition = { variables: [] };
expect(evalIf(node.if, noVars)).toBe(false);
});
it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => {
const footer = findById(modal, `${PREFIX}template_modal_footer`)!;
const preview = footer.children[0].children[0] as Node;
const right = footer.children[1].children as Node[];
const cancel = right[0];
const save = right[2];
for (const b of [preview, save]) {
expect(evalIf(b.if, scope('alimtalk', true))).toBe(false);
expect(evalIf(b.if, scope('mail', undefined))).toBe(true);
}
expect(cancel.if).toBeUndefined();
});
it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => {
for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) {
const node = findById(modal, `${PREFIX}${id}`)!;
expect(node, id).toBeTruthy();
expect(String(node.if ?? '')).not.toContain('hidden_template_editor');
}
});
});
@@ -1858,7 +1858,9 @@
"types": {
"welcome": "Welcome",
"reset_password": "Password Reset",
"password_changed": "Password Changed"
"password_changed": "Password Changed",
"sitemap_regenerated": "Sitemap Regeneration Complete",
"sitemap_regenerate_failed": "Sitemap Regeneration Failed"
},
"channels_label": "Channels:",
"edit_modal": {
@@ -1862,7 +1862,9 @@
"types": {
"welcome": "회원가입 환영",
"reset_password": "비밀번호 재설정",
"password_changed": "비밀번호 변경 알림"
"password_changed": "비밀번호 변경 알림",
"sitemap_regenerated": "사이트맵 재생성 완료",
"sitemap_regenerate_failed": "사이트맵 재생성 실패"
},
"channels_label": "채널:",
"edit_modal": {
@@ -533,6 +533,21 @@
}
]
},
{
"id": "template_form_sections_ext",
"comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.",
"type": "extension_point",
"name": "notification_template_form_sections",
"props": {
"definition": "{{_global.notification_template_form_modal?.definition}}",
"template": "{{_global.notification_template_form_modal?.template}}",
"channel": "{{_global.notification_template_form_modal?.template?.channel ?? ''}}",
"modalId": "modal_notification_template_form",
"stateKey": "notification_template_form_modal",
"saveEndpoint": "{{'/api/admin/notification-templates/' + _global.notification_template_form_modal?.template?.id}}",
"refetchDataSourceId": "notificationDefinitions"
}
},
{
"id": "template_lang_tabs",
"type": "basic",
@@ -564,7 +579,8 @@
}
]
}
]
],
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
},
{
"id": "template_subject_input",
@@ -621,7 +637,8 @@
}
]
}
]
],
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
},
{
"id": "template_body_input",
@@ -679,13 +696,14 @@
}
]
}
]
],
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
},
{
"id": "template_click_url_input",
"type": "basic",
"name": "Div",
"if": "{{_global.notification_template_form_modal?.template?.channel === 'database'}}",
"if": "{{(_global.notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}",
"props": {
"className": "row-stack"
},
@@ -738,7 +756,7 @@
"id": "template_variables_info",
"type": "basic",
"name": "Div",
"if": "{{(_global.notification_template_form_modal?.definition?.variables ?? []).length > 0}}",
"if": "{{((_global.notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}",
"props": {
"className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4"
},
@@ -855,17 +873,18 @@
"target": "modal_notification_template_preview"
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
],
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
}
]
},
@@ -893,6 +912,21 @@
}
]
},
{
"id": "template_form_footer_ext",
"comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.",
"type": "extension_point",
"name": "notification_template_form_footer_actions",
"props": {
"definition": "{{_global.notification_template_form_modal?.definition}}",
"template": "{{_global.notification_template_form_modal?.template}}",
"channel": "{{_global.notification_template_form_modal?.template?.channel ?? ''}}",
"modalId": "modal_notification_template_form",
"stateKey": "notification_template_form_modal",
"saveEndpoint": "{{'/api/admin/notification-templates/' + _global.notification_template_form_modal?.template?.id}}",
"refetchDataSourceId": "notificationDefinitions"
}
},
{
"type": "basic",
"name": "Button",
@@ -992,7 +1026,8 @@
]
}
}
]
],
"if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
}
]
}