feat(message_bizppurio): 알림톡 연동 행 슬롯·연결 모달 재설계

알림 설정 알림톡 탭에서 각 알림에 카카오 알림톡 템플릿을 연결하는 UI.
코어 편집 모달·저장 버튼은 건드리지 않는다(무오염).

- 각 알림 행 하단에 범용 확장 슬롯(notification_definition_row_footer) — 연결 상태 줄
 + [연결/변경] 버튼. 코어 편집 모달 내부 주입 불가(엔진 modals 미탐색) 회피.
- 우리 전용 연결 모달(modals 병합) — 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 +
 [취소][저장]. 저장은 우리 API(코어 저장 무관). openModal 을 승인 조회보다 먼저 실행.
- 상태 배너(readiness 미충족/테스트 모드, 문제 있을 때만) + 알림톡 탭 안내 박스.
- SMS 대체발송 안내(실패 시 이 알림 본문이 SMS로 발송) + 다크 모드 solid 색.
- 카카오 API 422 는 errorHandling.suppress 로 조용히 처리(전 탭 에러 토스트 제거).

Vitest 17 통과, audit 위반 0, Chrome MCP 다크·저장 검수 통과.
This commit is contained in:
chym1217 committed 2026-07-28 12:36:25 +09:00
1 parent af43a7c41d
commit 17a95bb556
7 files changed
+506 -243

No files matched your search

