feat(sirsoft-board,sirsoft-ecommerce,message_bizppurio): 게시판·이커머스 알림 설정에 카카오 알림톡 연동 UI 추가

코어 알림 설정 화면에만 있던 비즈뿌리오 카카오 알림톡 템플릿 연결 UI(연결 버튼,
검수모드 배너, 안내박스)가 게시판·이커머스 알림 설정 화면에는 빠져 있었다.
Phase 6 재설계 시 "후속 작업"으로 남겨두고 실제 반영이 되지 않았던 부분이다.

- 게시판·이커머스 알림 정의 행에 코어와 동일한 notification_definition_row_footer
 확장 슬롯을 추가하고, 게시판 레이아웃에는 코어/이커머스와 동일한 채널 콘텐츠
 wrapper id를 신설해 구조를 맞췄다.
- 비즈뿌리오 플러그인에 게시판·이커머스 전용 overlay 확장 파일을 추가해 배너·
 안내박스·연결모달·데이터소스를 각 레이아웃에 주입한다. 백엔드는 notification_type을
 확장 구분 없는 문자열 키로 이미 3영역 공용 처리하고 있어 프론트 진입점만 보완했다.
- 회귀 테스트: 이커머스 화면에서 최초 구현 시 발견된 위치 버그(연결 버튼이 변수
 뱃지 아래가 아니라 토글/편집 버튼 옆으로 붙던 문제)를 fail 재현 후 수정,
 같은 회귀를 잡는 구조 검증 테스트를 게시판·이커머스 양쪽에 추가했다.
