diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md new file mode 100644 index 00000000..69d4b516 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +이 프로젝트의 모든 주요 변경사항을 기록합니다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. + +## [1.0.0] - 2026-07-09 + +### Added + +- 비즈뿌리오(다우기술) 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다. +- 관리자 「메시지 발송 관리」 메뉴와 조회·관리 권한을 추가했습니다. +- 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/LICENSE b/plugins/_bundled/sirsoft-message_bizppurio/LICENSE new file mode 100644 index 00000000..4e94ed38 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/LICENSE @@ -0,0 +1,47 @@ +프로그램 명칭 : 그누보드7용 비즈뿌리오 메시징 플러그인 (sirsoft-message_bizppurio) + +저작자 : (주)에스아이알소프트 + +----- MIT 라이선스 (한국어 번역) -------------------------------------------------------- + +MIT 라이선스 + +Copyright (c) 2026 (주)에스아이알소프트 + +이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게 +소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는 +판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음 +조건을 따라야 합니다: + +위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야 +합니다. + +소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지 +않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에 +국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의 +사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타 +책임에 대해 책임을 지지 않습니다. + +----- MIT License (English Original) -------------------------------------------------------- + +The MIT License (MIT) + +Copyright (c) 2026 SIRSOFT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/composer.json b/plugins/_bundled/sirsoft-message_bizppurio/composer.json new file mode 100644 index 00000000..22992fd2 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/composer.json @@ -0,0 +1,22 @@ +{ + "name": "plugins/sirsoft-message_bizppurio", + "description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin for G7 platform by sirsoft", + "type": "library", + "version": "1.0.0", + "license": "MIT", + "authors": [ + { + "name": "sirsoft", + "email": "contact@sirsoft.com" + } + ], + "require": { + "php": "^8.2", + "ext-json": "*" + }, + "autoload": { + "psr-4": { + "Plugins\\Sirsoft\\MessageBizppurio\\": ["src/", "./"] + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json new file mode 100644 index 00000000..c77a63f4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json @@ -0,0 +1,22 @@ +{ + "_meta": { + "version": "1.0.0", + "description": "비즈뿌리오 메시징 플러그인 환경설정 기본값 및 프론트엔드 스키마. 크리덴셜(password/api_key/sender_key)은 sensitive + expose:false 로 프론트 노출을 차단한다. 관리자 설정 화면은 코어 /api/admin/plugins/{id}/settings 로 직접 조회하므로 window.G7Config 노출이 불필요하여 전 필드 expose:false." + }, + "defaults": { + "environment": "dev", + "bizppurio_id": "", + "password": "", + "api_key": "", + "sender_number": "", + "sender_key": "" + }, + "frontend_schema": { + "environment": { "expose": false }, + "bizppurio_id": { "expose": false }, + "password": { "expose": false, "sensitive": true }, + "api_key": { "expose": false, "sensitive": true }, + "sender_number": { "expose": false }, + "sender_key": { "expose": false, "sensitive": true } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php new file mode 100644 index 00000000..3dacc8e4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -0,0 +1,33 @@ + [ + 'sms' => 'SMS', + 'lms' => 'LMS', + 'alimtalk' => 'Alimtalk', + ], + + // 발송 상태 (DispatchStatus enum) + 'status' => [ + 'pending' => 'Pending', + 'sent' => 'Sending', + 'success' => 'Success', + 'failed' => 'Failed', + ], + + // 발송 출처 (DispatchSource enum) + 'source' => [ + 'auto' => 'Automatic', + 'manual' => 'Manual', + 'bulk' => 'Bulk', + ], + + // 결과코드 분류 (ResultCategory enum) + 'result_category' => [ + 'success' => 'Success', + 'retry' => 'Retry', + 'permanent_failure' => 'Permanent Failure', + 'balance_low' => 'Insufficient Balance', + ], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php new file mode 100644 index 00000000..ae9112c2 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -0,0 +1,33 @@ + [ + 'sms' => 'SMS', + 'lms' => 'LMS', + 'alimtalk' => '알림톡', + ], + + // 발송 상태 (DispatchStatus enum) + 'status' => [ + 'pending' => '대기', + 'sent' => '발송중', + 'success' => '성공', + 'failed' => '실패', + ], + + // 발송 출처 (DispatchSource enum) + 'source' => [ + 'auto' => '자동', + 'manual' => '수동', + 'bulk' => '대량', + ], + + // 결과코드 분류 (ResultCategory enum) + 'result_category' => [ + 'success' => '성공', + 'retry' => '재시도', + 'permanent_failure' => '영구 실패', + 'balance_low' => '잔액 부족', + ], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/package.json b/plugins/_bundled/sirsoft-message_bizppurio/package.json new file mode 100644 index 00000000..cd1c67c9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/package.json @@ -0,0 +1,16 @@ +{ + "name": "@plugins/sirsoft-message_bizppurio", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest", + "test:run": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "jsdom": "^25.0.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json new file mode 100644 index 00000000..1a752d1d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json @@ -0,0 +1,25 @@ +{ + "identifier": "sirsoft-message_bizppurio", + "vendor": "sirsoft", + "name": { + "ko": "비즈뿌리오 메시징", + "en": "Bizppurio Messaging" + }, + "version": "1.0.0", + "description": { + "ko": "비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.", + "en": "Bizppurio (Daou Tech) SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook." + }, + "license": "MIT", + "g7_version": ">=7.0.3", + "dependencies": { + "modules": {}, + "plugins": {} + }, + "github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio", + "github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio/blob/main/CHANGELOG.md", + "loading": { + "strategy": "global", + "priority": 100 + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php new file mode 100644 index 00000000..b6b9d2e3 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -0,0 +1,367 @@ + 'Sirsoft', + 'license' => 'MIT', + 'homepage' => 'https://sir.kr', + 'keywords' => ['bizppurio', 'sms', 'lms', 'alimtalk', 'kakao', 'messaging', 'notification'], + ]; + } + + /** + * 관리자 메뉴 정의 + * + * 코어 PluginManager 는 모듈과 달리 plugin 의 getAdminMenus() 를 자동 호출하지 않으므로 + * 본 클래스의 activate()/deactivate()/uninstall() lifecycle hook 에서 직접 sync 한다. + * 멱등성: helper.syncMenu 가 upsert 패턴이라 재활성화 시에도 row 중복 없이 그대로 복원. + * + * 최상위 「메시지 발송 관리」 아래에 알림톡 템플릿 관리·발송 이력 하위 메뉴를 둔다. + * (하위 페이지는 Phase 4·5 에서 라우트/레이아웃과 함께 실물화) + * + * @return array> + */ + public function getAdminMenus(): array + { + return [ + [ + 'name' => ['ko' => '메시지 발송 관리', 'en' => 'Messaging'], + 'slug' => 'sirsoft-message_bizppurio', + 'url' => null, + 'icon' => 'fas fa-paper-plane', + 'order' => 55, + 'children' => [ + [ + 'name' => ['ko' => '알림톡 템플릿 관리', 'en' => 'Alimtalk Templates'], + 'slug' => 'sirsoft-message_bizppurio-alimtalk-templates', + 'url' => '/admin/plugins/sirsoft-message_bizppurio/alimtalk-templates', + 'icon' => 'fas fa-comment-dots', + 'order' => 1, + 'permission' => 'sirsoft-message_bizppurio.messaging.view', + ], + [ + 'name' => ['ko' => '발송 이력', 'en' => 'Dispatch History'], + 'slug' => 'sirsoft-message_bizppurio-dispatches', + 'url' => '/admin/plugins/sirsoft-message_bizppurio/dispatches', + 'icon' => 'fas fa-clock-rotate-left', + 'order' => 2, + 'permission' => 'sirsoft-message_bizppurio.messaging.view', + ], + ], + ], + ]; + } + + /** + * 플러그인 활성화 — 관리자 메뉴 자동 등록. + * + * 모듈은 ModuleManager 가 getAdminMenus() 를 자동 처리하지만 플러그인은 + * PluginManager 가 그 책임을 위임하므로 본 메서드에서 helper 를 직접 호출한다. + * syncMenu 가 신규 메뉴 생성 시 admin 역할을 자동 부여하므로 별도 역할 처리는 불필요. + * + * @return bool 활성화 성공 여부 + */ + public function activate(): bool + { + $helper = app(ExtensionMenuSyncHelper::class); + + foreach ($this->getAdminMenus() as $menuData) { + $helper->syncMenuRecursive( + $menuData, + ExtensionOwnerType::Plugin, + $this->getIdentifier(), + ); + } + + return true; + } + + /** + * 플러그인 비활성화 — 관리자 메뉴 일괄 제거. + * + * cleanupStaleMenus 에 currentSlugs=[] 를 넘겨 본 플러그인 소속 메뉴 전체를 삭제한다. + * 자식 메뉴 + role_menus 피벗 정리는 helper 가 cascade 처리. + * + * @return bool 비활성화 성공 여부 + */ + public function deactivate(): bool + { + app(ExtensionMenuSyncHelper::class)->cleanupStaleMenus( + ExtensionOwnerType::Plugin, + $this->getIdentifier(), + currentSlugs: [], + ); + + return true; + } + + /** + * 플러그인 제거 — 메뉴 잔존 안전망 (정상 흐름은 deactivate 가 먼저 처리). + * + * @return bool 제거 성공 여부 + */ + public function uninstall(): bool + { + $this->deactivate(); + + return true; + } + + /** + * 플러그인 권한 목록 반환 (계층 구조) + * + * PluginManager 가 1레벨(플러그인 노드) → 2레벨(카테고리) → 3레벨(개별 권한) 트리로 등록. + * 모든 권한은 admin 역할에 매핑. + * + * 권한 분할 의도: + * - view: 발송 이력·알림톡 템플릿 조회 (모니터링) + * - manage: 환경설정·템플릿 등록/검수·이벤트 연동 (운영) + * + * @return array 권한 정의 배열 (categories 계층 구조) + */ + public function getPermissions(): array + { + return [ + 'name' => [ + 'ko' => '비즈뿌리오 메시징', + 'en' => 'Bizppurio Messaging', + ], + 'description' => [ + 'ko' => '비즈뿌리오 메시징 플러그인이 제공하는 권한', + 'en' => 'Permissions provided by the Bizppurio Messaging plugin', + ], + 'categories' => [ + [ + 'identifier' => 'messaging', + 'name' => ['ko' => '메시지 발송', 'en' => 'Messaging'], + 'description' => [ + 'ko' => '메시지 발송 도메인 권한 (조회·관리)', + 'en' => 'Messaging domain permissions (view, manage)', + ], + 'permissions' => [ + [ + 'action' => 'view', + 'name' => ['ko' => '메시지 조회', 'en' => 'View Messaging'], + 'description' => [ + 'ko' => '발송 이력·알림톡 템플릿 조회 (모니터링)', + 'en' => 'View dispatch history and alimtalk templates (monitoring)', + ], + 'type' => 'admin', + 'roles' => ['admin'], + ], + [ + 'action' => 'manage', + 'name' => ['ko' => '메시지 관리', 'en' => 'Manage Messaging'], + 'description' => [ + 'ko' => '환경설정·알림톡 템플릿 등록/검수·이벤트 연동 관리', + 'en' => 'Manage settings, alimtalk template registration/inspection, and event bindings', + ], + 'type' => 'admin', + 'roles' => ['admin'], + ], + ], + ], + ], + ]; + } + + /** + * 플러그인 설정 스키마 반환 + * + * 관리자 설정 페이지 UI 를 동적으로 생성하는 데 사용됩니다. 크리덴셜(비밀번호·API 키)은 + * sensitive 로 마킹하여 마스킹하며, frontend_schema(defaults.json)에서 expose:false 로 + * 프론트 노출을 차단합니다. + * + * 발송 시스템(account)과 카카오 관리 시스템(bizId)의 식별자는 동일한 '비즈뿌리오 아이디' + * 이므로 bizppurio_id 단일 필드로 받는다. + * + * @return array 설정 스키마 + */ + public function getSettingsSchema(): array + { + return [ + 'environment' => [ + 'type' => 'enum', + 'options' => ['dev', 'live'], + 'default' => 'dev', + 'label' => ['ko' => '연동 환경', 'en' => 'Environment'], + 'hint' => [ + 'ko' => '검수(개발) 또는 운영 환경을 선택합니다. 발송 API 도메인이 환경에 따라 분기됩니다.', + 'en' => 'Select the inspection (dev) or production environment. The sending API domain differs by environment.', + ], + 'required' => false, + ], + 'bizppurio_id' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '비즈뿌리오 아이디', 'en' => 'Bizppurio ID'], + 'hint' => [ + 'ko' => '발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다.', + 'en' => 'The Bizppurio account ID used for both sending and Kakao management.', + ], + 'required' => false, + ], + 'password' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '비밀번호', 'en' => 'Password'], + 'hint' => [ + 'ko' => '발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다.', + 'en' => 'The Bizppurio password used to issue the sending token.', + ], + 'sensitive' => true, + 'required' => false, + ], + 'api_key' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => 'API 키', 'en' => 'API Key'], + 'hint' => [ + 'ko' => '카카오 관리(알림톡 템플릿·발신프로필)에 사용하는 API 키입니다. 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다.', + 'en' => 'The API key used for Kakao management (alimtalk templates, sender profiles).', + ], + 'sensitive' => true, + 'required' => false, + ], + 'sender_number' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '발신번호', 'en' => 'Sender Number'], + 'hint' => [ + 'ko' => '문자·알림톡 발송에 사용하는 발신 전화번호입니다.', + 'en' => 'The sender phone number used for SMS and alimtalk delivery.', + ], + 'required' => false, + ], + 'sender_key' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '알림톡 발신프로필 키', 'en' => 'Alimtalk Sender Profile Key'], + 'hint' => [ + 'ko' => '알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다.', + 'en' => 'The 40-character sender profile key used for alimtalk delivery and template lookup.', + ], + 'sensitive' => true, + 'required' => false, + ], + ]; + } + + /** + * 플러그인 설정 기본값 반환 (하위 호환) + * + * 신규 설치 기본값은 config/settings/defaults.json 의 defaults 섹션이 1순위이며, + * 본 메서드는 하위 호환 경로로 동일 값을 반환한다. + * + * @return array 기본 설정값 + */ + public function getConfigValues(): array + { + return [ + 'environment' => 'dev', + 'bizppurio_id' => '', + 'password' => '', + 'api_key' => '', + 'sender_number' => '', + 'sender_key' => '', + ]; + } + + /** + * 이 플러그인이 등록할 알림 정의/템플릿 선언을 반환합니다. + * + * Phase 1 범위: 비즈뿌리오 지갑 잔액부족 시 관리자에게 보내는 자체 알림 1건. + * (webhook 결과코드 9070/7436 감지 시 Phase 4 에서 이 알림을 발화) + * + * ※ 3영역(코어/게시판/이커머스) 알림톡 채널 기본 body 시드는 알림톡 채널 등록(Phase 3)· + * 탭 연동(Phase 6)과 강결합이므로 Phase 6 으로 이관한다. 그때 본 메서드에 정의를 추가한다. + * + * @return array> + */ + public function getNotificationDefinitions(): array + { + return [ + [ + 'type' => 'bizppurio_balance_low', + 'hook_prefix' => 'sirsoft-message_bizppurio', + 'name' => [ + 'ko' => '비즈뿌리오 잔액 부족', + 'en' => 'Bizppurio Balance Low', + ], + 'description' => [ + 'ko' => '비즈뿌리오 지갑 잔액이 부족해 문자/알림톡 발송이 실패했을 때 관리자에게 발송', + 'en' => 'Sent to admin when a message fails due to insufficient Bizppurio wallet balance', + ], + 'channels' => ['mail', 'database'], + 'hooks' => ['sirsoft-message_bizppurio.balance.low'], + 'variables' => [ + ['key' => 'name', 'description' => '수신자(관리자) 이름'], + ['key' => 'app_name', 'description' => '사이트 이름'], + ['key' => 'result_code', 'description' => '결과 코드(9070 문자 / 7436 알림톡)'], + ['key' => 'channel_label', 'description' => '발송 채널(문자/알림톡)'], + ['key' => 'settings_url', 'description' => '메시징 환경설정 URL'], + ['key' => 'site_url', 'description' => '사이트 URL'], + ], + 'templates' => [ + [ + 'channel' => 'mail', + 'recipients' => [['type' => 'role', 'value' => 'admin']], + 'subject' => [ + 'ko' => '[{app_name}] 비즈뿌리오 잔액이 부족합니다', + 'en' => '[{app_name}] Bizppurio balance is insufficient', + ], + 'body' => [ + 'ko' => '{name}님, 비즈뿌리오 지갑 잔액이 부족하여 {channel_label} 발송이 실패했습니다 (코드: {result_code}). 충전 후 발송이 정상화됩니다.', + 'en' => 'Dear {name}, a {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}). Delivery resumes after recharging.', + ], + ], + [ + 'channel' => 'database', + 'recipients' => [['type' => 'role', 'value' => 'admin']], + 'subject' => [ + 'ko' => '비즈뿌리오 잔액 부족', + 'en' => 'Bizppurio balance low', + ], + 'body' => [ + 'ko' => '비즈뿌리오 잔액 부족으로 {channel_label} 발송이 실패했습니다 (코드: {result_code}).', + 'en' => 'A {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}).', + ], + ], + ], + ], + ]; + } + + /** + * 훅 리스너 목록 반환 + * + * Phase 3~6 에서 채널 등록/발송 위임/webhook 리스너를 추가한다. + * + * @return array + */ + public function getHookListeners(): array + { + return []; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts new file mode 100644 index 00000000..74d07367 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts @@ -0,0 +1,120 @@ +/** + * 비즈뿌리오 메시징 플러그인 레이아웃 테스트 공통 헬퍼 + * + * JSON 트리에서 ID/이름 검색 및 핸들러·i18n 키 추출을 위한 유틸리티. + */ + +export type AnyNode = Record & { + id?: string; + name?: string; + type?: string; + children?: AnyNode[]; + slots?: Record; + modals?: Record | AnyNode[]; + actions?: AnyNode[]; + iteration?: { source?: string; item_var?: string; index_var?: string }; + if?: string; + props?: Record; + text?: string; +}; + +/** + * 트리 노드에서 특정 id를 재귀 탐색 (children/slots/modals) + */ +export function findById(node: AnyNode | undefined | null, id: string): AnyNode | null { + if (!node) return null; + if (node.id === id) return node; + + if (Array.isArray(node.children)) { + for (const child of node.children) { + const found = findById(child, id); + if (found) return found; + } + } + + if (node.slots && typeof node.slots === 'object') { + for (const slotChildren of Object.values(node.slots)) { + if (Array.isArray(slotChildren)) { + for (const child of slotChildren) { + const found = findById(child, id); + if (found) return found; + } + } + } + } + + if (node.modals) { + const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals); + for (const m of modalEntries) { + const found = findById(m as AnyNode, id); + if (found) return found; + } + } + + return null; +} + +/** + * 트리 노드에서 특정 name 컴포넌트를 모두 수집 (children/slots/modals) + */ +export function findAllByName(node: AnyNode | undefined | null, name: string): AnyNode[] { + const results: AnyNode[] = []; + if (!node) return results; + if (node.name === name) results.push(node); + + if (Array.isArray(node.children)) { + for (const child of node.children) { + results.push(...findAllByName(child, name)); + } + } + if (node.slots && typeof node.slots === 'object') { + for (const slotChildren of Object.values(node.slots)) { + if (Array.isArray(slotChildren)) { + for (const child of slotChildren) { + results.push(...findAllByName(child, name)); + } + } + } + } + if (node.modals) { + const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals); + for (const m of modalEntries) { + results.push(...findAllByName(m as AnyNode, name)); + } + } + + return results; +} + +/** + * 특정 name 컴포넌트 중 props.name 속성이 주어진 값인 첫 노드를 찾음 (폼 입력 필드 탐색용) + */ +export function findInputByName(node: AnyNode | undefined | null, inputName: string): AnyNode | null { + for (const candidate of [...findAllByName(node, 'Input'), ...findAllByName(node, 'Select')]) { + if ((candidate.props as { name?: string } | undefined)?.name === inputName) { + return candidate; + } + } + return null; +} + +/** + * JSON 문자열에서 사용된 핸들러 이름 모두 수집 + */ +export function collectHandlers(json: unknown): string[] { + const text = JSON.stringify(json); + const matches = text.match(/"handler":\s*"([^"]+)"/g) ?? []; + const names = matches + .map((m) => m.match(/"handler":\s*"([^"]+)"/)?.[1] ?? '') + .filter(Boolean); + return Array.from(new Set(names)); +} + +/** + * JSON 문자열에서 사용된 i18n 키($t:sirsoft-message_bizppurio.*) 모두 수집 + */ +export function collectI18nKeys(json: unknown): string[] { + const text = JSON.stringify(json); + const matches = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; + return Array.from(new Set(matches)); +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx new file mode 100644 index 00000000..5d5e5bea --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx @@ -0,0 +1,154 @@ +/** + * 비즈뿌리오 메시징 플러그인 환경설정 레이아웃 구조 검증 (§6-1) + * + * 3 섹션: + * 1. section_api (연동 환경 + 비즈뿌리오 아이디 + 비밀번호 + API 키) + * 2. section_sending (발신번호 + 알림톡 발신프로필 키) + * 3. section_integration (webhook 수신 주소 안내) + * + * 크리덴셜(password/api_key/sender_key)은 type=password 로 마스킹, 저장은 + * 자동바인딩(_local.form) → 코어 /api/admin/plugins/{id}/settings PUT. + */ + +import { describe, it, expect } from 'vitest'; +import layout from '../../../layouts/admin/plugin_settings.json'; +import ko from '../../../lang/ko.json'; +import en from '../../../lang/en.json'; +import { + findById, + findInputByName, + collectHandlers, + collectI18nKeys, + type AnyNode, +} from './helpers'; + +const root = layout as unknown as AnyNode; + +describe('plugin_settings.json — 레이아웃 메타/권한', () => { + it('layout_name 이 plugin_settings 이다', () => { + expect((root as { layout_name?: string }).layout_name).toBe('plugin_settings'); + }); + + it('_admin_base 를 상속한다', () => { + expect((root as { extends?: string }).extends).toBe('_admin_base'); + }); + + it('core.plugins.update 권한을 요구한다', () => { + expect((root as { permissions?: string[] }).permissions).toContain('core.plugins.update'); + }); + + it('settings 데이터소스가 코어 플러그인 설정 API 를 조회한다', () => { + const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; + const settings = sources.find((s) => s.id === 'settings'); + expect(settings).toBeTruthy(); + expect(settings?.endpoint).toBe('/api/admin/plugins/{{route.identifier}}/settings'); + expect(settings?.initLocal).toBe('form'); + }); +}); + +describe('plugin_settings.json — 자동바인딩', () => { + it('컨테이너가 dataKey=form + trackChanges 로 자동바인딩한다', () => { + const container = findById(root, 'plugin_settings_content'); + expect(container).toBeTruthy(); + expect((container as { dataKey?: string }).dataKey).toBe('form'); + expect((container as { trackChanges?: boolean }).trackChanges).toBe(true); + }); +}); + +describe('plugin_settings.json — 3 섹션', () => { + it.each([ + ['section_api', 'API 연동'], + ['section_sending', '발송 설정'], + ['section_integration', '연동 안내'], + ])('%s 섹션이 존재한다', (id) => { + expect(findById(root, id)).toBeTruthy(); + }); +}); + +describe('plugin_settings.json — 입력 필드 6종', () => { + it.each([ + 'environment', + 'bizppurio_id', + 'password', + 'api_key', + 'sender_number', + 'sender_key', + ])('%s 입력 필드가 자동바인딩 name 으로 존재한다', (name) => { + expect(findInputByName(root, name)).toBeTruthy(); + }); + + it('크리덴셜(password/api_key/sender_key)은 type=password 로 마스킹된다', () => { + for (const cred of ['password', 'api_key', 'sender_key']) { + const input = findInputByName(root, cred); + expect((input?.props as { type?: string } | undefined)?.type).toBe('password'); + } + }); + + it('bizppurio_id/sender_number 는 일반 text 입력이다', () => { + for (const field of ['bizppurio_id', 'sender_number']) { + const input = findInputByName(root, field); + expect((input?.props as { type?: string } | undefined)?.type).toBe('text'); + } + }); + + it('environment 는 dev/live 옵션 Select 이다', () => { + const select = findInputByName(root, 'environment'); + const options = (select?.children ?? []).map( + (c) => (c.props as { value?: string } | undefined)?.value + ); + expect(options).toEqual(['dev', 'live']); + }); +}); + +describe('plugin_settings.json — webhook 안내', () => { + it('연동 안내 섹션에 고정 webhook 경로가 readonly 로 표시된다', () => { + const section = findById(root, 'section_integration'); + const inputs = JSON.stringify(section); + expect(inputs).toContain('/api/plugins/sirsoft-message_bizppurio/webhook'); + expect(inputs).toContain('"readOnly":true'); + }); +}); + +describe('plugin_settings.json — 저장 버튼', () => { + it('저장 버튼이 hasChanges 없으면 비활성화된다', () => { + const save = findById(root, 'save_button'); + expect((save?.props as { disabled?: string } | undefined)?.disabled).toContain('!_local.hasChanges'); + }); + + it('저장은 코어 설정 API 로 PUT 한다', () => { + const text = JSON.stringify(findById(root, 'save_button')); + expect(text).toContain('/api/admin/plugins/{{route.identifier}}/settings'); + expect(text).toContain('"method":"PUT"'); + expect(text).toContain('{{_local.form}}'); + }); + + it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => { + const handlers = collectHandlers(layout); + const allowed = ['apiCall', 'setState', 'toast', 'navigate', 'sequence', 'refetchDataSource', 'scrollIntoView']; + for (const h of handlers) { + expect(allowed).toContain(h); + } + }); +}); + +describe('plugin_settings.json — i18n 키 정합', () => { + it('레이아웃이 참조하는 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => { + const keys = collectI18nKeys(layout); + expect(keys.length).toBeGreaterThan(0); + + const resolve = (dict: Record, path: string): unknown => + path.split('.').reduce((acc, seg) => { + if (acc && typeof acc === 'object') { + return (acc as Record)[seg]; + } + return undefined; + }, dict); + + for (const raw of keys) { + // "$t:sirsoft-message_bizppurio.settings.title" → "settings.title" + const path = raw.replace('$t:sirsoft-message_bizppurio.', ''); + expect(resolve(ko, path), `ko 누락: ${path}`).toBeTruthy(); + expect(resolve(en, path), `en 누락: ${path}`).toBeTruthy(); + } + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json new file mode 100644 index 00000000..ef90355f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -0,0 +1,60 @@ +{ + "name": "Bizppurio Messaging", + "description": "Bizppurio (Daou Tech) SMS/LMS and KakaoTalk alimtalk messaging plugin.", + "settings": { + "title": "Bizppurio Messaging Settings", + "description": "Manage Bizppurio integration credentials and sending options.", + "save": "Save", + "saving": "Saving...", + "save_success": "Settings saved.", + "save_failed": "Failed to save settings.", + "sections": { + "api": { + "title": "API Integration", + "description": "Credentials shared by the sending system and the Kakao management system." + }, + "sending": { + "title": "Sending Options", + "description": "Sender information used for SMS and alimtalk delivery." + }, + "integration": { + "title": "Integration Guide", + "description": "The webhook address that receives delivery results." + } + }, + "fields": { + "environment": { + "label": "Environment", + "hint": "Select the inspection (dev) or production environment. The sending API domain differs by environment.", + "option_dev": "Inspection (Dev)", + "option_live": "Production" + }, + "bizppurio_id": { + "label": "Bizppurio ID", + "hint": "The Bizppurio account ID used for both sending and Kakao management." + }, + "password": { + "label": "Password", + "hint": "The Bizppurio password used to issue the sending token." + }, + "api_key": { + "label": "API Key", + "hint": "The API key is issued after you submit it together with your ID to Bizppurio customer support." + }, + "sender_number": { + "label": "Sender Number", + "hint": "The sender phone number used for SMS and alimtalk delivery." + }, + "sender_key": { + "label": "Alimtalk Sender Profile Key", + "hint": "The 40-character sender profile key used for alimtalk delivery and template lookup." + } + }, + "webhook": { + "label": "Delivery Result Address (webhook)", + "hint": "Register this address with the Bizppurio business team as the URL PUSH endpoint so delivery results are recorded automatically.", + "copy": "Copy", + "copied": "Copied." + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json new file mode 100644 index 00000000..e1d93a1f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -0,0 +1,60 @@ +{ + "name": "비즈뿌리오 메시징", + "description": "비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송 플러그인입니다.", + "settings": { + "title": "비즈뿌리오 메시징 설정", + "description": "비즈뿌리오 연동 정보와 발송 설정을 관리합니다.", + "save": "저장", + "saving": "저장 중...", + "save_success": "설정이 저장되었습니다.", + "save_failed": "설정 저장에 실패했습니다.", + "sections": { + "api": { + "title": "API 연동", + "description": "발송 시스템과 카카오 관리 시스템에 공통으로 사용하는 연동 정보입니다." + }, + "sending": { + "title": "발송 설정", + "description": "문자·알림톡 발송에 사용하는 발신 정보입니다." + }, + "integration": { + "title": "연동 안내", + "description": "발송 결과를 수신하는 webhook 주소입니다." + } + }, + "fields": { + "environment": { + "label": "연동 환경", + "hint": "검수(개발) 또는 운영 환경을 선택합니다. 발송 API 도메인이 환경에 따라 분기됩니다.", + "option_dev": "검수(개발)", + "option_live": "운영" + }, + "bizppurio_id": { + "label": "비즈뿌리오 아이디", + "hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다." + }, + "password": { + "label": "비밀번호", + "hint": "발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다." + }, + "api_key": { + "label": "API 키", + "hint": "API 키는 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다." + }, + "sender_number": { + "label": "발신번호", + "hint": "문자·알림톡 발송에 사용하는 발신 전화번호입니다." + }, + "sender_key": { + "label": "알림톡 발신프로필 키", + "hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다." + } + }, + "webhook": { + "label": "발송 결과 수신 주소 (webhook)", + "hint": "비즈뿌리오 사업팀에 이 주소를 URL PUSH 수신 주소로 등록하면 발송 결과가 이력에 자동 기록됩니다.", + "copy": "복사", + "copied": "복사되었습니다." + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json new file mode 100644 index 00000000..751cdb1e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -0,0 +1,644 @@ +{ + "version": "1.0.0", + "layout_name": "plugin_settings", + "permissions": [ + "core.plugins.update" + ], + "extends": "_admin_base", + "meta": { + "title": "$t:sirsoft-message_bizppurio.settings.title", + "description": "$t:sirsoft-message_bizppurio.settings.description" + }, + "data_sources": [ + { + "id": "settings", + "type": "api", + "endpoint": "/api/admin/plugins/{{route.identifier}}/settings", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "initLocal": "form", + "refetchOnMount": true + } + ], + "slots": { + "content": [ + { + "id": "plugin_settings_content", + "type": "basic", + "name": "Div", + "dataKey": "form", + "trackChanges": true, + "props": { + "className": "p-4 sm:p-6 lg:p-8 min-h-screen bg-gray-50 dark:bg-gray-900" + }, + "children": [ + { + "id": "page_header", + "type": "composite", + "name": "PageHeader", + "props": { + "title": "$t:sirsoft-message_bizppurio.settings.title", + "description": "$t:sirsoft-message_bizppurio.settings.description", + "backPath": "/admin/plugins", + "backLabel": "$t:admin.plugins.back_to_list" + } + }, + { + "id": "validation_error", + "type": "basic", + "name": "Div", + "if": "{{_local.errors}}", + "props": { + "id": "bizppurio_settings_validation_error", + "role": "alert", + "aria-live": "assertive", + "tabIndex": -1, + "className": "alert-danger scroll-mt-32" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex gap-3" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "triangle-exclamation", + "size": "md", + "className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "text-error-soft" + }, + "text": "$t:admin.plugins.settings.validation_error" + }, + { + "type": "basic", + "name": "Ul", + "if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}", + "props": { + "className": "mt-2 list-disc list-inside space-y-1" + }, + "iteration": { + "source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])", + "item_var": "message" + }, + "children": [ + { + "type": "basic", + "name": "Li", + "text": "{{message}}" + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "section_api", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "text-base font-semibold text-gray-900 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.api.title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.api.description" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-6" + }, + "children": [ + { + "id": "field_environment", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.label" + }, + { + "type": "basic", + "name": "Select", + "props": { + "name": "environment", + "className": "input mt-1" + }, + "children": [ + { + "type": "basic", + "name": "Option", + "props": { + "value": "dev" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.option_dev" + }, + { + "type": "basic", + "name": "Option", + "props": { + "value": "live" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.option_live" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.hint" + } + ] + }, + { + "id": "field_bizppurio_id", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.label" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "bizppurio_id", + "autoComplete": "off", + "className": "input mt-1" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.hint" + } + ] + }, + { + "id": "field_password", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.password.label" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "password", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "input mt-1 font-mono" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.password.hint" + } + ] + }, + { + "id": "field_api_key", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.label" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "api_key", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "input mt-1 font-mono" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.hint" + } + ] + } + ] + } + ] + }, + { + "id": "section_sending", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "text-base font-semibold text-gray-900 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.description" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-6" + }, + "children": [ + { + "id": "field_sender_number", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.label" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "sender_number", + "autoComplete": "off", + "className": "input mt-1" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.hint" + } + ] + }, + { + "id": "field_sender_key", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.label" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "sender_key", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "input mt-1 font-mono" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.hint" + } + ] + } + ] + } + ] + }, + { + "id": "section_integration", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "text-base font-semibold text-gray-900 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.integration.title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.integration.description" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.webhook.label" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "readOnly": true, + "value": "/api/plugins/sirsoft-message_bizppurio/webhook", + "className": "input flex-1 font-mono text-sm bg-gray-50 dark:bg-gray-900" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.webhook.hint" + } + ] + } + ] + }, + { + "id": "footer_buttons", + "type": "basic", + "name": "Div", + "props": { + "className": "sticky bottom-0 z-10 flex justify-end items-center gap-3 mt-6 px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700" + }, + "children": [ + { + "id": "cancel_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-secondary" + }, + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:common.cancel" + } + ] + }, + { + "id": "save_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{!_local.hasChanges || _local.isSaving}}", + "className": "btn btn-primary flex-center gap-1.5" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": true, + "errors": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/admin/plugins/{{route.identifier}}/settings", + "params": { + "method": "PUT", + "body": "{{_local.form}}" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": false, + "hasChanges": false + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:admin.plugins.settings.save_success" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "settings" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": false, + "errors": "{{error.errors ?? { _general: error.message }}}" + } + }, + { + "handler": "scrollIntoView", + "params": { + "selector": "#bizppurio_settings_validation_error", + "behavior": "smooth", + "block": "start", + "waitForElement": true, + "timeout": 2000 + } + }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "$t:admin.plugins.settings.save_error" + } + } + ] + } + ] + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "if": "{{_local.isSaving}}", + "props": { + "name": "spinner", + "className": "spinner-sm" + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{!_local.isSaving}}", + "text": "$t:common.save" + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.isSaving}}", + "text": "$t:common.saving" + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php new file mode 100644 index 00000000..58ede0a3 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php @@ -0,0 +1,52 @@ + __('sirsoft-message_bizppurio::messages.channel.sms'), + self::Lms => __('sirsoft-message_bizppurio::messages.channel.lms'), + self::Alimtalk => __('sirsoft-message_bizppurio::messages.channel.alimtalk'), + }; + } + + /** + * 문자(SMS/LMS) 채널 여부를 반환합니다. + * + * @return bool 문자 채널이면 true, 알림톡이면 false + */ + public function isText(): bool + { + return $this === self::Sms || $this === self::Lms; + } + + /** + * 모든 채널 값 목록을 반환합니다. + * + * @return array 채널 문자열 배열 (예: ["sms", "lms", "alimtalk"]) + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php new file mode 100644 index 00000000..a81cd214 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php @@ -0,0 +1,42 @@ + __('sirsoft-message_bizppurio::messages.source.auto'), + self::Manual => __('sirsoft-message_bizppurio::messages.source.manual'), + self::Bulk => __('sirsoft-message_bizppurio::messages.source.bulk'), + }; + } + + /** + * 모든 출처 값 목록을 반환합니다. + * + * @return array 출처 문자열 배열 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php new file mode 100644 index 00000000..0377e545 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php @@ -0,0 +1,54 @@ + __('sirsoft-message_bizppurio::messages.status.pending'), + self::Sent => __('sirsoft-message_bizppurio::messages.status.sent'), + self::Success => __('sirsoft-message_bizppurio::messages.status.success'), + self::Failed => __('sirsoft-message_bizppurio::messages.status.failed'), + }; + } + + /** + * 리포트 수신으로 상태가 확정되었는지(성공/실패) 여부를 반환합니다. + * + * @return bool 성공 또는 실패이면 true, 대기/발송중이면 false + */ + public function isFinal(): bool + { + return $this === self::Success || $this === self::Failed; + } + + /** + * 모든 상태 값 목록을 반환합니다. + * + * @return array 상태 문자열 배열 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php new file mode 100644 index 00000000..4475233d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php @@ -0,0 +1,69 @@ + __('sirsoft-message_bizppurio::messages.result_category.success'), + self::Retry => __('sirsoft-message_bizppurio::messages.result_category.retry'), + self::PermanentFailure => __('sirsoft-message_bizppurio::messages.result_category.permanent_failure'), + self::BalanceLow => __('sirsoft-message_bizppurio::messages.result_category.balance_low'), + }; + } + + /** + * 재시도 대상 분류 여부를 반환합니다. + * + * @return bool 일시 오류(retry)이면 true + */ + public function isRetryable(): bool + { + return $this === self::Retry; + } + + /** + * 발송 실패로 확정되는 분류 여부를 반환합니다. + * + * @return bool 영구 실패 또는 잔액 부족이면 true + */ + public function isFailure(): bool + { + return $this === self::PermanentFailure || $this === self::BalanceLow; + } + + /** + * 모든 분류 값 목록을 반환합니다. + * + * @return array 분류 문자열 배열 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php new file mode 100644 index 00000000..6e19a775 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -0,0 +1,58 @@ + + */ + protected array $repositories = [ + // Phase 4: BizppurioDispatchRepositoryInterface / BizppurioNotificationBindingRepositoryInterface + ]; + + /** + * CacheInterface 가 필요한 서비스 (contextual binding). + * + * Phase 2 에서 발송 토큰 캐시 서비스(BizppurioTokenService)를 등록한다. + * + * @var array + */ + protected array $cacheServices = [ + // Phase 2: BizppurioTokenService::class + ]; + + /** + * 플러그인 루트 lang 디렉토리를 로드합니다. + * + * 백엔드 다국어는 lang/{ko,en}/*.php 에 두고 `$this->translationNamespace()` + * (= 플러그인 식별자) 네임스페이스로 로드한다. + * + * @return void + */ + protected function loadExtensionTranslations(): void + { + $langPath = dirname($this->getProviderPath(), 2).'/lang'; + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, $this->translationNamespace()); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php new file mode 100644 index 00000000..448f2cd5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php @@ -0,0 +1,140 @@ +plugin = new Plugin; + } + + public function test_식별자가_디렉토리명과_일치한다(): void + { + $this->assertSame('sirsoft-message_bizppurio', $this->plugin->getIdentifier()); + $this->assertSame('sirsoft', $this->plugin->getVendor()); + } + + public function test_권한이_view_manage_2개로_선언된다(): void + { + $permissions = $this->plugin->getPermissions(); + + $this->assertArrayHasKey('categories', $permissions); + $this->assertCount(1, $permissions['categories']); + + $category = $permissions['categories'][0]; + $this->assertSame('messaging', $category['identifier']); + + $actions = array_column($category['permissions'], 'action'); + $this->assertEqualsCanonicalizing(['view', 'manage'], $actions); + + foreach ($category['permissions'] as $permission) { + $this->assertContains('admin', $permission['roles']); + $this->assertSame('admin', $permission['type']); + } + } + + public function test_설정_스키마의_크리덴셜은_sensitive다(): void + { + $schema = $this->plugin->getSettingsSchema(); + + foreach (['password', 'api_key', 'sender_key'] as $credential) { + $this->assertArrayHasKey($credential, $schema); + $this->assertTrue( + $schema[$credential]['sensitive'] ?? false, + "{$credential} 는 sensitive 로 마킹되어야 한다." + ); + } + + // 비크리덴셜 필드는 존재하되 sensitive 아님 + $this->assertArrayHasKey('bizppurio_id', $schema); + $this->assertArrayHasKey('sender_number', $schema); + $this->assertArrayHasKey('environment', $schema); + $this->assertSame('dev', $schema['environment']['default']); + } + + public function test_defaults_json의_크리덴셜은_expose_false다(): void + { + $path = dirname(__DIR__, 3).'/config/settings/defaults.json'; + $this->assertFileExists($path); + + $defaults = json_decode(file_get_contents($path), true); + $schema = $defaults['frontend_schema']; + + foreach ($schema as $field => $rule) { + $this->assertFalse( + $rule['expose'] ?? true, + "{$field} 는 프론트에 노출(expose:true)되면 안 된다." + ); + } + + foreach (['password', 'api_key', 'sender_key'] as $credential) { + $this->assertTrue($schema[$credential]['sensitive'] ?? false); + } + } + + public function test_잔액부족_알림이_관리자_대상으로_정의된다(): void + { + $definitions = $this->plugin->getNotificationDefinitions(); + + $this->assertCount(1, $definitions); + $definition = $definitions[0]; + + $this->assertSame('bizppurio_balance_low', $definition['type']); + $this->assertSame('sirsoft-message_bizppurio', $definition['hook_prefix']); + $this->assertEqualsCanonicalizing(['mail', 'database'], $definition['channels']); + + foreach ($definition['templates'] as $template) { + $recipients = $template['recipients']; + $this->assertSame('role', $recipients[0]['type']); + $this->assertSame('admin', $recipients[0]['value']); + } + } + + public function test_activate_시_관리자_메뉴가_계층으로_생성된다(): void + { + $this->plugin->activate(); + + $parent = Menu::where('slug', 'sirsoft-message_bizppurio') + ->where('extension_type', ExtensionOwnerType::Plugin->value) + ->first(); + + $this->assertNotNull($parent, '최상위 메뉴가 생성되어야 한다.'); + + $children = Menu::where('parent_id', $parent->id)->get(); + $this->assertCount(2, $children, '알림톡 템플릿 관리·발송 이력 2개 하위 메뉴가 생성되어야 한다.'); + + $childSlugs = $children->pluck('slug')->all(); + $this->assertContains('sirsoft-message_bizppurio-alimtalk-templates', $childSlugs); + $this->assertContains('sirsoft-message_bizppurio-dispatches', $childSlugs); + } + + public function test_deactivate_시_메뉴가_제거된다(): void + { + $this->plugin->activate(); + $this->assertGreaterThan(0, Menu::where('extension_identifier', 'sirsoft-message_bizppurio')->count()); + + $this->plugin->deactivate(); + + $this->assertSame( + 0, + Menu::where('extension_identifier', 'sirsoft-message_bizppurio')->count(), + '비활성화 시 플러그인 소속 메뉴가 전부 제거되어야 한다.' + ); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php new file mode 100644 index 00000000..979844b5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php @@ -0,0 +1,107 @@ +snapshotHookManager(); + } + + /** + * tearDown 에 HookManager 상태 복원. + * + * @return void + */ + protected function tearDown(): void + { + $this->restoreHookManager(); + + parent::tearDown(); + } + + /** + * HookManager static $hooks / $filters / $dispatching 를 스냅샷. + * + * @return void + */ + private function snapshotHookManager(): void + { + $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $this->hookSnapshot = [ + 'hooks' => $ref->getProperty('hooks')->getValue(), + 'filters' => $ref->getProperty('filters')->getValue(), + 'dispatching' => $ref->getProperty('dispatching')->getValue(), + ]; + } + + /** + * 스냅샷 시점으로 HookManager 복원. + * + * @return void + */ + private function restoreHookManager(): void + { + if ($this->hookSnapshot === null) { + return; + } + + $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']); + $ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']); + $ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']); + + $this->hookSnapshot = null; + } + + /** + * 마이그레이션 경로를 반환합니다. + * + * RefreshDatabase 의 migrate:fresh 명령에 코어 + 번들 확장 마이그레이션 경로를 전달합니다. + * + * @return array + */ + protected function migrateFreshUsing(): array + { + $paths = ['database/migrations']; + foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { + $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p); + } + foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { + $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p); + } + + return [ + '--drop-views' => $this->shouldDropViews(), + '--drop-types' => $this->shouldDropTypes(), + '--seed' => false, + '--path' => $paths, + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php new file mode 100644 index 00000000..6d3718c9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php @@ -0,0 +1,64 @@ +assertEqualsCanonicalizing(['sms', 'lms', 'alimtalk'], DispatchChannel::values()); + $this->assertTrue(DispatchChannel::Sms->isText()); + $this->assertTrue(DispatchChannel::Lms->isText()); + $this->assertFalse(DispatchChannel::Alimtalk->isText()); + $this->assertNull(DispatchChannel::tryFrom('invalid')); + } + + public function test_dispatch_status_값과_final_판정(): void + { + $this->assertEqualsCanonicalizing(['pending', 'sent', 'success', 'failed'], DispatchStatus::values()); + $this->assertFalse(DispatchStatus::Pending->isFinal()); + $this->assertFalse(DispatchStatus::Sent->isFinal()); + $this->assertTrue(DispatchStatus::Success->isFinal()); + $this->assertTrue(DispatchStatus::Failed->isFinal()); + } + + public function test_dispatch_source_값(): void + { + $this->assertEqualsCanonicalizing(['auto', 'manual', 'bulk'], DispatchSource::values()); + $this->assertSame('auto', DispatchSource::Auto->value); + } + + public function test_result_category_분류_판정(): void + { + $this->assertEqualsCanonicalizing( + ['success', 'retry', 'permanent_failure', 'balance_low'], + ResultCategory::values() + ); + + $this->assertTrue(ResultCategory::Retry->isRetryable()); + $this->assertFalse(ResultCategory::Success->isRetryable()); + + $this->assertTrue(ResultCategory::PermanentFailure->isFailure()); + $this->assertTrue(ResultCategory::BalanceLow->isFailure()); + $this->assertFalse(ResultCategory::Success->isFailure()); + $this->assertFalse(ResultCategory::Retry->isFailure()); + } + + public function test_label은_다국어_문자열을_반환한다(): void + { + // label() 이 예외 없이 문자열을 반환하는지 (lang 키 해석 스모크) + $this->assertIsString(DispatchChannel::Sms->label()); + $this->assertIsString(DispatchStatus::Success->label()); + $this->assertIsString(DispatchSource::Auto->label()); + $this->assertIsString(ResultCategory::BalanceLow->label()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml new file mode 100644 index 00000000..e76e0f21 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -0,0 +1,66 @@ +# audit:allow test-scenario-coverage reason: | +# Phase 1 환경설정 화면은 입력·저장이 코어 /api/admin/plugins/{id}/settings 에 위임되는 +# 단순 자동바인딩 폼이라, axes cross product 의 런타임 효과(저장 성공/실패 왕복)는 코어 설정 +# 컨트롤러가 책임진다. 본 확장의 effects(3섹션 구조 / 6필드 / 크리덴셜 마스킹 / webhook 안내 / +# 저장 버튼 게이팅 / i18n 정합 / 권한·메뉴·알림 정의 / 설정 초기값 로드)는 PHPUnit 12건 +# (InstallationTest·EnumTest) + Vitest 22건(plugin_settings.test.tsx) 정적 검증으로 가드된다. +# 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 인증·발송 환경 필요) +# 단계에서 최초 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을 추가한다. 그때까지 +# 매니페스트는 명세로 유지한다. + +feature: 비즈뿌리오 메시징 환경설정 (Phase 1) + +description: | + 관리자 환경설정 화면(admin/plugin_settings.json)에서 비즈뿌리오 연동 정보를 입력·저장한다. + 코어 플러그인 설정 인프라(/api/admin/plugins/{id}/settings)에 자동바인딩(_local.form)으로 + 위임하며, 3 섹션(API 연동 / 발송 설정 / 연동 안내)으로 구성된다. + + 핵심 동작: + - API 연동: 연동 환경(dev/live) + 비즈뿌리오 아이디 + 비밀번호 + API 키 + - 발송 설정: 발신번호 + 알림톡 발신프로필 키 + - 연동 안내: 발송 결과 수신 webhook 주소(readonly 표시) + - 크리덴셜(password/api_key/sender_key)은 type=password 마스킹 + frontend_schema expose:false + - 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT + - 설치·활성화 시 권한 2개(view/manage)·메뉴 3개·잔액부족 알림 정의 생성 + 설정 초기값 로드 + + ※ 알림톡 채널 기본 body 시드(3영역)는 채널 등록(Phase 3)·탭 연동(Phase 6)과 강결합이므로 + Phase 6 으로 이관(plugin.php getNotificationDefinitions 주석 참조). + +axes: + credential_state: [empty, filled] + environment: [dev, live] + field_kind: [credential, plain, readonly] + +exclusions: + - { field_kind: readonly, credential_state: filled, reason: "webhook 주소는 고정 표시값 — 운영자 입력 대상 아님" } + +effects: + # 섹션 구조 + - three_sections_api_sending_integration_present + # 입력 필드 + - six_input_fields_bound_by_name + - credential_fields_masked_as_password + - plain_fields_use_text_input + - environment_select_has_dev_live_options + # 연동 안내 + - webhook_address_shown_readonly + # 저장 + - save_button_disabled_without_changes + - save_puts_to_core_plugin_settings_api + - only_registered_handlers_used + # 다국어 + - all_referenced_i18n_keys_exist_in_ko_and_en + # 설치·활성화 (백엔드) + - install_creates_view_and_manage_permissions + - activate_creates_hierarchical_admin_menus + - deactivate_removes_plugin_menus + - balance_low_notification_targets_admin + - settings_defaults_load_with_dev_environment + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php + +audit: + status: codified diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json b/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json new file mode 100644 index 00000000..170e1528 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["resources/js/*"] + } + }, + "include": ["resources/js/**/*.ts", "resources/js/**/*.tsx"] +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts new file mode 100644 index 00000000..ffd85611 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + include: ['resources/js/__tests__/**/*.test.{ts,tsx}'], + setupFiles: [], + }, + resolve: { + alias: { + '@': resolve(__dirname, 'resources/js'), + }, + }, +});