@@ -0,0 +1,101 @@
{
"extension_point": "notification_definition_row_footer",
"comment": "알림 설정 알림톡 탭의 각 알림 행 하단(코어 notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼을 채운다(계획서 §6-2, Phase 6 재설계). channel==='alimtalk'일 때만 노출. extensionPointProps.definition 으로 이 행의 알림(def)을 받고, 연결 정보는 bizppurioBindings(overlay notification_tab_core.json 이 등록·조회) 에서 def.type 으로 읽는다. [연결/변경] → 우리 연결 모달(modal_bizppurio_binding, overlay 가 등록)을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.",
"components": [
{
"id": "bizppurio_row_binding",
"type": "basic",
"name": "Div",
"if": "{{extensionPointProps.activeChannel === 'alimtalk'}}",
"props": {
"className": "flex-between gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-link",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
}
},
{
"comment": "연결됨: 템플릿명(또는 코드) · SMS 대체 표시 / 미연결: '미연결'",
"type": "basic",
"name": "Span",
"if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-200 truncate"
},
"text": "{{(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code) + (bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ? ' · SMS' : '')}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code)}}",
"props": {
"className": "text-sm text-gray-400 dark:text-gray-500"
},
"text": "$t:sirsoft-message_bizppurio.binding.unbound"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-3 py-1.5 rounded-md border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30 whitespace-nowrap flex-shrink-0"
},
"text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ? '$t:sirsoft-message_bizppurio.binding.btn_change' : '$t:sirsoft-message_bizppurio.binding.btn_connect'}}",
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"comment": "모달 상태 seed — 대상 알림 type·이름·변수 + 기존 연결값 프리필",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal": {
"notification_type": "{{extensionPointProps.definition?.type}}",
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
"variables": "{{extensionPointProps.definition?.variables ?? []}}",
"template_code": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ?? ''}}",
"template_name": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name ?? ''}}",
"fallback_sms": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ?? false}}",
"isSaving": false
}
}
},
{
"handler": "openModal",
"target": "modal_bizppurio_binding"
},
{
"comment": "승인 템플릿 드롭다운 옵션 조회(모달 열 때만 → 전 탭 에러 없음). 자격증명 미설정 시 422 는 fallback 으로 조용히 처리되며, openModal 뒤에 두어 조회 실패가 모달 표시를 막지 않게 한다.",
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioApprovedTemplates"
}
}
]
}
}
]
}
]
}
],
"priority": 320
}
@@ -1,13 +1,14 @@
{
"target_layout": "admin_settings",
"comment": "알림 템플릿 편집 모달(alimtalk 채널)에 비즈뿌리오 전용 칸을 append 한다(계획서 §6-2, Phase 6). 코어 편집 모달·저장 버튼은 전혀 건드리지 않는다(코어 템플릿 무오염). channel==='alimtalk'일 때만 ①안내 카드 ②연결 템플릿 드롭다운 ③SMS 대체 토글을 노출하며, 값을 바꾸면 즉시 우리 API 로 저장하고 toast 로 피드백한다(별도 저장 버튼 없음 — PO 확정 UX). 코어 [저장] 버튼은 본문·수신자만 저장하고 연동과 무관하다. 초기값·저장 후 상태는 bizppurioBindings 데이터소스에서 편집 대상 알림 type 으로 읽는다.",
"comment": "알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(계획서 §6-2, Phase 6 재설계). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼(channel==='alimtalk'일 때만) ③ [연결] 클릭 시 우리 연결 모달(modals 병합)에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API. 편집 모달(본문 편집)과 완전히 분리되어 헷갈리지 않는다.",
"data_sources": [
{
"id": "bizppurioBindings",
"comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
"method": "GET",
"auto_fetch": true,
"auto_fetch": false,
"auth_required": true,
"fallback": {
"data": {
@@ -17,11 +18,17 @@
},
{
"id": "bizppurioApprovedTemplates",
"comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단(결정 C). 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 빈 목록 사용, 안내는 배너·'승인 템플릿 없음' 문구가 담당). 이 토스트를 막지 않으면 모달 열 때마다 에러 토스트가 뜬다.",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates",
"method": "GET",
"auto_fetch": true,
"auto_fetch": false,
"auth_required": true,
"errorHandling": {
"default": {
"handler": "suppress"
}
},
"fallback": {
"data": {
"templates": []
@@ -33,7 +40,7 @@
{
"target_id": "notif_channel_content",
"position": "prepend_child",
"comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음, PO Q1-A). readiness 미충족 🔴 설정하기 안내 / is_test_mode 🟡 테스트 모드 안내. 코어 목록·모달은 그대로.",
"comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.",
"components": [
{
"id": "bizppurio_status_banner",
@@ -41,7 +48,7 @@
"name": "Div",
"if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}",
"props": {
"className": "mb-3"
"className": "mt-4 mb-3"
},
"children": [
{
@@ -128,28 +135,65 @@
]
}
]
}
]
},
{
"target_id": "template_variables_info",
"position": "append",
"components": [
},
{
"id": "bizppurio_alimtalk_section",
"comment": "알림톡 전용 칸 — channel==='alimtalk' 일 때만. 변경 즉시 우리 API 로 저장.",
"id": "bizppurio_alimtalk_guide",
"comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.",
"type": "basic",
"name": "Div",
"if": "{{_global.notification_template_form_modal?.template?.channel === 'alimtalk'}}",
"if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}",
"props": {
"className": "bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-700 rounded-lg p-4 space-y-3"
"className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-info",
"size": "sm",
"className": "text-blue-500 dark:text-blue-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-blue-800 dark:text-blue-200"
},
"text": "$t:sirsoft-message_bizppurio.binding.list_guide"
}
]
}
]
}
],
"modals": [
{
"id": "modal_bizppurio_binding",
"comment": "알림톡 연결 전용 모달(우리 소유, 신규). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}",
"size": "md",
"closeOnOverlayClick": false
},
"children": [
{
"id": "bizppurio_binding_modal_body",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}",
"props": {
"className": "space-y-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-start gap-2"
"className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800"
},
"children": [
{
@@ -158,35 +202,20 @@
"props": {
"name": "fas fa-circle-info",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0 mt-0.5"
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-medium text-teal-800 dark:text-teal-200"
},
"text": "$t:sirsoft-message_bizppurio.binding.section_title"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-teal-700 dark:text-teal-300 mt-0.5"
},
"text": "$t:sirsoft-message_bizppurio.binding.section_hint"
}
]
"name": "Span",
"props": {
"className": "text-xs text-teal-700 dark:text-teal-300"
},
"text": "$t:sirsoft-message_bizppurio.binding.section_hint"
}
]
},
{
"id": "bizppurio_binding_template_select",
"type": "basic",
"name": "Div",
"children": [
@@ -202,64 +231,19 @@
"type": "composite",
"name": "Select",
"props": {
"value": "{{_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')}}",
"value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
"className": "w-full text-sm",
"options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}"
},
"actions": [
{
"type": "change",
"handler": "sequence",
"handler": "setState",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"notification_template_form_modal.bizppurio.template_code": "{{$event.target.value}}",
"notification_template_form_modal.bizppurio.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}",
"notification_template_form_modal.bizppurio.fallback_sms": "{{$event.target.value === '' ? false : (_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false))}}"
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
"params": {
"method": "POST",
"body": {
"notification_type": "{{_global.notification_template_form_modal?.definition?.type}}",
"template_code": "{{$event.target.value}}",
"template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}",
"fallback_sms_enabled": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? false}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioBindings"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.saved",
"type": "success"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.save_error",
"type": "error"
}
}
]
}
]
"target": "global",
"bizppurio_binding_modal.template_code": "{{$event.target.value}}",
"bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}",
"bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
}
}
]
@@ -276,7 +260,6 @@
]
},
{
"id": "bizppurio_binding_fallback_toggle",
"type": "basic",
"name": "Div",
"props": {
@@ -287,61 +270,16 @@
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false)}}",
"disabled": "{{(_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')) === ''}}"
"checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}",
"disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}"
},
"actions": [
{
"type": "change",
"handler": "sequence",
"handler": "setState",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"notification_template_form_modal.bizppurio.fallback_sms": "{{!(_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false))}}"
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
"params": {
"method": "POST",
"body": {
"notification_type": "{{_global.notification_template_form_modal?.definition?.type}}",
"template_code": "{{_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')}}",
"template_name": "{{_global.notification_template_form_modal?.bizppurio?.template_name ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_name ?? '')}}",
"fallback_sms_enabled": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? false}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioBindings"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.saved",
"type": "success"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.save_error",
"type": "error"
}
}
]
}
]
"target": "global",
"bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
}
}
]
@@ -357,17 +295,177 @@
]
},
{
"id": "bizppurio_binding_fallback_hint",
"comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-teal-700 dark:text-teal-300"
"className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.variables_hint"
"text": "$t:sirsoft-message_bizppurio.binding.fallback_hint"
},
{
"id": "bizppurio_binding_variables",
"type": "basic",
"name": "Div",
"if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}",
"props": {
"className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.variables_hint"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap gap-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"iteration": {
"source": "{{_global.bizppurio_binding_modal?.variables ?? []}}",
"item_var": "v"
},
"props": {
"className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300"
},
"text": "{{'#{' + (v.key ?? '') + '}'}}"
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50",
"disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
},
"text": "$t:common.cancel",
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "modal_bizppurio_binding"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50",
"disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
},
"text": "$t:common.save",
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
"params": {
"method": "POST",
"body": {
"notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}",
"template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
"template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}",
"fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioBindings"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.saved",
"type": "success"
}
},
{
"handler": "closeModal",
"target": "modal_bizppurio_binding"
},
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": false
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.save_error",
"type": "error"
}
}
]
}
]
}
}
]
}
]
}
]
}
],
"init_actions": [
{
"comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).",
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioBindings"
}
}
],
"priority": 320
}
@@ -1,105 +1,72 @@
/**
* 알림 설정 알림톡 연동 overlay 구조 검증 (Phase 6 재설계, §6-2)
* 알림 설정 알림톡 탭 연동 UI 구조 검증 (Phase 6 재설계, §6-2)
*
* notification_tab_core.json 은 코어 admin_settings 편집 모달(alimtalk 채널)에 전용 칸을 얹고,
* 알림톡·SMS 탭 상단에 상태 배너를 얹는다. 코어 편집 모달·저장 버튼은 건드리지 않으며(무오염),
* 전용 칸 값을 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음).
* 두 확장 파일로 분리 구현:
* - notification_tab_core.json (Overlay): 상태 배너(injections) + 안내 박스 + 연결 모달(modals)
* + data_sources. target_layout=admin_settings.
* - notification_row_footer.json (ExtensionPoint): 코어 목록 각 행 하단 슬롯
* (notification_definition_row_footer)에 연결 상태 줄 + [연결/변경] 버튼.
*
* 검증:
* - 데이터소스(bizppurioBindings·bizppurioApprovedTemplates) 주입
* - 전용 칸: 안내 카드 · 연결 템플릿 드롭다운(즉시 저장) · SMS 대체 토글(연결 없으면 비활성)
* - 상태 배너: readiness 미충족(설정하기) · 테스트 모드
* - 코어 편집 모달 저장 body 에 bizppurio 필드가 없다(무오염)
* - i18n 키 정합(ko·en 동일)
* 코어 편집 모달·저장 버튼은 건드리지 않는다(무오염). 연결은 편집 모달과 분리된 우리 전용
* 모달에서 하며, 변경 즉시가 아니라 [저장] 버튼으로 명확히 저장한다. 카카오 API 422 는
* errorHandling.suppress 로 조용히 처리(안내는 배너·문구가 담당).
*
* 검증: 파일 분리(overlay vs extension_point), 배너/안내/모달 구조, 행 슬롯 UI, 저장 배선,
* 무오염(코어 저장 body 미개입), i18n 정합.
*/
import { describe, it, expect } from 'vitest';
import overlay from '../../../extensions/notification_tab_core.json';
import footer from '../../../extensions/notification_row_footer.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, type AnyNode } from './helpers';
const overlayRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
const bannerRoot = {
children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []).flatMap((i) => i.components ?? []),
} as AnyNode;
const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode;
/** overlay 텍스트에서 $t:key 및 $t('key') 형태의 플러그인 i18n 키를 모두 수집한다. */
const collectPluginKeys = (json: unknown): string[] => {
const text = JSON.stringify(json);
const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? [];
const keys = [
return Array.from(new Set([
...prefixed.map((m) => m.replace('$t:', '')),
...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')),
];
return Array.from(new Set(keys));
]));
};
type Overlay = {
target_layout: string;
data_sources?: Array<{ id: string; endpoint: string }>;
injections: Array<{ target_id: string; position: string; components: AnyNode[] }>;
};
const ext = overlay as unknown as Overlay;
/** injection components 를 하나의 검색 가능한 루트로 감싼다. */
const injectionRoot = (targetId: string): AnyNode => {
const inj = ext.injections.find((i) => i.target_id === targetId);
return { children: inj?.components ?? [] } as AnyNode;
};
describe('notification binding overlay — 대상·데이터소스', () => {
it('코어 admin_settings 레이아웃을 대상으로 한다', () => {
expect(ext.target_layout).toBe('admin_settings');
describe('binding UI — 파일 분리(Overlay vs ExtensionPoint)', () => {
it('overlay 는 target_layout=admin_settings 이고 extension_point 키가 없다', () => {
expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings');
expect((overlay as Record<string, unknown>).extension_point).toBeUndefined();
});
it('연동 맵·승인 템플릿 데이터소스를 주입한다', () => {
const ids = (ext.data_sources ?? []).map((d) => d.id);
it('footer 는 extension_point=notification_definition_row_footer 이고 target_layout 이 없다', () => {
expect((footer as { extension_point?: string }).extension_point).toBe('notification_definition_row_footer');
expect((footer as Record<string, unknown>).target_layout).toBeUndefined();
});
it('overlay 는 연결 맵·승인 템플릿 데이터소스를 등록한다', () => {
const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
expect(ids).toContain('bizppurioBindings');
expect(ids).toContain('bizppurioApprovedTemplates');
});
it('전용 칸은 코어 편집 모달의 template_variables_info 뒤에 append 된다', () => {
const inj = ext.injections.find((i) => i.target_id === 'template_variables_info');
expect(inj).toBeTruthy();
expect(inj?.position).toBe('append');
it('승인 템플릿 데이터소스는 auto_fetch:false 이고 422 를 suppress 한다(전 탭 에러 방지)', () => {
const ds = ((overlay as { data_sources?: Array<Record<string, unknown>> }).data_sources ?? [])
.find((d) => d.id === 'bizppurioApprovedTemplates');
expect(ds?.auto_fetch).toBe(false);
expect(JSON.stringify(ds?.errorHandling)).toContain('suppress');
});
});
describe('notification binding overlay — 전용 칸', () => {
const root = injectionRoot('template_variables_info');
it('알림톡 전용 섹션은 channel === alimtalk 일 때만 노출된다', () => {
const section = findById(root, 'bizppurio_alimtalk_section');
expect(section).toBeTruthy();
expect((section as { if?: string }).if).toContain("=== 'alimtalk'");
});
it('연결 템플릿 드롭다운을 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음)', () => {
const raw = JSON.stringify(findById(root, 'bizppurio_binding_template_select'));
expect(raw).toContain('apiCall');
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings');
expect(raw).toContain('"method":"POST"');
expect(raw).toContain('binding.saved');
});
it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이고, 변경 시 즉시 저장한다', () => {
const toggleWrap = findById(root, 'bizppurio_binding_fallback_toggle');
const raw = JSON.stringify(toggleWrap);
expect(raw).toContain('"disabled"');
expect(raw).toContain("=== ''");
expect(raw).toContain('apiCall');
expect(raw).toContain('"method":"POST"');
});
it('저장 실패 시 에러 토스트를 띄운다', () => {
const raw = JSON.stringify(root);
expect(raw).toContain('binding.save_error');
});
});
describe('notification binding overlay — 상태 배너', () => {
const root = injectionRoot('notif_channel_content');
it('배너는 sms·alimtalk 탭에서 문제가 있을 때만 노출된다', () => {
const banner = findById(root, 'bizppurio_status_banner');
describe('binding UI — 상태 배너 + 안내 박스', () => {
it('배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => {
const banner = findById(bannerRoot, 'bizppurio_status_banner');
expect(banner).toBeTruthy();
const cond = (banner as { if?: string }).if ?? '';
expect(cond).toContain("'sms'");
@@ -109,34 +76,101 @@ describe('notification binding overlay — 상태 배너', () => {
});
it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => {
const raw = JSON.stringify(findById(root, 'bizppurio_banner_not_ready'));
const raw = JSON.stringify(findById(bannerRoot, 'bizppurio_banner_not_ready'));
expect(raw).toContain('banner.not_ready');
expect(raw).toContain('banner.setup_action');
expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings');
});
it('테스트 모드 배너는 readiness 충족 + is_test_mode 일 때만 노출된다', () => {
const testBanner = findById(root, 'bizppurio_banner_test_mode');
const cond = (testBanner as { if?: string }).if ?? '';
expect(cond).toContain('readiness?.ready !== false');
expect(cond).toContain('is_test_mode === true');
expect(JSON.stringify(testBanner)).toContain('banner.test_mode');
it('알림톡 탭 상시 안내 박스가 있다(무엇을 하는 화면인지)', () => {
const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide');
expect(guide).toBeTruthy();
expect((guide as { if?: string }).if).toContain("=== 'alimtalk'");
expect(JSON.stringify(guide)).toContain('binding.list_guide');
});
});
describe('notification binding overlay — i18n 정합', () => {
it('overlay 가 참조하는 모든 키가 ko·en 에 존재한다', () => {
const keys = collectPluginKeys(overlay);
expect(keys.length).toBeGreaterThan(0);
describe('binding UI — 행 연결(extension_point)', () => {
it('행 연결 UI 는 channel === alimtalk 일 때만 노출된다', () => {
const row = findById(footerRoot, 'bizppurio_row_binding');
expect(row).toBeTruthy();
expect((row as { if?: string }).if).toContain("extensionPointProps.activeChannel === 'alimtalk'");
});
// 플러그인 resources/lang/{ko,en}.json 파일 루트가 곧 sirsoft-message_bizppurio 네임스페이스.
// 키 `sirsoft-message_bizppurio.binding.saved` → 첫 세그먼트(네임스페이스) 제거 후 파일 루트에서 해석.
it('연결 상태를 bizppurioBindings 에서 def.type 으로 읽어 표시한다(연결됨/미연결)', () => {
const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
expect(raw).toContain('bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]');
expect(raw).toContain('binding.unbound');
expect(raw).toContain('binding.btn_connect');
expect(raw).toContain('binding.btn_change');
});
it('[연결] 클릭 시 모달 상태를 seed 하고 우리 연결 모달을 연다', () => {
const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
expect(raw).toContain('bizppurio_binding_modal');
expect(raw).toContain('"openModal"');
expect(raw).toContain('modal_bizppurio_binding');
});
it('모달 열기(openModal)가 승인 템플릿 조회(refetch)보다 먼저 실행된다(조회 실패가 모달 표시를 막지 않도록)', () => {
const row = findById(footerRoot, 'bizppurio_row_binding');
const raw = JSON.stringify(row);
const openIdx = raw.indexOf('"openModal"');
const refetchIdx = raw.indexOf('bizppurioApprovedTemplates');
expect(openIdx).toBeGreaterThan(-1);
expect(refetchIdx).toBeGreaterThan(-1);
expect(openIdx).toBeLessThan(refetchIdx);
});
});
describe('binding UI — 연결 모달(우리 소유, 코어 편집 모달과 분리)', () => {
const modal = findById(overlayRoot, 'modal_bizppurio_binding');
it('연결 전용 모달이 modals 로 등록된다', () => {
expect(modal).toBeTruthy();
expect((modal as { name?: string }).name).toBe('Modal');
});
it('안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 토글 + 변수 안내를 담는다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain('binding.section_hint');
expect(raw).toContain('binding.connected_template');
expect(raw).toContain('binding.fallback_sms');
expect(raw).toContain('binding.variables_hint');
});
it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이다', () => {
const raw = JSON.stringify(findById(overlayRoot, 'bizppurio_binding_modal_body'));
expect(raw).toContain('"disabled"');
expect(raw).toContain("=== ''");
});
it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings');
expect(raw).toContain('"method":"POST"');
expect(raw).toContain('binding.saved');
expect(raw).toContain('binding.save_error');
expect(raw).toContain('"closeModal"');
expect(raw).toContain('bizppurioBindings');
});
});
describe('binding UI — 코어 무오염 + i18n', () => {
it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => {
const all = JSON.stringify(overlay) + JSON.stringify(footer);
expect(all).not.toContain('/api/admin/notification-templates/');
expect(all).not.toContain('notification_template_form_modal');
});
it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => {
const keys = [...collectPluginKeys(overlay), ...collectPluginKeys(footer)];
expect(keys.length).toBeGreaterThan(0);
const resolve = (root: unknown, path: string): unknown =>
path.split('.').slice(1).reduce<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[seg], root);
for (const key of keys) {
for (const key of Array.from(new Set(keys))) {
expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
}
});
});
});
@@ -64,12 +64,18 @@
},
"binding": {
"section_title": "KakaoTalk Alimtalk Binding",
"section_hint": "Changes are saved instantly, separately from the message body.",
"list_guide": "Link a KakaoTalk alimtalk template to each notification. Use [Connect] to assign an approved template — the alimtalk message is then sent automatically when that event occurs.",
"section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.",
"modal_title": "Alimtalk Binding · {name}",
"unbound": "Not connected",
"btn_connect": "Connect",
"btn_change": "Change",
"connected_template": "Connected Template",
"none": "Not connected",
"no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.",
"fallback_sms": "Fall back to SMS on failure",
"variables_hint": "Available variables are auto-substituted on send.",
"fallback_hint": "If the alimtalk message fails, this notification's body is sent as an SMS instead.",
"variables_hint": "Available variables (auto-substituted on send)",
"saved": "Alimtalk binding saved.",
"save_error": "Failed to save alimtalk binding."
},
@@ -64,12 +64,18 @@
},
"binding": {
"section_title": "카카오 알림톡 연동",
"section_hint": "선택하면 즉시 저장됩니다. 본문 저장과 별개로 동작합니다.",
"list_guide": "각 알림에 발송할 카카오 알림톡 템플릿을 연결하세요. [연결]로 승인된 템플릿을 지정하면 해당 이벤트 발생 시 알림톡이 자동 발송됩니다.",
"section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.",
"modal_title": "알림톡 연결 · {name}",
"unbound": "미연결",
"btn_connect": "연결",
"btn_change": "연결 변경",
"connected_template": "연결 템플릿",
"none": "연결 안 함",
"no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.",
"fallback_sms": "실패 시 SMS로 대체발송",
"variables_hint": "제공 변수는 발송 시 자동 치환됩니다.",
"fallback_hint": "알림톡 발송이 실패하면 이 알림의 본문 내용이 문자(SMS)로 대신 발송됩니다.",
"variables_hint": "제공 변수 (발송 시 자동 치환)",
"saved": "알림톡 연동을 저장했습니다.",
"save_error": "알림톡 연동 저장에 실패했습니다."
},
@@ -27,9 +27,13 @@ description: |
- AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip.
- 변수 치환: 코어 {var} → 알림톡 #{var}(D8, 매핑 UI 없음).
- 대체발송: binding.fallback_sms_enabled ON → payload 에 resend:{first:sms}+recontent 병합.
- 전용 칸(overlay): channel==='alimtalk' 일 때만 안내 카드 + 연결 템플릿 드롭다운 + SMS 대체
토글. 변경 즉시 우리 API(store) 저장 + toast. 승인(RDY/ACT) 템플릿만 드롭다운 노출.
- 상태 배너(overlay): sms·alimtalk 탭에서 readiness 미충족 🔴설정하기 / is_test_mode 🟡테스트모드.
- 행 연결 UI(extension_point notification_definition_row_footer): 알림톡 탭의 각 알림 행 하단에
연결 상태 줄(템플릿명·SMS대체) + [연결/변경] 버튼. channel==='alimtalk' 일 때만.
- 연결 모달(overlay modals, 우리 소유): 코어 편집 모달과 분리. 안내 카드 + 연결 템플릿
드롭다운(승인 RDY/ACT만) + SMS 대체 토글 + 변수 안내 + [취소][저장]. [저장]→우리 API(store)
+ toast + 모달 닫힘 + 목록 갱신. 카카오 API 422 는 errorHandling.suppress 로 조용히 처리.
- 상태 배너(overlay injections): sms·alimtalk 탭에서 readiness 미충족 🔴설정하기 / is_test_mode 🟡.
- 안내 박스(overlay): 알림톡 탭 상시 안내(무엇을 하는 화면인지).
axes:
channel: [alimtalk]
@@ -75,15 +79,20 @@ effects:
- bindings_map_returned_for_prefill
- view_permission_required_for_read
- manage_permission_required_for_store
# 전용 칸·배너 (프론트 — Vitest 구조/i18n)
- alimtalk_section_shown_only_for_alimtalk_channel
- template_select_saves_immediately_via_our_api
# 행 연결 UI·연결 모달·배너 (프론트 — Vitest 구조/i18n)
- row_binding_shown_only_for_alimtalk_channel
- row_shows_bound_or_unbound_status_from_bindings
- connect_button_seeds_modal_and_opens_our_modal
- openmodal_runs_before_approved_refetch
- our_binding_modal_registered_via_modals
- fallback_toggle_disabled_when_no_template
- save_error_shows_toast
- save_posts_to_our_api_then_toast_close_refresh
- approved_templates_422_suppressed
- banner_shown_only_on_sms_alimtalk_tab_when_problem
- not_ready_banner_has_setup_action
- test_mode_banner_shown_when_ready_and_test_mode
- core_edit_modal_save_body_has_no_bizppurio_fields
- alimtalk_tab_guide_box_shown
- core_edit_modal_untouched_no_bizppurio_fields
# e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상)
- e2e_open_alimtalk_edit_shows_section
- e2e_select_template_saves_and_toasts
@@ -500,6 +500,15 @@
]
}
]
},
{
"comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.",
"type": "extension_point",
"name": "notification_definition_row_footer",
"props": {
"definition": "{{def}}",
"activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}"
}
}
]
},