This commit is contained in:
chym1217
2026-07-28 12:40:22 +09:00
parent 49edacf5e0
commit 40d3ce9639
10 changed files with 1416 additions and 100 deletions
@@ -9,6 +9,6 @@
### Added
- 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다.
- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다.
- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다.
- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다.
- 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다.
@@ -0,0 +1,483 @@
{
"target_layout": "admin_board_settings",
"comment": "게시판 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 게시판 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 게시판 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).",
"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": false,
"auth_required": true,
"fallback": {
"data": {
"bindings": {}
}
}
},
{
"id": "bizppurioApprovedTemplates",
"comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates",
"method": "GET",
"auto_fetch": false,
"auth_required": true,
"errorHandling": {
"default": {
"handler": "suppress"
}
},
"fallback": {
"data": {
"templates": [],
"load_failed": true
}
}
}
],
"injections": [
{
"target_id": "board_notif_channel_content",
"position": "prepend_child",
"comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.",
"components": [
{
"id": "bizppurio_board_status_banner",
"type": "basic",
"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": "mt-4 mb-3 space-y-2"
},
"children": [
{
"id": "bizppurio_board_banner_not_ready",
"type": "basic",
"name": "Div",
"if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}",
"props": {
"className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-exclamation",
"size": "sm",
"className": "text-red-600 dark:text-red-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-red-800 dark:text-red-200"
},
"text": "$t:sirsoft-message_bizppurio.banner.not_ready"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap"
},
"text": "$t:sirsoft-message_bizppurio.banner.setup_action",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/plugins/sirsoft-message_bizppurio/settings"
}
}
]
}
]
},
{
"id": "bizppurio_board_banner_test_mode",
"type": "basic",
"name": "Div",
"if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}",
"props": {
"className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-flask",
"size": "sm",
"className": "text-amber-600 dark:text-amber-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
},
"text": "$t:sirsoft-message_bizppurio.banner.test_mode"
}
]
}
]
},
{
"id": "bizppurio_board_alimtalk_guide",
"comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.",
"type": "basic",
"name": "Div",
"if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}",
"props": {
"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_board_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 items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-info",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-teal-700 dark:text-teal-300"
},
"text": "$t:sirsoft-message_bizppurio.binding.section_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-secondary block text-xs font-medium mb-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.connected_template"
},
{
"type": "composite",
"name": "Select",
"props": {
"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": "setState",
"params": {
"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)}}"
}
}
]
},
{
"comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.",
"type": "basic",
"name": "P",
"if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}",
"props": {
"className": "text-xs text-red-600 dark:text-red-400 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed"
},
{
"comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.",
"type": "basic",
"name": "P",
"if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}",
"props": {
"className": "text-xs text-amber-600 dark:text-amber-400 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}",
"disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary text-xs"
},
"text": "$t:sirsoft-message_bizppurio.binding.fallback_sms"
}
]
},
{
"id": "bizppurio_board_binding_fallback_hint",
"comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.fallback_hint"
},
{
"id": "bizppurio_board_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
}
@@ -0,0 +1,483 @@
{
"target_layout": "admin_ecommerce_settings",
"comment": "이커머스 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 이커머스 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 이커머스 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).",
"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": false,
"auth_required": true,
"fallback": {
"data": {
"bindings": {}
}
}
},
{
"id": "bizppurioApprovedTemplates",
"comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates",
"method": "GET",
"auto_fetch": false,
"auth_required": true,
"errorHandling": {
"default": {
"handler": "suppress"
}
},
"fallback": {
"data": {
"templates": [],
"load_failed": true
}
}
}
],
"injections": [
{
"target_id": "ecommerce_notif_channel_content",
"position": "prepend_child",
"comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.",
"components": [
{
"id": "bizppurio_ecommerce_status_banner",
"type": "basic",
"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": "mt-4 mb-3 space-y-2"
},
"children": [
{
"id": "bizppurio_ecommerce_banner_not_ready",
"type": "basic",
"name": "Div",
"if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}",
"props": {
"className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-exclamation",
"size": "sm",
"className": "text-red-600 dark:text-red-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-red-800 dark:text-red-200"
},
"text": "$t:sirsoft-message_bizppurio.banner.not_ready"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap"
},
"text": "$t:sirsoft-message_bizppurio.banner.setup_action",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/plugins/sirsoft-message_bizppurio/settings"
}
}
]
}
]
},
{
"id": "bizppurio_ecommerce_banner_test_mode",
"type": "basic",
"name": "Div",
"if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}",
"props": {
"className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-flask",
"size": "sm",
"className": "text-amber-600 dark:text-amber-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
},
"text": "$t:sirsoft-message_bizppurio.banner.test_mode"
}
]
}
]
},
{
"id": "bizppurio_ecommerce_alimtalk_guide",
"comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.",
"type": "basic",
"name": "Div",
"if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}",
"props": {
"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_ecommerce_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 items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-info",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-teal-700 dark:text-teal-300"
},
"text": "$t:sirsoft-message_bizppurio.binding.section_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-secondary block text-xs font-medium mb-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.connected_template"
},
{
"type": "composite",
"name": "Select",
"props": {
"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": "setState",
"params": {
"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)}}"
}
}
]
},
{
"comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.",
"type": "basic",
"name": "P",
"if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}",
"props": {
"className": "text-xs text-red-600 dark:text-red-400 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed"
},
{
"comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.",
"type": "basic",
"name": "P",
"if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}",
"props": {
"className": "text-xs text-amber-600 dark:text-amber-400 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}",
"disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary text-xs"
},
"text": "$t:sirsoft-message_bizppurio.binding.fallback_sms"
}
]
},
{
"id": "bizppurio_ecommerce_binding_fallback_hint",
"comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.fallback_hint"
},
{
"id": "bizppurio_ecommerce_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
}
@@ -0,0 +1,167 @@
// e2e:allow 게시판·이커머스 배너/버튼/모달 노출은 Chrome MCP 로 실브라우저 확인·수정까지 완료
// (2026-07-28). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸(코어와 동일 사유).
/**
* 게시판·이커머스 알림 설정 알림톡 탭 연동 UI 구조 검증 (이슈 #28 후속)
*
* 배경: notification_tab_core.json(target_layout=admin_settings) + notification_row_footer.json
* (extension_point, 전역 매칭)은 코어 알림 설정 화면에만 연동 버튼·배너를 노출했다.
* extension_point 는 이름만 같으면 어느 레이아웃에서도 매칭되지만, target_id 기반 overlay
* injection 은 레이아웃별로 독립이라 게시판·이커머스는 배너/안내박스/연결모달이 뜨지 않았다.
*
* notification_tab_board.json / notification_tab_ecommerce.json 을 신설해
* target_layout=admin_board_settings / admin_ecommerce_settings 로 각각 등록했다.
* notification_row_footer.json 은 그대로(전역 매칭)이므로 재사용된다.
*/
import { describe, it, expect } from 'vitest';
import boardOverlay from '../../../extensions/notification_tab_board.json';
import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, type AnyNode } from './helpers';
type OverlayFixture = {
label: string;
overlay: typeof boardOverlay;
targetLayout: string;
targetId: string;
bannerId: string;
notReadyId: string;
testModeId: string;
guideId: string;
modalBodyId: string;
};
const FIXTURES: OverlayFixture[] = [
{
label: '게시판',
overlay: boardOverlay,
targetLayout: 'admin_board_settings',
targetId: 'board_notif_channel_content',
bannerId: 'bizppurio_board_status_banner',
notReadyId: 'bizppurio_board_banner_not_ready',
testModeId: 'bizppurio_board_banner_test_mode',
guideId: 'bizppurio_board_alimtalk_guide',
modalBodyId: 'bizppurio_board_binding_modal_body',
},
{
label: '이커머스',
overlay: ecommerceOverlay,
targetLayout: 'admin_ecommerce_settings',
targetId: 'ecommerce_notif_channel_content',
bannerId: 'bizppurio_ecommerce_status_banner',
notReadyId: 'bizppurio_ecommerce_banner_not_ready',
testModeId: 'bizppurio_ecommerce_banner_test_mode',
guideId: 'bizppurio_ecommerce_alimtalk_guide',
modalBodyId: 'bizppurio_ecommerce_binding_modal_body',
},
];
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) ?? [];
return Array.from(new Set([
...prefixed.map((m) => m.replace('$t:', '')),
...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')),
]));
};
describe.each(FIXTURES)('$label 알림톡 연동 overlay', ({
overlay, targetLayout, targetId, bannerId, notReadyId, testModeId, guideId, modalBodyId,
}) => {
const injectionRoot = {
children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? [])
.flatMap((i) => i.components ?? []),
} as AnyNode;
const modalRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
it(`target_layout=${targetLayout} 이고 extension_point 키가 없다(overlay 전용)`, () => {
expect((overlay as { target_layout?: string }).target_layout).toBe(targetLayout);
expect((overlay as Record<string, unknown>).extension_point).toBeUndefined();
});
it(`배너·안내박스는 target_id=${targetId} 에 prepend_child 로 주입된다`, () => {
const injections = (overlay as { injections?: Array<Record<string, unknown>> }).injections ?? [];
expect(injections).toHaveLength(1);
expect(injections[0].target_id).toBe(targetId);
expect(injections[0].position).toBe('prepend_child');
});
it('연결 맵·승인 템플릿 데이터소스를 등록한다(코어와 동일 endpoint)', () => {
const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
expect(ids).toContain('bizppurioBindings');
expect(ids).toContain('bizppurioApprovedTemplates');
});
it('상태 배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => {
const banner = findById(injectionRoot, bannerId);
expect(banner).toBeTruthy();
const cond = (banner as { if?: string }).if ?? '';
expect(cond).toContain("'sms'");
expect(cond).toContain("'alimtalk'");
expect(cond).toContain('readiness?.ready === false');
expect(cond).toContain('is_test_mode === true');
});
it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => {
const raw = JSON.stringify(findById(injectionRoot, notReadyId));
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 banner = findById(injectionRoot, testModeId);
expect(banner).toBeTruthy();
const cond = (banner as { if?: string }).if ?? '';
expect(cond).not.toContain('readiness');
expect(cond).toContain('is_test_mode === true');
});
it('알림톡 탭 상시 안내 박스가 있다', () => {
const guide = findById(injectionRoot, guideId);
expect(guide).toBeTruthy();
expect((guide as { if?: string }).if).toContain("=== 'alimtalk'");
expect(JSON.stringify(guide)).toContain('binding.list_guide');
});
it('연결 전용 모달(modal_bizppurio_binding)이 modals 로 등록된다', () => {
const modal = findById(modalRoot, 'modal_bizppurio_binding');
expect(modal).toBeTruthy();
expect((modal as { name?: string }).name).toBe('Modal');
});
it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => {
const modal = findById(modalRoot, 'modal_bizppurio_binding');
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('"closeModal"');
expect(raw).toContain('bizppurioBindings');
});
it('연결 템플릿이 없으면 SMS 대체 토글이 비활성이다(코어와 동일 규칙)', () => {
const raw = JSON.stringify(findById(modalRoot, modalBodyId));
expect(raw).toContain('"disabled"');
expect(raw).toContain("=== ''");
});
it('overlay 는 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => {
const raw = JSON.stringify(overlay);
expect(raw).not.toContain('/api/admin/notification-templates/');
expect(raw).not.toContain('notification_template_form_modal');
});
it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => {
const keys = collectPluginKeys(overlay);
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 Array.from(new Set(keys))) {
expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
}
});
});