From 73b05aa42f8e9dbe48d8c449d60c75f6fdc84ab4 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 14 Jul 2026 16:16:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=ED=86=A1=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20(=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=83=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 페이지 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 등록·수정·삭제·검수· 상태변경한다. 진입은 코어 소유 설정 페이지 하나로 통일하고 getAdminMenus 는 폐기했다. 템플릿은 kapi 에 실시간 위임하며 DB 에 저장하지 않는다. 등록 폼은 계획서 §6-4 전체를 구현한다: 템플릿 코드(자동 생성/직접 입력), 카테고리 2단 드롭다운, 템플릿 유형(기본형/이미지형/강조표기형), 이미지형 파일 업로드, 본문 변수 클릭 삽입 + 알림별 변수 참고표, 선택 항목 6개 체크펼침(부가정보·미리보기·버튼·바로연결·대표링크·보안), 등록/등록 후 검수요청 2버튼. --- docs/backend/api/README.md | 4 +- .../sirsoft-message_bizppurio/CHANGELOG.md | 7 +- .../dist/js/plugin.iife.js | 2 + .../dist/js/plugin.iife.js.map | 1 + .../docs/api/README.md | 3 +- .../docs/api/alimtalk-templates.md | 730 ++++++++++ .../lang/en/activity_log.php | 34 + .../lang/en/messages.php | 50 + .../lang/ko/activity_log.php | 34 + .../lang/ko/messages.php | 50 + .../sirsoft-message_bizppurio/package.json | 4 + .../sirsoft-message_bizppurio/plugin.json | 7 + .../sirsoft-message_bizppurio/plugin.php | 78 +- .../layouts/alimtalk_templates.test.tsx | 236 ++++ .../layouts/plugin_settings.test.tsx | 12 +- .../resources/js/handlers/index.ts | 14 + .../resources/js/handlers/insertVariable.ts | 103 ++ .../js/handlers/uploadTemplateImage.ts | 98 ++ .../resources/js/index.ts | 94 ++ .../resources/js/types.ts | 28 + .../resources/lang/en.json | 143 ++ .../resources/lang/ko.json | 143 ++ .../layouts/admin/plugin_settings.json | 1222 ++++++++++++++++- .../Admin/AlimtalkTemplateController.php | 284 ++++ .../Requests/StoreAlimtalkTemplateRequest.php | 109 ++ .../UpdateAlimtalkTemplateRequest.php | 16 + .../Requests/UploadTemplateImageRequest.php | 51 + .../src/Services/AlimtalkTemplateService.php | 581 ++++++++ .../src/Services/BizppurioKakaoApiClient.php | 42 + .../src/routes/api.php | 37 + .../AlimtalkTemplateControllerTest.php | 301 ++++ .../Feature/Installation/InstallationTest.php | 43 +- .../Services/AlimtalkTemplateServiceTest.php | 274 ++++ .../Services/BizppurioKakaoApiClientTest.php | 27 + .../tests/scenarios/alimtalk-templates.yaml | 104 ++ .../tests/scenarios/plugin-settings.yaml | 6 +- .../sirsoft-message_bizppurio/vite.config.ts | 41 + 37 files changed, 4902 insertions(+), 111 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 34b2ebfd..db6fff1f 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 10 · **엔드포인트 수**: 374 +- **확장 수**: 10 · **엔드포인트 수**: 388 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -212,7 +212,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | -| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 2 / 2 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 3 / 16 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 256ccfa2..aa2082ce 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -11,10 +11,15 @@ - 비즈뿌리오 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다. - 연동 환경을 운영으로 설정하면 발송에 필요한 아이디·비밀번호·발신번호가 반드시 입력되도록 검증하고, 실제 발송·비용이 발생한다는 경고를 함께 안내합니다. - 발송 결과(리포트)를 받을 수신 주소를 설정 화면에서 확인·복사할 수 있습니다. 이 주소를 비즈뿌리오에 등록하면 발송 결과가 이력에 자동 기록됩니다. -- 관리자 「메시지 발송 관리」 메뉴와 조회·관리 권한을 추가했습니다. +- 비즈뿌리오 메시지 발송 기능에 대한 조회·관리 권한을 추가했습니다. 관련 화면은 플러그인 설정 페이지에서 진입합니다. - 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다. - 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. - 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다. - 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.) - 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). (발송 결과를 확인하는 화면은 후속 단계에서 제공됩니다.) - 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. +- 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 직접 등록·수정·삭제하고 검수를 요청할 수 있습니다. 등록한 템플릿의 상태(발송가능·검수중·반려·미검수·중지 등)를 배지로 확인하고, 상태별로 가능한 관리 동작(검수 요청·중지·승인 취소 등)을 바로 실행할 수 있습니다. +- 템플릿 등록 화면에서 템플릿명·템플릿 코드(자동 생성 또는 직접 입력)·카테고리(대분류/소분류)·템플릿 유형(기본형·이미지형·강조표기형)·본문을 입력하고, 우측에서 실시간 미리보기를 확인할 수 있습니다. 이미지형은 이미지를 첨부하면 자동으로 업로드됩니다. (아이템리스트형 등 고급 유형은 비즈뿌리오 홈페이지에서 등록한 뒤에도 목록에서 함께 관리할 수 있습니다.) +- 본문에 넣을 수 있는 변수를 클릭하면 커서 위치에 바로 삽입되며, 알림 종류별로 제공되는 변수 예시를 참고표로 함께 안내합니다. +- 선택 항목(부가정보·미리보기 메시지·버튼·바로연결·대표 링크·템플릿 보안)을 체크해 필요한 것만 펼쳐 입력할 수 있습니다. +- 템플릿은 [등록]으로 임시 저장하거나 [등록 후 검수요청]으로 등록과 동시에 카카오 검수를 요청할 수 있습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js new file mode 100644 index 00000000..057cb622 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js @@ -0,0 +1,2 @@ +var SirsoftMessageBizppurio=(function(f){"use strict";const w=window.G7Core?.createLogger?.("MessageBizppurio:InsertVariable")??{log:(...e)=>console.log("[MessageBizppurio:InsertVariable]",...e),warn:(...e)=>console.warn("[MessageBizppurio:InsertVariable]",...e),error:(...e)=>console.error("[MessageBizppurio:InsertVariable]",...e)},y="templateContent",A="templateForm.templateContent";function C(e,o){const t=window.G7Core,g=e.params??{},n=String(g.variable??"").trim(),a=String(g.target??y),s=String(g.statePath??A);if(n===""){w.warn("삽입할 변수명이 지정되지 않았습니다.");return}if(!t?.state?.getLocal||!t?.state?.setLocal){w.error("G7Core.state API 를 사용할 수 없습니다.");return}const d=o?.state??t.state.getLocal()??{},r=String(P(d,s)??""),i=`#{${n}}`,l=document.querySelector(`textarea[name="${a}"], input[name="${a}"]`),u=(l?.value??"")===r,b=u?l?.selectionStart??r.length:r.length,E=u?l?.selectionEnd??r.length:r.length,M=r.slice(0,b)+i+r.slice(E);if(t.state.setLocal({[s]:M}),l){const $=b+i.length;requestAnimationFrame(()=>{try{l.focus(),l.setSelectionRange($,$)}catch{}})}w.log(`변수 삽입: ${i} → ${s}`)}function P(e,o){return o.split(".").reduce((t,g)=>t?.[g],e)}const p=window.G7Core?.createLogger?.("MessageBizppurio:UploadImage")??{log:(...e)=>console.log("[MessageBizppurio:UploadImage]",...e),warn:(...e)=>console.warn("[MessageBizppurio:UploadImage]",...e),error:(...e)=>console.error("[MessageBizppurio:UploadImage]",...e)},G="/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image";async function z(e,o){const t=window.G7Core;if(!t?.state?.setLocal){p.error("G7Core.state.setLocal 을 사용할 수 없습니다.");return}const n=(o?.event??e?.event)?.target,a=n?.files?.[0];if(a){t.state.setLocal({imageUploading:!0,imageUploadError:null});try{const s=localStorage.getItem("auth_token")??"",d=new FormData;d.append("image",a,a.name);const r=await fetch(G,{method:"POST",headers:{Accept:"application/json",...s?{Authorization:`Bearer ${s}`}:{}},body:d}),i=await r.json().catch(()=>({}));if(!r.ok||i?.success===!1){const u=i?.errors?.kakao_message||i?.message||"이미지 업로드에 실패했습니다.";t.state.setLocal({imageUploading:!1,imageUploadError:u}),p.warn("이미지 업로드 실패:",u);return}const l=i?.data?.image_url??"",U=i?.data?.image_name??a.name;t.state.setLocal({imageUploading:!1,imageUploadError:null,"templateForm.templateImageUrl":l,"templateForm.templateImageName":U}),p.log("이미지 업로드 완료:",l)}catch(s){t.state.setLocal({imageUploading:!1,imageUploadError:String(s)}),p.error("이미지 업로드 예외:",s)}finally{n&&(n.value="")}}}const m={insertVariable:C,uploadTemplateImage:z},c="sirsoft-message_bizppurio",h=window.G7Core?.createLogger?.(`Plugin:${c}`)??{log:(...e)=>console.log(`[Plugin:${c}]`,...e),warn:(...e)=>console.warn(`[Plugin:${c}]`,...e),error:(...e)=>console.error(`[Plugin:${c}]`,...e)};function I(e){Object.entries(m).forEach(([o,t])=>{e.registerHandler(`${c}.${o}`,t,{category:"plugin",source:c})}),h.log(`${Object.keys(m).length} handler(s) registered:`,Object.keys(m).map(o=>`${c}.${o}`))}function S(e){const o=window.G7Core?.getActionDispatcher?.();if(o){I(o);return}if(!e){h.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let t=0;const g=50,n=()=>{const a=window.G7Core?.getActionDispatcher?.();if(a){I(a);return}++t<=g?setTimeout(n,100):h.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};n()}function L(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>S(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();S(!e)}}return L(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:c,handlers:Object.keys(m),initPlugin:L}),f.initPlugin=L,Object.defineProperty(f,Symbol.toStringTag,{value:"Module"}),f})({}); +//# sourceMappingURL=plugin.iife.js.map diff --git a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map new file mode 100644 index 00000000..63320d32 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map @@ -0,0 +1 @@ +{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/insertVariable.ts","../../resources/js/handlers/uploadTemplateImage.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * insertVariable 핸들러\n *\n * 알림톡 템플릿 등록/수정 화면에서 \"사용 가능 변수\" 버튼을 클릭하면, 대상 본문의 현재\n * 커서 위치에 `#{변수}` 를 삽입한다(D8). 값 자체는 G7 상태(`_local.`)를 SSoT\n * 로 삼아 setLocal 로 갱신한다. DOM 값을 직접 쓰지 않으므로 자동/수동 바인딩 충돌\n * (삽입 후 다음 렌더에서 상태값으로 덮여 사라지는 문제)이 발생하지 않는다.\n *\n * 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다.\n *\n * 레이아웃 JSON 사용 예:\n * {\n * \"handler\": \"sirsoft-message_bizppurio.insertVariable\",\n * \"params\": { \"variable\": \"name\", \"target\": \"templateContent\", \"statePath\": \"templateForm.templateContent\" }\n * }\n */\n\nimport type { ActionContext, ActionWithParams } from '../types';\n\nconst logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? {\n log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args),\n warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args),\n error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args),\n};\n\n/** 삽입 대상 본문 필드(name) 기본값 */\nconst DEFAULT_TARGET = 'templateContent';\n\n/** 상태 경로 기본값 (_local 하위) */\nconst DEFAULT_STATE_PATH = 'templateForm.templateContent';\n\n/**\n * 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다.\n *\n * @param action 액션 객체(params.variable, params.target, params.statePath)\n * @param context 액션 컨텍스트(state 스냅샷)\n */\nexport function insertVariableHandler(\n action: ActionWithParams,\n context: ActionContext,\n): void {\n const G7Core = (window as any).G7Core;\n const params = action.params ?? {};\n const variable = String(params.variable ?? '').trim();\n const target = String(params.target ?? DEFAULT_TARGET);\n const statePath = String(params.statePath ?? DEFAULT_STATE_PATH);\n\n if (variable === '') {\n logger.warn('삽입할 변수명이 지정되지 않았습니다.');\n return;\n }\n\n if (!G7Core?.state?.getLocal || !G7Core?.state?.setLocal) {\n logger.error('G7Core.state API 를 사용할 수 없습니다.');\n return;\n }\n\n // 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getLocal.\n const localState = (context as any)?.state ?? G7Core.state.getLocal() ?? {};\n const current = String(readPath(localState, statePath) ?? '');\n\n const token = `#{${variable}}`;\n\n // 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립.\n const field = document.querySelector(\n `textarea[name=\"${target}\"], input[name=\"${target}\"]`,\n );\n const domValue = field?.value ?? '';\n // DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다.\n const inSync = domValue === current;\n const start = inSync ? (field?.selectionStart ?? current.length) : current.length;\n const end = inSync ? (field?.selectionEnd ?? current.length) : current.length;\n\n const next = current.slice(0, start) + token + current.slice(end);\n\n G7Core.state.setLocal({ [statePath]: next });\n\n // 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서).\n if (field) {\n const caret = start + token.length;\n requestAnimationFrame(() => {\n try {\n field.focus();\n field.setSelectionRange(caret, caret);\n } catch {\n // setSelectionRange 미지원 input — 무시\n }\n });\n }\n\n logger.log(`변수 삽입: ${token} → ${statePath}`);\n}\n\n/**\n * dot notation 경로로 객체에서 값을 읽습니다. (예: \"templateForm.templateContent\")\n *\n * @param obj 대상 객체\n * @param path dot 경로\n * @return 경로 값(없으면 undefined)\n */\nfunction readPath(obj: Record, path: string): unknown {\n return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);\n}\n","/**\n * uploadTemplateImage 핸들러\n *\n * 이미지형 알림톡 템플릿 등록 화면에서 파일 선택(change) 시, 선택한 이미지를 플러그인의\n * 이미지 업로드 API(카카오 서버 업로드 위임)로 전송하고, 반환된 카카오 이미지 URL 을\n * `_local.templateForm.templateImageUrl` 에, 파일명을 `templateImageName` 에 설정한다.\n *\n * 업로드는 multipart/form-data 이므로 apiCall 핸들러로는 처리하기 번거로워 전용 핸들러로 둔다.\n * 진행 상태는 `_local.imageUploading`, 오류는 `_local.imageUploadError` 로 노출한다.\n *\n * 레이아웃 JSON 사용 예:\n * {\n * \"type\": \"change\",\n * \"handler\": \"sirsoft-message_bizppurio.uploadTemplateImage\"\n * }\n * (대상 file input 의 change 이벤트에 바인딩; $event.target.files[0] 를 사용)\n */\n\nimport type { ActionContext, ActionWithParams } from '../types';\n\nconst logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:UploadImage')) ?? {\n log: (...args: unknown[]) => console.log('[MessageBizppurio:UploadImage]', ...args),\n warn: (...args: unknown[]) => console.warn('[MessageBizppurio:UploadImage]', ...args),\n error: (...args: unknown[]) => console.error('[MessageBizppurio:UploadImage]', ...args),\n};\n\nconst UPLOAD_URL = '/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image';\n\n/**\n * 선택한 이미지 파일을 업로드하고 결과 URL 을 상태에 반영합니다.\n *\n * @param action 액션 객체(미사용 params)\n * @param context 액션 컨텍스트($event 로 파일 접근)\n */\nexport async function uploadTemplateImageHandler(\n action: ActionWithParams,\n context: ActionContext,\n): Promise {\n const G7Core = (window as any).G7Core;\n if (!G7Core?.state?.setLocal) {\n logger.error('G7Core.state.setLocal 을 사용할 수 없습니다.');\n return;\n }\n\n const event = (context?.event ?? (action as any)?.event) as Event | undefined;\n const input = event?.target as HTMLInputElement | undefined;\n const file = input?.files?.[0];\n\n if (!file) {\n return;\n }\n\n G7Core.state.setLocal({ imageUploading: true, imageUploadError: null });\n\n try {\n const token = localStorage.getItem('auth_token') ?? '';\n const form = new FormData();\n form.append('image', file, file.name);\n\n const res = await fetch(UPLOAD_URL, {\n method: 'POST',\n headers: {\n Accept: 'application/json',\n ...(token ? { Authorization: `Bearer ${token}` } : {}),\n },\n body: form,\n });\n\n const json = await res.json().catch(() => ({}));\n\n if (!res.ok || json?.success === false) {\n const message = json?.errors?.kakao_message || json?.message || '이미지 업로드에 실패했습니다.';\n G7Core.state.setLocal({ imageUploading: false, imageUploadError: message });\n logger.warn('이미지 업로드 실패:', message);\n return;\n }\n\n const url = json?.data?.image_url ?? '';\n const name = json?.data?.image_name ?? file.name;\n\n G7Core.state.setLocal({\n imageUploading: false,\n imageUploadError: null,\n 'templateForm.templateImageUrl': url,\n 'templateForm.templateImageName': name,\n });\n\n logger.log('이미지 업로드 완료:', url);\n } catch (e) {\n G7Core.state.setLocal({ imageUploading: false, imageUploadError: String(e) });\n logger.error('이미지 업로드 예외:', e);\n } finally {\n // 같은 파일 재선택이 가능하도록 input 값 초기화\n if (input) {\n input.value = '';\n }\n }\n}\n","/**\n * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵.\n *\n * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인\n * 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable\n */\n\nimport { insertVariableHandler } from './insertVariable';\nimport { uploadTemplateImageHandler } from './uploadTemplateImage';\n\nexport const handlerMap = {\n insertVariable: insertVariableHandler,\n uploadTemplateImage: uploadTemplateImageHandler,\n} as const;\n","/**\n * sirsoft-message_bizppurio 플러그인 엔트리포인트.\n *\n * 플러그인 활성화 시 자동 로드되어 커스텀 핸들러(insertVariable)를 ActionDispatcher 에\n * 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다.\n */\n\nimport { handlerMap } from './handlers';\n\nconst PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';\n\nconst logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? {\n log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n};\n\n/**\n * handlerMap 의 모든 핸들러를 ActionDispatcher 에 등록합니다.\n *\n * @param dispatcher 코어 ActionDispatcher 인스턴스\n */\nfunction register(dispatcher: any): void {\n Object.entries(handlerMap).forEach(([name, handler]) => {\n dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.${name}`, handler, {\n category: 'plugin',\n source: PLUGIN_IDENTIFIER,\n });\n });\n\n logger.log(\n `${Object.keys(handlerMap).length} handler(s) registered:`,\n Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`),\n );\n}\n\n/**\n * ActionDispatcher 준비 후 핸들러를 등록합니다.\n *\n * 최초 로드 시 ActionDispatcher 가 아직 없을 수 있으므로 짧게 재시도한다.\n *\n * @param retry ActionDispatcher 부재 시 재시도 여부\n */\nfunction registerHandlers(retry: boolean): void {\n const dispatcher = (window as any).G7Core?.getActionDispatcher?.();\n\n if (dispatcher) {\n register(dispatcher);\n return;\n }\n\n if (!retry) {\n logger.warn('ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.');\n return;\n }\n\n let count = 0;\n const max = 50; // 최대 5초 (50 * 100ms)\n const tick = () => {\n const found = (window as any).G7Core?.getActionDispatcher?.();\n if (found) {\n register(found);\n return;\n }\n if (++count <= max) {\n setTimeout(tick, 100);\n } else {\n logger.error('ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.');\n }\n };\n tick();\n}\n\n/**\n * 플러그인 초기화 — DOM 준비 후 핸들러 등록.\n */\nexport function initPlugin(): void {\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', () => registerHandlers(true));\n } else {\n const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.();\n registerHandlers(!hasDispatcher);\n }\n}\n\ninitPlugin();\n\nif (typeof window !== 'undefined') {\n (window as any).__SirsoftMessageBizppurio = {\n identifier: PLUGIN_IDENTIFIER,\n handlers: Object.keys(handlerMap),\n initPlugin,\n };\n}"],"names":["logger","args","DEFAULT_TARGET","DEFAULT_STATE_PATH","insertVariableHandler","action","context","G7Core","params","variable","target","statePath","localState","current","readPath","token","field","inSync","start","end","next","caret","obj","path","acc","key","UPLOAD_URL","uploadTemplateImageHandler","input","file","form","res","json","message","url","name","e","handlerMap","PLUGIN_IDENTIFIER","register","dispatcher","handler","registerHandlers","retry","count","max","tick","found","initPlugin","hasDispatcher"],"mappings":"sDAmBA,MAAMA,EAAW,OAAe,QAAQ,eAAe,iCAAiC,GAAM,CAC1F,IAAK,IAAIC,IAAoB,QAAQ,IAAI,oCAAqC,GAAGA,CAAI,EACrF,KAAM,IAAIA,IAAoB,QAAQ,KAAK,oCAAqC,GAAGA,CAAI,EACvF,MAAO,IAAIA,IAAoB,QAAQ,MAAM,oCAAqC,GAAGA,CAAI,CAC7F,EAGMC,EAAiB,kBAGjBC,EAAqB,+BAQpB,SAASC,EACZC,EACAC,EACI,CACJ,MAAMC,EAAU,OAAe,OACzBC,EAASH,EAAO,QAAU,CAAA,EAC1BI,EAAW,OAAOD,EAAO,UAAY,EAAE,EAAE,KAAA,EACzCE,EAAS,OAAOF,EAAO,QAAUN,CAAc,EAC/CS,EAAY,OAAOH,EAAO,WAAaL,CAAkB,EAE/D,GAAIM,IAAa,GAAI,CACjBT,EAAO,KAAK,sBAAsB,EAClC,MACJ,CAEA,GAAI,CAACO,GAAQ,OAAO,UAAY,CAACA,GAAQ,OAAO,SAAU,CACtDP,EAAO,MAAM,gCAAgC,EAC7C,MACJ,CAGA,MAAMY,EAAcN,GAAiB,OAASC,EAAO,MAAM,SAAA,GAAc,CAAA,EACnEM,EAAU,OAAOC,EAASF,EAAYD,CAAS,GAAK,EAAE,EAEtDI,EAAQ,KAAKN,CAAQ,IAGrBO,EAAQ,SAAS,cACnB,kBAAkBN,CAAM,mBAAmBA,CAAM,IAAA,EAI/CO,GAFWD,GAAO,OAAS,MAELH,EACtBK,EAAQD,EAAUD,GAAO,gBAAkBH,EAAQ,OAAUA,EAAQ,OACrEM,EAAMF,EAAUD,GAAO,cAAgBH,EAAQ,OAAUA,EAAQ,OAEjEO,EAAOP,EAAQ,MAAM,EAAGK,CAAK,EAAIH,EAAQF,EAAQ,MAAMM,CAAG,EAKhE,GAHAZ,EAAO,MAAM,SAAS,CAAE,CAACI,CAAS,EAAGS,EAAM,EAGvCJ,EAAO,CACP,MAAMK,EAAQH,EAAQH,EAAM,OAC5B,sBAAsB,IAAM,CACxB,GAAI,CACAC,EAAM,MAAA,EACNA,EAAM,kBAAkBK,EAAOA,CAAK,CACxC,MAAQ,CAER,CACJ,CAAC,CACL,CAEArB,EAAO,IAAI,UAAUe,CAAK,MAAMJ,CAAS,EAAE,CAC/C,CASA,SAASG,EAASQ,EAA0BC,EAAuB,CAC/D,OAAOA,EAAK,MAAM,GAAG,EAAE,OAAY,CAACC,EAAKC,IAAmCD,IAAIC,CAAG,EAAIH,CAAG,CAC9F,CClFA,MAAMtB,EAAW,OAAe,QAAQ,eAAe,8BAA8B,GAAM,CACvF,IAAK,IAAIC,IAAoB,QAAQ,IAAI,iCAAkC,GAAGA,CAAI,EAClF,KAAM,IAAIA,IAAoB,QAAQ,KAAK,iCAAkC,GAAGA,CAAI,EACpF,MAAO,IAAIA,IAAoB,QAAQ,MAAM,iCAAkC,GAAGA,CAAI,CAC1F,EAEMyB,EAAa,wEAQnB,eAAsBC,EAClBtB,EACAC,EACa,CACb,MAAMC,EAAU,OAAe,OAC/B,GAAI,CAACA,GAAQ,OAAO,SAAU,CAC1BP,EAAO,MAAM,qCAAqC,EAClD,MACJ,CAGA,MAAM4B,GADStB,GAAS,OAAUD,GAAgB,QAC7B,OACfwB,EAAOD,GAAO,QAAQ,CAAC,EAE7B,GAAKC,EAIL,CAAAtB,EAAO,MAAM,SAAS,CAAE,eAAgB,GAAM,iBAAkB,KAAM,EAEtE,GAAI,CACA,MAAMQ,EAAQ,aAAa,QAAQ,YAAY,GAAK,GAC9Ce,EAAO,IAAI,SACjBA,EAAK,OAAO,QAASD,EAAMA,EAAK,IAAI,EAEpC,MAAME,EAAM,MAAM,MAAML,EAAY,CAChC,OAAQ,OACR,QAAS,CACL,OAAQ,mBACR,GAAIX,EAAQ,CAAE,cAAe,UAAUA,CAAK,IAAO,CAAA,CAAC,EAExD,KAAMe,CAAA,CACT,EAEKE,EAAO,MAAMD,EAAI,KAAA,EAAO,MAAM,KAAO,CAAA,EAAG,EAE9C,GAAI,CAACA,EAAI,IAAMC,GAAM,UAAY,GAAO,CACpC,MAAMC,EAAUD,GAAM,QAAQ,eAAiBA,GAAM,SAAW,mBAChEzB,EAAO,MAAM,SAAS,CAAE,eAAgB,GAAO,iBAAkB0B,EAAS,EAC1EjC,EAAO,KAAK,cAAeiC,CAAO,EAClC,MACJ,CAEA,MAAMC,EAAMF,GAAM,MAAM,WAAa,GAC/BG,EAAOH,GAAM,MAAM,YAAcH,EAAK,KAE5CtB,EAAO,MAAM,SAAS,CAClB,eAAgB,GAChB,iBAAkB,KAClB,gCAAiC2B,EACjC,iCAAkCC,CAAA,CACrC,EAEDnC,EAAO,IAAI,cAAekC,CAAG,CACjC,OAASE,EAAG,CACR7B,EAAO,MAAM,SAAS,CAAE,eAAgB,GAAO,iBAAkB,OAAO6B,CAAC,EAAG,EAC5EpC,EAAO,MAAM,cAAeoC,CAAC,CACjC,QAAA,CAEQR,IACAA,EAAM,MAAQ,GAEtB,EACJ,CCvFO,MAAMS,EAAa,CACtB,eAAgBjC,EAChB,oBAAqBuB,CACzB,ECJMW,EAAoB,4BAEpBtC,EAAW,OAAe,QAAQ,eAAe,UAAUsC,CAAiB,EAAE,GAAM,CACtF,IAAK,IAAIrC,IAAoB,QAAQ,IAAI,WAAWqC,CAAiB,IAAK,GAAGrC,CAAI,EACjF,KAAM,IAAIA,IAAoB,QAAQ,KAAK,WAAWqC,CAAiB,IAAK,GAAGrC,CAAI,EACnF,MAAO,IAAIA,IAAoB,QAAQ,MAAM,WAAWqC,CAAiB,IAAK,GAAGrC,CAAI,CACzF,EAOA,SAASsC,EAASC,EAAuB,CACrC,OAAO,QAAQH,CAAU,EAAE,QAAQ,CAAC,CAACF,EAAMM,CAAO,IAAM,CACpDD,EAAW,gBAAgB,GAAGF,CAAiB,IAAIH,CAAI,GAAIM,EAAS,CAChE,SAAU,SACV,OAAQH,CAAA,CACX,CACL,CAAC,EAEDtC,EAAO,IACH,GAAG,OAAO,KAAKqC,CAAU,EAAE,MAAM,0BACjC,OAAO,KAAKA,CAAU,EAAE,OAAY,GAAGC,CAAiB,IAAIH,CAAI,EAAE,CAAA,CAE1E,CASA,SAASO,EAAiBC,EAAsB,CAC5C,MAAMH,EAAc,OAAe,QAAQ,sBAAA,EAE3C,GAAIA,EAAY,CACZD,EAASC,CAAU,EACnB,MACJ,CAEA,GAAI,CAACG,EAAO,CACR3C,EAAO,KAAK,2CAA2C,EACvD,MACJ,CAEA,IAAI4C,EAAQ,EACZ,MAAMC,EAAM,GACNC,EAAO,IAAM,CACf,MAAMC,EAAS,OAAe,QAAQ,sBAAA,EACtC,GAAIA,EAAO,CACPR,EAASQ,CAAK,EACd,MACJ,CACI,EAAEH,GAASC,EACX,WAAWC,EAAM,GAAG,EAEpB9C,EAAO,MAAM,0CAA0C,CAE/D,EACA8C,EAAA,CACJ,CAKO,SAASE,GAAmB,CAC/B,GAAI,SAAS,aAAe,UACxB,SAAS,iBAAiB,mBAAoB,IAAMN,EAAiB,EAAI,CAAC,MACvE,CACH,MAAMO,EAAgB,CAAC,CAAE,OAAe,QAAQ,sBAAA,EAChDP,EAAiB,CAACO,CAAa,CACnC,CACJ,CAEA,OAAAD,EAAA,EAEI,OAAO,OAAW,MACjB,OAAe,0BAA4B,CACxC,WAAYV,EACZ,SAAU,OAAO,KAAKD,CAAU,EAChC,WAAAW,CAAA"} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index d223bcc9..3b65cd51 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -4,10 +4,11 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 2 · **엔드포인트 수**: 2 +- **문서 수**: 3 · **엔드포인트 수**: 16 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | +| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 14 | | [report.md](report.md) | `report` | 1 | | [webhook.md](webhook.md) | `webhook` | 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md new file mode 100644 index 00000000..d79b3de5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md @@ -0,0 +1,730 @@ +# Alimtalk Templates API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Alimtalk Templates 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + +## 개요 + +설정 페이지 알림톡 템플릿 탭에서 사용하는 관리 API 입니다. 카카오 관리 API(`kapi.ppurio.com`)에 +실시간 위임하며 템플릿을 DB 에 저장하지 않습니다. 조회 계열은 `messaging.view`, 변경 계열(등록· +수정·삭제·검수·상태변경·이미지 업로드)은 `messaging.manage` 권한을 요구합니다. kapi 실패(반려·차단· +상태오류)는 `422` 로 전파되며 `errors.result_code`(kapi 결과코드)·`errors.kakao_message`(카카오 사유)를 +함께 내려줍니다. + +**주요 요청 파라미터(등록·수정 `POST /` · `PUT /{code}`)** + +| 파라미터 | 타입 | 필수 | 설명 | +| --- | --- | :---: | --- | +| `templateName` | string | ✓ | 템플릿명(관리용, 최대 200자) | +| `templateContent` | string | ✓ | 본문(최대 1300자, `#{변수}` 사용) | +| `categoryCode` | string | ✓ | 카테고리 코드(카테고리 조회에서 획득) | +| `templateEmphasizeType` | string | ✓ | 유형: `NONE`/`IMAGE`/`TEXT`(1차 지원, ITEM_LIST 제외) | +| `templateCode` | string | | 템플릿 코드. 비우면 카카오가 **자동 생성** | +| `templateTitle`/`templateSubtitle` | string | △ | 강조표기형(TEXT) 필수 | +| `templateImageUrl`/`templateImageName` | string | △ | 이미지형(IMAGE) 필수 — 이미지 업로드 API 로 획득한 URL | +| `templateExtra`·`templatePreviewMessage`·`securityFlag` | mixed | | 선택: 부가정보·미리보기 메시지·보안 | +| `buttons[]`(최대5)·`quickReplies[]`(최대10)·`templateRepresentLink` | array | | 선택: 버튼·바로연결·대표 링크 | +| `requestInspection` | boolean | | true 면 등록 직후 검수요청까지 수행([등록 후 검수요청]) | + +> `senderKey`·`templateMessageType`(BA/EX/AD/MI)은 클라이언트가 보내지 않습니다. `senderKey` 는 +> 환경설정(발신프로필 키), `templateMessageType` 은 부가정보·채널추가(AC) 버튼 유무로 서버가 자동 계산합니다. + +**이미지 업로드(`POST /image`, multipart)**: 이미지형 템플릿용 이미지를 카카오 서버에 올려 URL 을 +반환합니다(`data.image_url`). 제한: jpg/png, 최대 500KB, 가로 500px 이상. + +상태별 가능 액션(정본): REG→수정/삭제/검수요청 · REQ→검수취소 · REJ→수정/삭제 · +RDY·ACT→중지/승인취소 · STP→중지해제 · DMT→휴면해제 · BLK→(없음). + +아래 엔드포인트별 상세는 `php artisan api:docgen` 이 생성한 스캐폴딩입니다. 실측이 인증을 요구해 +제외된 응답 필드/예시는 실연동 검증 단계에서 사람이 보강합니다. + +--- + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.store` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | body | string | 예 | max 200 | | +| templateContent | body | string | 예 | max 1300 | | +| categoryCode | body | string | 예 | max 20 | | +| templateEmphasizeType | body | string | 예 | — | | +| templateCode | body | string | 아니오 | max 30 | | +| templateTitle | body | string | 아니오 | max 23 | | +| templateSubtitle | body | string | 아니오 | max 18 | | +| templateImageName | body | string | 아니오 | max 255 | | +| templateImageUrl | body | string | 아니오 | max 500 | | +| templatePreviewMessage | body | string | 아니오 | max 40 | | +| templateExtra | body | string | 아니오 | max 500 | | +| templateHeader | body | string | 아니오 | max 16 | | +| securityFlag | body | boolean | 아니오 | — | | +| requestInspection | body | boolean | 아니오 | — | | +| buttons | body | array | 아니오 | max 5 | | +| quickReplies | body | array | 아니오 | max 10 | | +| templateRepresentLink | body | array | 아니오 | — | | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "templateName": "예시 이름", + "templateContent": "예시 내용입니다.", + "categoryCode": "예시값", + "templateEmphasizeType": "예시값", + "templateCode": "예시값", + "templateTitle": "예시 제목", + "templateSubtitle": "예시 제목", + "templateImageName": "예시 이름", + "templateImageUrl": "https://example.com", + "templatePreviewMessage": "예시값", + "templateExtra": "예시값", + "templateHeader": "예시값", + "securityFlag": true, + "requestInspection": true, + "buttons": [ + "예시값" + ], + "quickReplies": [ + "예시값" + ], + "templateRepresentLink": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@categories` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.image` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@uploadImage` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| image | body | file | 예 | max 500 | | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="image"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@profiles` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + + +### DELETE /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.destroy` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +DELETE /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### PUT /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.update` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | +| templateName | body | string | 예 | max 200 | | +| templateContent | body | string | 예 | max 1300 | | +| categoryCode | body | string | 예 | max 20 | | +| templateEmphasizeType | body | string | 예 | — | | +| templateTitle | body | string | 아니오 | max 23 | | +| templateSubtitle | body | string | 아니오 | max 18 | | +| templateImageName | body | string | 아니오 | max 255 | | +| templateImageUrl | body | string | 아니오 | max 500 | | +| templatePreviewMessage | body | string | 아니오 | max 40 | | +| templateExtra | body | string | 아니오 | max 500 | | +| templateHeader | body | string | 아니오 | max 16 | | +| securityFlag | body | boolean | 아니오 | — | | +| requestInspection | body | boolean | 아니오 | — | | +| buttons | body | array | 아니오 | max 5 | | +| quickReplies | body | array | 아니오 | max 10 | | +| templateRepresentLink | body | array | 아니오 | — | | + +**요청 예시** + +```http +PUT /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "templateName": "예시 이름", + "templateContent": "예시 내용입니다.", + "categoryCode": "예시값", + "templateEmphasizeType": "예시값", + "templateCode": "예시값", + "templateTitle": "예시 제목", + "templateSubtitle": "예시 제목", + "templateImageName": "예시 이름", + "templateImageUrl": "https://example.com", + "templatePreviewMessage": "예시값", + "templateExtra": "예시값", + "templateHeader": "예시값", + "securityFlag": true, + "requestInspection": true, + "buttons": [ + "예시값" + ], + "quickReplies": [ + "예시값" + ], + "templateRepresentLink": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-approval + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cancel-approval` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@cancelApproval` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-approval HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-request + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cancel-request` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@cancelRequest` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-request HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/release + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.release` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@release` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/release HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/request + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.request` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@requestInspection` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/request HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/reuse + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.reuse` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@reuse` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/reuse HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/stop + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.stop` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@stop` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/stop HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php new file mode 100644 index 00000000..606ec5c4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php @@ -0,0 +1,34 @@ + [ + 'sirsoft-message_bizppurio' => [ + 'alimtalk_template' => [ + 'create' => 'Register alimtalk template', + 'update' => 'Update alimtalk template', + 'delete' => 'Delete alimtalk template', + 'request' => 'Request alimtalk template inspection', + 'cancel_request' => 'Cancel alimtalk template inspection request', + 'stop' => 'Stop alimtalk template', + 'reuse' => 'Resume alimtalk template', + 'cancel_approval' => 'Cancel alimtalk template approval', + 'release' => 'Release alimtalk template from dormancy', + ], + ], + ], + + // Description bodies — standard pattern activity_log.description.X (resolved by DescriptionResolver). + 'description' => [ + 'alimtalk_template_create' => 'Registered alimtalk template (:template_name)', + 'alimtalk_template_update' => 'Updated alimtalk template (:template_code)', + 'alimtalk_template_delete' => 'Deleted alimtalk template (:template_code)', + 'alimtalk_template_request' => 'Requested alimtalk template inspection (:template_code)', + 'alimtalk_template_cancel_request' => 'Canceled alimtalk template inspection request (:template_code)', + 'alimtalk_template_stop' => 'Stopped alimtalk template (:template_code)', + 'alimtalk_template_reuse' => 'Resumed alimtalk template (:template_code)', + 'alimtalk_template_cancel_approval' => 'Canceled alimtalk template approval (:template_code)', + 'alimtalk_template_release' => 'Released alimtalk template from dormancy (:template_code)', + ], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index b59faed7..ee4a74aa 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -73,5 +73,55 @@ return [ 'invalid_response' => 'Unable to parse the Bizppurio response.', 'kakao_credentials_missing' => 'Please set the ID and API key to use the Kakao management API.', 'kakao_request_failed' => 'The Kakao management API request failed.', + 'sender_key_missing' => 'Please set the alimtalk sender profile key first.', + ], + + // Alimtalk template management (Phase 5) + 'template' => [ + // Action result messages + 'created' => 'The alimtalk template has been registered.', + 'created_requested' => 'The alimtalk template has been registered and inspection requested.', + 'updated' => 'The alimtalk template has been updated.', + 'deleted' => 'The alimtalk template has been deleted.', + 'requested' => 'Inspection has been requested.', + 'request_canceled' => 'The inspection request has been canceled.', + 'stopped' => 'The template has been stopped.', + 'resumed' => 'The template has been resumed.', + 'approval_canceled' => 'The approval has been canceled.', + 'released' => 'The template has been released from dormancy.', + + // Status badges (serviceStatus) + 'status' => [ + 'sendable' => 'Sendable', + 'inspecting' => 'Inspecting', + 'rejected' => 'Rejected', + 'uninspected' => 'Not inspected', + 'stopped' => 'Stopped', + 'blocked' => 'Blocked', + 'dormant' => 'Dormant', + 'unknown' => 'Unknown', + ], + + // Template types (templateEmphasizeType) + 'emphasize_type' => [ + 'none' => 'Basic', + 'text' => 'Highlighted', + 'image' => 'Image', + 'item_list' => 'Item list', + ], + + // Validation attribute labels (FormRequest attributes) + 'fields' => [ + 'name' => 'Template name', + 'content' => 'Template content', + 'category' => 'Category', + 'emphasize_type' => 'Template type', + 'code' => 'Template code', + 'title' => 'Highlight title', + 'subtitle' => 'Highlight subtitle', + 'image' => 'Image', + 'image_name' => 'Image file name', + 'image_url' => 'Image URL', + ], ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php new file mode 100644 index 00000000..cf10f0ae --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php @@ -0,0 +1,34 @@ + [ + 'sirsoft-message_bizppurio' => [ + 'alimtalk_template' => [ + 'create' => '알림톡 템플릿 등록', + 'update' => '알림톡 템플릿 수정', + 'delete' => '알림톡 템플릿 삭제', + 'request' => '알림톡 템플릿 검수 요청', + 'cancel_request' => '알림톡 템플릿 검수 요청 취소', + 'stop' => '알림톡 템플릿 중지', + 'reuse' => '알림톡 템플릿 중지 해제', + 'cancel_approval' => '알림톡 템플릿 승인 취소', + 'release' => '알림톡 템플릿 휴면 해제', + ], + ], + ], + + // description 본문 — 표준 패턴 activity_log.description.X (DescriptionResolver 해석 대상). + 'description' => [ + 'alimtalk_template_create' => '알림톡 템플릿 등록 (:template_name)', + 'alimtalk_template_update' => '알림톡 템플릿 수정 (:template_code)', + 'alimtalk_template_delete' => '알림톡 템플릿 삭제 (:template_code)', + 'alimtalk_template_request' => '알림톡 템플릿 검수 요청 (:template_code)', + 'alimtalk_template_cancel_request' => '알림톡 템플릿 검수 요청 취소 (:template_code)', + 'alimtalk_template_stop' => '알림톡 템플릿 중지 (:template_code)', + 'alimtalk_template_reuse' => '알림톡 템플릿 중지 해제 (:template_code)', + 'alimtalk_template_cancel_approval' => '알림톡 템플릿 승인 취소 (:template_code)', + 'alimtalk_template_release' => '알림톡 템플릿 휴면 해제 (:template_code)', + ], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index dfe38c01..aa2079a6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -73,5 +73,55 @@ return [ 'invalid_response' => '비즈뿌리오 응답을 해석할 수 없습니다.', 'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.', 'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.', + 'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.', + ], + + // 알림톡 템플릿 관리 (Phase 5) + 'template' => [ + // 액션 결과 메시지 + 'created' => '알림톡 템플릿을 등록했습니다.', + 'created_requested' => '알림톡 템플릿을 등록하고 검수를 요청했습니다.', + 'updated' => '알림톡 템플릿을 수정했습니다.', + 'deleted' => '알림톡 템플릿을 삭제했습니다.', + 'requested' => '검수를 요청했습니다.', + 'request_canceled' => '검수 요청을 취소했습니다.', + 'stopped' => '템플릿을 중지했습니다.', + 'resumed' => '템플릿 중지를 해제했습니다.', + 'approval_canceled' => '승인을 취소했습니다.', + 'released' => '휴면을 해제했습니다.', + + // 상태 배지 (serviceStatus) + 'status' => [ + 'sendable' => '발송가능', + 'inspecting' => '검수중', + 'rejected' => '반려', + 'uninspected' => '미검수', + 'stopped' => '중지', + 'blocked' => '차단', + 'dormant' => '휴면', + 'unknown' => '알 수 없음', + ], + + // 템플릿 유형 (templateEmphasizeType) + 'emphasize_type' => [ + 'none' => '기본형', + 'text' => '강조표기형', + 'image' => '이미지형', + 'item_list' => '아이템리스트형', + ], + + // 검증 속성 라벨 (FormRequest attributes) + 'fields' => [ + 'name' => '템플릿명', + 'content' => '템플릿 내용', + 'category' => '카테고리', + 'emphasize_type' => '템플릿 유형', + 'code' => '템플릿 코드', + 'title' => '강조표기 문구', + 'subtitle' => '보조 문구', + 'image' => '이미지', + 'image_name' => '이미지 파일명', + 'image_url' => '이미지 링크', + ], ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/package.json b/plugins/_bundled/sirsoft-message_bizppurio/package.json index cd1c67c9..25c55585 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/package.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", "test": "vitest", "test:run": "vitest run" }, @@ -11,6 +14,7 @@ "@types/node": "^22.0.0", "jsdom": "^25.0.0", "typescript": "^5.6.0", + "vite": "^5.4.14", "vitest": "^2.1.0" } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json index e9f35053..b4513cb6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json @@ -18,6 +18,13 @@ }, "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", + "assets": { + "js": { + "entry": "resources/js/index.ts", + "output": "dist/js/plugin.iife.js" + }, + "handlers": true + }, "loading": { "strategy": "global", "priority": 100 diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 3979d8a1..762108a1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -34,77 +34,13 @@ class Plugin extends AbstractPlugin } /** - * 관리자 메뉴 정의 + * 플러그인 비활성화 — 이 플러그인 소속 관리자 메뉴 잔재 정리. * - * 코어 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 처리. + * 이 플러그인은 관리자 메뉴를 만들지 않는다(화면 배치 결정 2026-07-14 — 진입은 코어 + * 소유 설정 페이지 하나로 통일). 다만 이전 버전(getAdminMenus 사용 시기)에 생성된 + * 메뉴 row 가 DB 에 남아 있을 수 있어, 비활성화 시 currentSlugs=[] 로 cleanupStaleMenus + * 를 호출해 잔재를 청소한다. 재활성화 시 아무 메뉴도 만들지 않으므로 정상 무메뉴 상태로 + * 수렴한다(멱등). 자식 메뉴 + role_menus 피벗은 helper 가 cascade 처리. * * @return bool 비활성화 성공 여부 */ @@ -120,7 +56,7 @@ class Plugin extends AbstractPlugin } /** - * 플러그인 제거 — 메뉴 잔존 안전망 (정상 흐름은 deactivate 가 먼저 처리). + * 플러그인 제거 — 메뉴 잔재 안전망 (정상 흐름은 deactivate 가 먼저 처리). * * @return bool 제거 성공 여부 */ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx new file mode 100644 index 00000000..143fa0b8 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx @@ -0,0 +1,236 @@ +/** + * 비즈뿌리오 메시징 플러그인 알림톡 템플릿 관리 탭 구조 검증 (Phase 5, §6-3·6-4) + * + * plugin_settings.json 안에 탭으로 배치된 알림톡 템플릿 관리 화면을 검증한다. + * - 탭 네비게이션(환경설정 ↔ 알림톡 템플릿) + 배타 전환(activeSettingsTab) + * - 목록 서브뷰(상태필터·검색·상태배지·상세) / 폼 서브뷰(유형별 필드·변수삽입) + * - 상세 모달 / readiness 안내 / 데이터소스 / i18n 정합 + * + * 독립 페이지·메뉴 없음(⚑ 결정 2·3): 설정 페이지 탭으로만 진입. + */ + +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, collectI18nKeys, type AnyNode } from './helpers'; + +const root = layout as unknown as AnyNode; + +describe('alimtalk templates — 탭 네비게이션', () => { + it('탭 네비게이션에 환경설정·알림톡 템플릿 탭 버튼이 있다', () => { + const nav = findById(root, 'settings_tabs'); + expect(nav).toBeTruthy(); + expect(findById(nav, 'tab_connection')).toBeTruthy(); + expect(findById(nav, 'tab_templates')).toBeTruthy(); + }); + + it('알림톡 탭 버튼이 query.tab=templates 로 URL 을 갱신하고 목록을 refetch 한다', () => { + const raw = JSON.stringify(findById(root, 'tab_templates')); + expect(raw).toContain('replaceUrl'); + expect(raw).toContain('"tab":"templates"'); + expect(raw).toContain('"templateView":"list"'); + expect(raw).toContain('alimtalk_templates'); + }); + + it('탭 패널이 query.tab 으로 배타 전환된다 (새로고침에도 유지)', () => { + const connection = findById(root, 'connection_tab_panel'); + const templates = findById(root, 'templates_tab_panel'); + expect((connection as { if?: string }).if).toContain("query.tab ?? 'connection') === 'connection'"); + expect((templates as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'"); + }); + + it('init_actions 가 templates 탭 진입 시 목록을 자동 로드한다 (새로고침 복원)', () => { + const inits = (root as { init_actions?: AnyNode[] }).init_actions ?? []; + const refetch = inits.find((a) => a.handler === 'refetchDataSource'); + expect(refetch).toBeTruthy(); + expect((refetch as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'"); + }); +}); + +describe('alimtalk templates — 데이터소스', () => { + it('템플릿 목록·카테고리 데이터소스가 admin API 를 조회한다', () => { + const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; + const list = sources.find((s) => s.id === 'alimtalk_templates'); + const cats = sources.find((s) => s.id === 'alimtalk_categories'); + expect(list?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates'); + expect(list?.auto_fetch).toBe(false); + expect(cats?.endpoint).toContain('/alimtalk-templates/categories'); + }); +}); + +describe('alimtalk templates — 목록 서브뷰', () => { + it('목록 서브뷰가 templateView=list 조건으로 표시된다', () => { + const view = findById(root, 'templates_list_view'); + expect((view as { if?: string }).if).toContain("templateView ?? 'list') === 'list'"); + }); + + it('상태 필터 Select 와 검색 입력이 있다', () => { + const toolbar = findById(root, 'templates_toolbar'); + const raw = JSON.stringify(toolbar); + expect(raw).toContain('templateStatus'); + expect(raw).toContain('templateKeyword'); + }); + + it('빈 목록에서도 헤더 있는 표(카드)를 항상 표시하고 데이터 유무로 Tbody 를 분기한다', () => { + const card = findById(root, 'templates_table_card'); + expect(card).toBeTruthy(); + const raw = JSON.stringify(card); + // 표 헤더는 항상 존재 + expect(raw).toContain('columns.name'); + expect(raw).toContain('columns.status'); + // 데이터 유무 분기 (빈 상태 안내 + 목록 iteration) + expect(raw).toContain('.length > 0'); + expect(raw).toContain('.length === 0'); + expect(raw).toContain('templates.list.empty'); + }); + + it('표가 templates 목록을 iteration 으로 렌더한다', () => { + const raw = JSON.stringify(findById(root, 'templates_table_card')); + expect(raw).toContain('alimtalk_templates?.data?.templates'); + expect(raw).toContain('"item_var":"tpl"'); + expect(raw).toContain('tpl.templateName'); + expect(raw).toContain('status_badge'); + }); + + it('[새 템플릿] 버튼이 폼 서브뷰로 전환하고 폼을 초기화한다', () => { + const raw = JSON.stringify(findById(root, 'templates_toolbar')); + expect(raw).toContain('"templateView":"form"'); + expect(raw).toContain('templateEmphasizeType'); + }); +}); + +describe('alimtalk templates — 폼 서브뷰', () => { + it('폼 서브뷰가 templateView=form 조건으로 표시된다', () => { + const view = findById(root, 'templates_form_view'); + expect((view as { if?: string }).if).toContain("templateView === 'form'"); + }); + + it('공통 필드(이름·본문)가 수동바인딩(value+onChange)으로 존재한다', () => { + const raw = JSON.stringify(findById(root, 'templates_form_view')); + // 이름: value 바인딩 + templateForm.templateName setState (자동바인딩 name 대신) + expect(raw).toContain('templateForm.templateName'); + expect(raw).toContain('_local.templateForm?.templateName'); + // 본문: templateForm.templateContent 수동바인딩 + expect(raw).toContain('templateForm.templateContent'); + }); + + it('강조표기형(TEXT) 필드가 유형 조건으로 표시된다', () => { + const text = findById(root, 'template_text_fields'); + expect((text as { if?: string }).if).toContain("templateEmphasizeType === 'TEXT'"); + const raw = JSON.stringify(text); + expect(raw).toContain('templateTitle'); + expect(raw).toContain('templateSubtitle'); + }); + + it('이미지형(IMAGE) 필드가 유형 조건으로 표시된다', () => { + const image = findById(root, 'template_image_fields'); + expect((image as { if?: string }).if).toContain("templateEmphasizeType === 'IMAGE'"); + const raw = JSON.stringify(image); + expect(raw).toContain('templateImageName'); + expect(raw).toContain('templateImageUrl'); + }); + + it('유형 선택 버튼이 1차 지원 3종(NONE/IMAGE/TEXT)만 노출하고 ITEM_LIST 는 없다', () => { + const raw = JSON.stringify(findById(root, 'templates_form_view')); + // 버튼 방식: setState 로 templateEmphasizeType 을 직접 설정 + expect(raw).toContain('"templateForm.templateEmphasizeType":"NONE"'); + expect(raw).toContain('"templateForm.templateEmphasizeType":"IMAGE"'); + expect(raw).toContain('"templateForm.templateEmphasizeType":"TEXT"'); + expect(raw).not.toContain('ITEM_LIST'); + }); + + it('변수 버튼이 insertVariable 핸들러로 본문 상태(statePath)에 삽입한다', () => { + const raw = JSON.stringify(findById(root, 'template_variables')); + expect(raw).toContain('sirsoft-message_bizppurio.insertVariable'); + expect(raw).toContain('"target":"templateContent"'); + expect(raw).toContain('"statePath":"templateForm.templateContent"'); + }); + + it('이벤트별 변수 참고표가 토글 없이 항상 표시된다(인라인 배열 iteration)', () => { + const raw = JSON.stringify(findById(root, 'template_variables')); + // 참고표 iteration 이 인라인 배열(varref lang 라벨 + 변수 예시)을 렌더 + expect(raw).toContain('templates.varref.welcome'); + expect(raw).toContain('"item_var":"vref"'); + // showVarRef 토글 조건이 없어야 함(항상 표시) + expect(raw).not.toContain('showVarRef'); + }); + + it('선택 항목이 계획서 §6-4 6개(부가정보·미리보기·버튼·바로연결·대표링크·보안) 체크펼침으로 존재하고 헤더는 없다', () => { + const raw = JSON.stringify(findById(root, 'template_optional')); + expect(raw).toContain('templateForm.templateExtra'); + expect(raw).toContain('templateForm.templatePreviewMessage'); + expect(raw).toContain('templateForm.buttons'); + expect(raw).toContain('templateForm.quickReplies'); + expect(raw).toContain('templateForm.templateRepresentLink'); + expect(raw).toContain('templateForm.securityFlag'); + // 각 항목 체크박스 토글(_local.opt*) + expect(raw).toContain('optExtra'); + expect(raw).toContain('optButton'); + expect(raw).toContain('optLink'); + // 헤더(ITEM_LIST 전용, §6-4 선택항목 아님)는 제거됨 + expect(raw).not.toContain('templateForm.templateHeader'); + }); + + it('템플릿 코드(자동/직접)·카테고리 2단 드롭다운·이미지 파일첨부가 존재한다', () => { + const codeField = JSON.stringify(findById(root, 'template_code_field')); + expect(codeField).toContain('templateCodeMode'); + expect(codeField).toContain('"auto"'); + expect(codeField).toContain('"manual"'); + + const catField = JSON.stringify(findById(root, 'template_category_field')); + expect(catField).toContain('categoryGroup'); + expect(catField).toContain('templateForm.categoryCode'); + expect(catField).toContain('alimtalk_categories'); + + const imgField = JSON.stringify(findById(root, 'template_image_fields')); + expect(imgField).toContain('"type":"file"'); + expect(imgField).toContain('sirsoft-message_bizppurio.uploadTemplateImage'); + expect(imgField).toContain('templateImageUrl'); + }); + + it('저장 버튼이 [등록]과 [등록 후 검수요청] 2개로 분리된다', () => { + const raw = JSON.stringify(findById(root, 'templates_form_view')); + expect(raw).toContain('"method":"POST"'); + expect(raw).toContain('_local.templateForm'); + expect(raw).toContain('"templateView":"list"'); + // 등록 후 검수요청: requestInspection 플래그를 body 에 실음 + expect(raw).toContain('requestInspection'); + expect(raw).toContain('save_register'); + expect(raw).toContain('save_and_request'); + }); +}); + +describe('alimtalk templates — 상세 모달 & readiness', () => { + it('상세 모달이 정의되어 있다', () => { + const modals = (root as { modals?: AnyNode[] }).modals ?? []; + const detail = modals.find((m) => m.id === 'alimtalk_template_detail_modal'); + expect(detail).toBeTruthy(); + }); + + it('readiness 안내가 api_key/sender_key 미설정 시 조건부로 표시된다', () => { + const readiness = findById(root, 'templates_readiness'); + const cond = (readiness as { if?: string }).if ?? ''; + expect(cond).toContain('api_key'); + expect(cond).toContain('sender_key'); + }); +}); + +describe('alimtalk templates — i18n 정합', () => { + it('레이아웃 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => { + const keys = collectI18nKeys(layout); + 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) { + 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/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx index f38145cc..7f63768d 100644 --- 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 @@ -47,8 +47,8 @@ describe('plugin_settings.json — 레이아웃 메타/권한', () => { }); describe('plugin_settings.json — 자동바인딩', () => { - it('컨테이너가 dataKey=form + trackChanges 로 자동바인딩한다', () => { - const container = findById(root, 'plugin_settings_content'); + it('환경설정 탭 패널이 dataKey=form + trackChanges 로 자동바인딩한다', () => { + const container = findById(root, 'connection_tab_panel'); expect(container).toBeTruthy(); expect((container as { dataKey?: string }).dataKey).toBe('form'); expect((container as { trackChanges?: boolean }).trackChanges).toBe(true); @@ -154,7 +154,13 @@ describe('plugin_settings.json — 저장 버튼', () => { it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => { const handlers = collectHandlers(layout); - const allowed = ['apiCall', 'setState', 'toast', 'navigate', 'sequence', 'refetchDataSource', 'scrollIntoView', 'copyToClipboard']; + const allowed = [ + 'apiCall', 'setState', 'toast', 'navigate', 'sequence', + 'refetchDataSource', 'scrollIntoView', 'copyToClipboard', + 'openModal', 'closeModal', 'replaceUrl', + 'sirsoft-message_bizppurio.insertVariable', + 'sirsoft-message_bizppurio.uploadTemplateImage', + ]; for (const h of handlers) { expect(allowed).toContain(h); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts new file mode 100644 index 00000000..5858cc2b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts @@ -0,0 +1,14 @@ +/** + * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵. + * + * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인 + * 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable + */ + +import { insertVariableHandler } from './insertVariable'; +import { uploadTemplateImageHandler } from './uploadTemplateImage'; + +export const handlerMap = { + insertVariable: insertVariableHandler, + uploadTemplateImage: uploadTemplateImageHandler, +} as const; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts new file mode 100644 index 00000000..bd661ddf --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts @@ -0,0 +1,103 @@ +/** + * insertVariable 핸들러 + * + * 알림톡 템플릿 등록/수정 화면에서 "사용 가능 변수" 버튼을 클릭하면, 대상 본문의 현재 + * 커서 위치에 `#{변수}` 를 삽입한다(D8). 값 자체는 G7 상태(`_local.`)를 SSoT + * 로 삼아 setLocal 로 갱신한다. DOM 값을 직접 쓰지 않으므로 자동/수동 바인딩 충돌 + * (삽입 후 다음 렌더에서 상태값으로 덮여 사라지는 문제)이 발생하지 않는다. + * + * 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다. + * + * 레이아웃 JSON 사용 예: + * { + * "handler": "sirsoft-message_bizppurio.insertVariable", + * "params": { "variable": "name", "target": "templateContent", "statePath": "templateForm.templateContent" } + * } + */ + +import type { ActionContext, ActionWithParams } from '../types'; + +const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? { + log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args), + warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args), + error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args), +}; + +/** 삽입 대상 본문 필드(name) 기본값 */ +const DEFAULT_TARGET = 'templateContent'; + +/** 상태 경로 기본값 (_local 하위) */ +const DEFAULT_STATE_PATH = 'templateForm.templateContent'; + +/** + * 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다. + * + * @param action 액션 객체(params.variable, params.target, params.statePath) + * @param context 액션 컨텍스트(state 스냅샷) + */ +export function insertVariableHandler( + action: ActionWithParams, + context: ActionContext, +): void { + const G7Core = (window as any).G7Core; + const params = action.params ?? {}; + const variable = String(params.variable ?? '').trim(); + const target = String(params.target ?? DEFAULT_TARGET); + const statePath = String(params.statePath ?? DEFAULT_STATE_PATH); + + if (variable === '') { + logger.warn('삽입할 변수명이 지정되지 않았습니다.'); + return; + } + + if (!G7Core?.state?.getLocal || !G7Core?.state?.setLocal) { + logger.error('G7Core.state API 를 사용할 수 없습니다.'); + return; + } + + // 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getLocal. + const localState = (context as any)?.state ?? G7Core.state.getLocal() ?? {}; + const current = String(readPath(localState, statePath) ?? ''); + + const token = `#{${variable}}`; + + // 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립. + const field = document.querySelector( + `textarea[name="${target}"], input[name="${target}"]`, + ); + const domValue = field?.value ?? ''; + // DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다. + const inSync = domValue === current; + const start = inSync ? (field?.selectionStart ?? current.length) : current.length; + const end = inSync ? (field?.selectionEnd ?? current.length) : current.length; + + const next = current.slice(0, start) + token + current.slice(end); + + G7Core.state.setLocal({ [statePath]: next }); + + // 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서). + if (field) { + const caret = start + token.length; + requestAnimationFrame(() => { + try { + field.focus(); + field.setSelectionRange(caret, caret); + } catch { + // setSelectionRange 미지원 input — 무시 + } + }); + } + + logger.log(`변수 삽입: ${token} → ${statePath}`); +} + +/** + * dot notation 경로로 객체에서 값을 읽습니다. (예: "templateForm.templateContent") + * + * @param obj 대상 객체 + * @param path dot 경로 + * @return 경로 값(없으면 undefined) + */ +function readPath(obj: Record, path: string): unknown { + return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts new file mode 100644 index 00000000..e7e626ae --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts @@ -0,0 +1,98 @@ +/** + * uploadTemplateImage 핸들러 + * + * 이미지형 알림톡 템플릿 등록 화면에서 파일 선택(change) 시, 선택한 이미지를 플러그인의 + * 이미지 업로드 API(카카오 서버 업로드 위임)로 전송하고, 반환된 카카오 이미지 URL 을 + * `_local.templateForm.templateImageUrl` 에, 파일명을 `templateImageName` 에 설정한다. + * + * 업로드는 multipart/form-data 이므로 apiCall 핸들러로는 처리하기 번거로워 전용 핸들러로 둔다. + * 진행 상태는 `_local.imageUploading`, 오류는 `_local.imageUploadError` 로 노출한다. + * + * 레이아웃 JSON 사용 예: + * { + * "type": "change", + * "handler": "sirsoft-message_bizppurio.uploadTemplateImage" + * } + * (대상 file input 의 change 이벤트에 바인딩; $event.target.files[0] 를 사용) + */ + +import type { ActionContext, ActionWithParams } from '../types'; + +const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:UploadImage')) ?? { + log: (...args: unknown[]) => console.log('[MessageBizppurio:UploadImage]', ...args), + warn: (...args: unknown[]) => console.warn('[MessageBizppurio:UploadImage]', ...args), + error: (...args: unknown[]) => console.error('[MessageBizppurio:UploadImage]', ...args), +}; + +const UPLOAD_URL = '/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image'; + +/** + * 선택한 이미지 파일을 업로드하고 결과 URL 을 상태에 반영합니다. + * + * @param action 액션 객체(미사용 params) + * @param context 액션 컨텍스트($event 로 파일 접근) + */ +export async function uploadTemplateImageHandler( + action: ActionWithParams, + context: ActionContext, +): Promise { + const G7Core = (window as any).G7Core; + if (!G7Core?.state?.setLocal) { + logger.error('G7Core.state.setLocal 을 사용할 수 없습니다.'); + return; + } + + const event = (context?.event ?? (action as any)?.event) as Event | undefined; + const input = event?.target as HTMLInputElement | undefined; + const file = input?.files?.[0]; + + if (!file) { + return; + } + + G7Core.state.setLocal({ imageUploading: true, imageUploadError: null }); + + try { + const token = localStorage.getItem('auth_token') ?? ''; + const form = new FormData(); + form.append('image', file, file.name); + + const res = await fetch(UPLOAD_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: form, + }); + + const json = await res.json().catch(() => ({})); + + if (!res.ok || json?.success === false) { + const message = json?.errors?.kakao_message || json?.message || '이미지 업로드에 실패했습니다.'; + G7Core.state.setLocal({ imageUploading: false, imageUploadError: message }); + logger.warn('이미지 업로드 실패:', message); + return; + } + + const url = json?.data?.image_url ?? ''; + const name = json?.data?.image_name ?? file.name; + + G7Core.state.setLocal({ + imageUploading: false, + imageUploadError: null, + 'templateForm.templateImageUrl': url, + 'templateForm.templateImageName': name, + }); + + logger.log('이미지 업로드 완료:', url); + } catch (e) { + G7Core.state.setLocal({ imageUploading: false, imageUploadError: String(e) }); + logger.error('이미지 업로드 예외:', e); + } finally { + // 같은 파일 재선택이 가능하도록 input 값 초기화 + if (input) { + input.value = ''; + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts new file mode 100644 index 00000000..ba8d309e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts @@ -0,0 +1,94 @@ +/** + * sirsoft-message_bizppurio 플러그인 엔트리포인트. + * + * 플러그인 활성화 시 자동 로드되어 커스텀 핸들러(insertVariable)를 ActionDispatcher 에 + * 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다. + */ + +import { handlerMap } from './handlers'; + +const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio'; + +const logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? { + log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args), + warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args), + error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args), +}; + +/** + * handlerMap 의 모든 핸들러를 ActionDispatcher 에 등록합니다. + * + * @param dispatcher 코어 ActionDispatcher 인스턴스 + */ +function register(dispatcher: any): void { + Object.entries(handlerMap).forEach(([name, handler]) => { + dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.${name}`, handler, { + category: 'plugin', + source: PLUGIN_IDENTIFIER, + }); + }); + + logger.log( + `${Object.keys(handlerMap).length} handler(s) registered:`, + Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`), + ); +} + +/** + * ActionDispatcher 준비 후 핸들러를 등록합니다. + * + * 최초 로드 시 ActionDispatcher 가 아직 없을 수 있으므로 짧게 재시도한다. + * + * @param retry ActionDispatcher 부재 시 재시도 여부 + */ +function registerHandlers(retry: boolean): void { + const dispatcher = (window as any).G7Core?.getActionDispatcher?.(); + + if (dispatcher) { + register(dispatcher); + return; + } + + if (!retry) { + logger.warn('ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.'); + return; + } + + let count = 0; + const max = 50; // 최대 5초 (50 * 100ms) + const tick = () => { + const found = (window as any).G7Core?.getActionDispatcher?.(); + if (found) { + register(found); + return; + } + if (++count <= max) { + setTimeout(tick, 100); + } else { + logger.error('ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.'); + } + }; + tick(); +} + +/** + * 플러그인 초기화 — DOM 준비 후 핸들러 등록. + */ +export function initPlugin(): void { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => registerHandlers(true)); + } else { + const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.(); + registerHandlers(!hasDispatcher); + } +} + +initPlugin(); + +if (typeof window !== 'undefined') { + (window as any).__SirsoftMessageBizppurio = { + identifier: PLUGIN_IDENTIFIER, + handlers: Object.keys(handlerMap), + initPlugin, + }; +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts new file mode 100644 index 00000000..a0610d1a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts @@ -0,0 +1,28 @@ +/** + * sirsoft-message_bizppurio 플러그인 프론트 타입 정의. + */ + +/** + * 액션 컨텍스트 인터페이스. + * + * ActionDispatcher 가 커스텀 핸들러 실행 시 전달하는 컨텍스트다. + */ +export interface ActionContext { + /** 현재 로컬 상태 가져오기 */ + getLocalState?: () => Record; + /** 로컬 상태 업데이트 */ + setLocalState?: (updates: Record) => void; + /** 이벤트 객체 */ + event?: Event; + /** 데이터 컨텍스트 */ + dataContext?: Record; +} + +/** + * 커스텀 핸들러 액션 객체(공통). + */ +export interface ActionWithParams { + handler: string; + params?: Record; + [key: string]: any; +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index e3f026ab..a13ba160 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -56,6 +56,149 @@ "note": "Once registered, delivery results are recorded in the history so you can verify actual delivery. Before registration only the send request is confirmed; the final result (success/failure) is not updated.", "copy": "Copy", "copied": "Report endpoint copied to clipboard." + }, + "tabs": { + "connection": "Settings", + "templates": "Alimtalk Templates" + } + }, + "templates": { + "title": "Alimtalk Templates", + "description": "Register, inspect, and manage KakaoTalk alimtalk templates. Only approved templates can be used for notification bindings.", + "readiness": { + "title": "The following settings are required to use alimtalk templates", + "go_settings": "Go to settings", + "missing_label": "Missing", + "api_key_missing": "Kakao management API key", + "sender_key_missing": "Alimtalk sender profile key", + "note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off test mode and using templates approved by Kakao." + }, + "list": { + "new": "New Template", + "refresh": "Refresh", + "search_placeholder": "Search by name (2-50 chars)", + "filter_all": "All statuses", + "empty": "No alimtalk templates registered.", + "empty_hint": "Register an alimtalk template with the [New Template] button.", + "load_failed": "Failed to load templates. Check the sender profile key and API key.", + "columns": { + "name": "Name", + "code": "Code", + "status": "Status", + "actions": "Actions" + } + }, + "status": { + "sendable": "Sendable", + "inspecting": "Inspecting", + "rejected": "Rejected", + "uninspected": "Not inspected", + "stopped": "Stopped", + "blocked": "Blocked", + "dormant": "Dormant", + "unknown": "Unknown" + }, + "actions": { + "edit": "Edit", + "delete": "Delete", + "request": "Request", + "cancel_request": "Cancel request", + "stop": "Stop", + "reuse": "Resume", + "cancel_approval": "Cancel approval", + "release": "Release", + "detail": "Detail" + }, + "form": { + "title_new": "Register Alimtalk Template", + "title_edit": "Edit Alimtalk Template", + "back_to_list": "Back to list", + "sender_key": "Sender profile key", + "code": "Template code", + "code_auto": "Auto-generate", + "code_manual": "Enter manually", + "code_hint": "Letters, digits, underscore(_), hyphen(-), max 30 chars", + "name": "Template name", + "name_hint": "Management name (not part of content), max 200 chars", + "category": "Category", + "category_group": "Group", + "category_item": "Item", + "emphasize_type": "Template type", + "type_none": "Basic", + "type_image": "Image", + "type_text": "Highlighted", + "type_item_list_notice": "Advanced templates such as item-list should be registered on the Bizppurio website. Templates registered there are also available for listing and bindings.", + "goto_bizppurio": "Bizppurio website", + "image_name": "Image file name", + "image_url": "Image URL", + "image_upload": "Attach image", + "image_upload_hint": "jpg/png / max 500KB / at least 500px wide (2:1 to 3:4 ratio)", + "image_uploading": "Uploading image...", + "image_remove": "Remove image", + "image_maker_notice": "The image maker (auto-generation tool) is provided in the Bizppurio console.", + "title_field": "Highlight title", + "title_hint": "Only content present in the body, max 23 chars (variables allowed)", + "subtitle_field": "Highlight subtitle", + "subtitle_hint": "Only content present in the body, max 18 chars (no variables)", + "content": "Template content", + "content_placeholder": "Enter the body. Insert variables with #{variable}.", + "content_hint": "Max 1300 chars", + "variables_title": "Insert variable", + "variables_help": "Insert #{variable} into the body; when you bind this template to a notification, it is replaced with the real value (member name, order number, etc.). Below are example variables per notification — insert the ones matching the notification you will bind.", + "variables_quick": "Common variables (click to insert at cursor)", + "variables_ref_toggle": "Variable reference by notification", + "variables_ref_event": "Notification", + "variables_ref_vars": "Provided variables", + "optional_title": "Optional fields", + "optional_toggle": "Expand optional fields (extra info, buttons, etc.)", + "optional_help": "Check only the items you need to reveal their inputs. Buttons and quick replies assume a single web link (WL).", + "button": "Button", + "button_name": "Button label", + "button_url": "Mobile link URL", + "button_hint": "A web-link button shown at the bottom of the message.", + "quick_reply": "Quick reply", + "quick_reply_hint": "Quick reply (web link) used on consult-talk channels.", + "represent_link": "Represent link", + "link_pc": "PC web link", + "link_mo": "Mobile web link", + "preview_message": "Preview message", + "preview_message_hint": "Summary shown in the chat list (max 40 chars).", + "extra": "Extra info", + "extra_hint": "Supplementary text outside the body such as channel-add or ad notices (registers the message type as extra-info type).", + "security_flag": "Secure template (OTP, etc.)", + "security_flag_hint": "Turn on for secure messages such as OTP. The message text is hidden on devices other than the sending device.", + "preview_title": "Preview", + "preview_empty": "Enter the body to see a preview here.", + "save_register": "Register", + "save_register_hint": "Registers as a draft only. To send, request inspection from the list and get Kakao approval.", + "save_and_request": "Register and request inspection", + "save_and_request_hint": "Registers and requests Kakao inspection at once. You cannot edit after requesting." + }, + "detail": { + "title": "Template detail", + "close": "Close" + }, + "confirm": { + "delete": "Delete this template?", + "request": "Request inspection? You cannot edit after requesting.", + "cancel_request": "Cancel the inspection request?", + "stop": "Stop this template? It cannot be used for delivery once stopped.", + "cancel_approval": "Cancel the approval? Re-inspection will be required." + }, + "toast": { + "created": "The alimtalk template has been registered.", + "created_requested": "The alimtalk template has been registered and inspection requested.", + "updated": "The alimtalk template has been updated.", + "deleted": "The alimtalk template has been deleted.", + "action_success": "The request has been processed.", + "action_failed": "Failed to process the request." + }, + "varref": { + "welcome": "Welcome (signup)", + "reset_password": "Password reset", + "order_confirmed": "Order confirmed", + "order_shipped": "Order shipped", + "new_comment": "New comment" } } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 4e585d3d..8dca6795 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -56,6 +56,149 @@ "note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.", "copy": "복사", "copied": "리포트 수신 주소가 클립보드에 복사되었습니다." + }, + "tabs": { + "connection": "환경설정", + "templates": "알림톡 템플릿" + } + }, + "templates": { + "title": "알림톡 템플릿", + "description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.", + "readiness": { + "title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다", + "go_settings": "환경설정으로 이동", + "missing_label": "미설정 항목", + "api_key_missing": "카카오 관리 API 키", + "sender_key_missing": "알림톡 발신프로필 키", + "note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다." + }, + "list": { + "new": "새 템플릿", + "refresh": "새로고침", + "search_placeholder": "템플릿명 검색 (2~50자)", + "filter_all": "전체 상태", + "empty": "등록된 알림톡 템플릿이 없습니다.", + "empty_hint": "[새 템플릿] 버튼으로 알림톡 템플릿을 등록하세요.", + "load_failed": "템플릿 목록을 불러오지 못했습니다. 발신프로필 키와 API 키를 확인하세요.", + "columns": { + "name": "템플릿명", + "code": "코드", + "status": "상태", + "actions": "관리" + } + }, + "status": { + "sendable": "발송가능", + "inspecting": "검수중", + "rejected": "반려", + "uninspected": "미검수", + "stopped": "중지", + "blocked": "차단", + "dormant": "휴면", + "unknown": "알 수 없음" + }, + "actions": { + "edit": "수정", + "delete": "삭제", + "request": "검수요청", + "cancel_request": "검수취소", + "stop": "중지", + "reuse": "중지해제", + "cancel_approval": "승인취소", + "release": "휴면해제", + "detail": "상세" + }, + "form": { + "title_new": "알림톡 템플릿 등록", + "title_edit": "알림톡 템플릿 수정", + "back_to_list": "목록으로", + "sender_key": "발신프로필 키", + "code": "템플릿 코드", + "code_auto": "자동 생성", + "code_manual": "직접 입력", + "code_hint": "영문·숫자·언더바(_)·하이픈(-), 최대 30자", + "name": "템플릿명", + "name_hint": "관리용 이름 (내용에 포함되지 않음), 최대 200자", + "category": "카테고리", + "category_group": "대분류", + "category_item": "소분류", + "emphasize_type": "템플릿 유형", + "type_none": "기본형", + "type_image": "이미지형", + "type_text": "강조표기형", + "type_item_list_notice": "아이템리스트형 등 고급 템플릿은 비즈뿌리오 홈페이지에서 등록하세요. 홈페이지에서 등록한 템플릿도 목록 조회·연동에서 그대로 사용할 수 있습니다.", + "goto_bizppurio": "비즈뿌리오 홈페이지", + "image_name": "이미지 파일명", + "image_url": "이미지 링크", + "image_upload": "이미지 첨부", + "image_upload_hint": "jpg·png / 최대 500KB / 가로 500px 이상 (가로:세로 2:1~3:4)", + "image_uploading": "이미지 업로드 중...", + "image_remove": "이미지 제거", + "image_maker_notice": "이미지 메이커(자동 생성 도구)는 비즈뿌리오 콘솔에서 제공됩니다.", + "title_field": "강조표기 문구", + "title_hint": "본문에 있는 내용만, 최대 23자 (변수 사용 가능)", + "subtitle_field": "보조 문구", + "subtitle_hint": "본문에 있는 내용만, 최대 18자 (변수 사용 불가)", + "content": "템플릿 내용", + "content_placeholder": "본문을 입력하세요. #{변수} 형식으로 변수를 넣을 수 있습니다.", + "content_hint": "최대 1300자", + "variables_title": "변수 넣기", + "variables_help": "본문에 #{변수} 형식으로 넣으면, 이 템플릿을 알림에 연결할 때 실제 값(회원 이름·주문번호 등)으로 자동 치환됩니다. 아래는 알림별 제공 변수 예시입니다 — 연결할 알림에 맞는 변수를 본문에 넣으세요.", + "variables_quick": "자주 쓰는 변수 (클릭하면 커서 위치에 삽입)", + "variables_ref_toggle": "알림별 제공 변수 참고표", + "variables_ref_event": "알림", + "variables_ref_vars": "제공 변수", + "optional_title": "선택 항목", + "optional_toggle": "선택 항목 펼치기 (부가정보·버튼 등)", + "optional_help": "필요한 항목만 체크하면 입력란이 나타납니다. 버튼·바로연결은 웹링크(WL) 1개 기준입니다.", + "button": "버튼", + "button_name": "버튼명", + "button_url": "모바일 링크 URL", + "button_hint": "메시지 하단에 표시되는 웹링크 버튼입니다.", + "quick_reply": "바로연결", + "quick_reply_hint": "상담톡 채널에서 사용하는 바로연결(웹링크)입니다.", + "represent_link": "대표 링크", + "link_pc": "PC 웹 링크", + "link_mo": "모바일 웹 링크", + "preview_message": "미리보기 메시지", + "preview_message_hint": "채팅 목록에 표시되는 요약 문구 (최대 40자)", + "extra": "부가정보", + "extra_hint": "채널 추가·광고성 안내 등 본문 외 부가 문구 (입력 시 메시지 유형이 부가정보형으로 등록됩니다)", + "security_flag": "템플릿 보안 (OTP 등)", + "security_flag_hint": "OTP 등 보안 메시지일 때 켜세요. 발신 디바이스 외 다른 기기에는 메시지 텍스트가 표시되지 않습니다.", + "preview_title": "미리보기", + "preview_empty": "본문을 입력하면 여기에 미리보기가 표시됩니다.", + "save_register": "등록", + "save_register_hint": "임시 등록만 합니다. 발송하려면 이후 목록에서 검수요청 → 카카오 승인이 필요합니다.", + "save_and_request": "등록 후 검수요청", + "save_and_request_hint": "등록과 동시에 카카오에 검수를 요청합니다. 요청 후에는 수정할 수 없습니다." + }, + "detail": { + "title": "템플릿 상세", + "close": "닫기" + }, + "confirm": { + "delete": "이 템플릿을 삭제하시겠습니까?", + "request": "검수를 요청하시겠습니까? 요청 후에는 수정할 수 없습니다.", + "cancel_request": "검수 요청을 취소하시겠습니까?", + "stop": "이 템플릿을 중지하시겠습니까? 중지하면 발송에 사용할 수 없습니다.", + "cancel_approval": "승인을 취소하시겠습니까? 취소하면 재검수가 필요합니다." + }, + "toast": { + "created": "알림톡 템플릿을 등록했습니다.", + "created_requested": "알림톡 템플릿을 등록하고 검수를 요청했습니다.", + "updated": "알림톡 템플릿을 수정했습니다.", + "deleted": "알림톡 템플릿을 삭제했습니다.", + "action_success": "요청을 처리했습니다.", + "action_failed": "요청 처리에 실패했습니다." + }, + "varref": { + "welcome": "회원가입 환영", + "reset_password": "비밀번호 재설정", + "order_confirmed": "결제 완료", + "order_shipped": "배송 시작", + "new_comment": "새 댓글" } } } 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 index bacd2c55..317f819a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -9,6 +9,13 @@ "title": "$t:sirsoft-message_bizppurio.settings.title", "description": "$t:sirsoft-message_bizppurio.settings.description" }, + "init_actions": [ + { + "if": "{{(query.tab ?? 'connection') === 'templates'}}", + "handler": "refetchDataSource", + "params": { "dataSourceId": "alimtalk_templates" } + } + ], "data_sources": [ { "id": "settings", @@ -28,16 +35,34 @@ "auto_fetch": true, "auth_required": true, "initLocal": "reportInfo" + }, + { + "id": "alimtalk_templates", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "params": { + "status": "{{_local.templateStatus ?? ''}}", + "keyword": "{{_local.templateKeyword ?? ''}}" + } + }, + { + "id": "alimtalk_categories", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories", + "method": "GET", + "auto_fetch": false, + "auth_required": true } ], "slots": { "content": [ { - "id": "plugin_settings_content", + "id": "plugin_settings_page", "type": "basic", "name": "Div", - "dataKey": "form", - "trackChanges": true, "props": { "className": "admin-page-content-responsive" }, @@ -54,7 +79,74 @@ } }, { - "id": "info_panel", + "id": "settings_tabs", + "type": "basic", + "name": "Div", + "props": { "className": "mt-6 border-b border-gray-200 dark:border-gray-700 flex gap-1" }, + "children": [ + { + "id": "tab_connection", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-4 py-2.5 text-sm font-medium border-b-2 transition-colors {{(query.tab ?? 'connection') === 'connection' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}}" + }, + "actions": [ + { + "type": "click", + "handler": "replaceUrl", + "params": { "query": { "tab": "connection" }, "mergeQuery": true } + } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.tabs.connection" } + ] + }, + { + "id": "tab_templates", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-4 py-2.5 text-sm font-medium border-b-2 transition-colors {{(query.tab ?? 'connection') === 'templates' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "replaceUrl", + "params": { "query": { "tab": "templates" }, "mergeQuery": true } + }, + { + "handler": "setState", + "params": { "target": "local", "templateView": "list" } + }, + { + "handler": "refetchDataSource", + "params": { "dataSourceId": "alimtalk_templates" } + } + ] + } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.tabs.templates" } + ] + } + ] + }, + { + "id": "connection_tab_panel", + "type": "basic", + "name": "Div", + "if": "{{(query.tab ?? 'connection') === 'connection'}}", + "dataKey": "form", + "trackChanges": true, + "children": [ + { + "id": "info_panel", "type": "basic", "name": "Div", "props": { @@ -819,7 +911,1127 @@ ] } ] + }, + { + "id": "templates_tab_panel", + "type": "basic", + "name": "Div", + "if": "{{(query.tab ?? 'connection') === 'templates'}}", + "props": { "className": "mt-6" }, + "children": [ + { + "id": "templates_readiness", + "type": "basic", + "name": "Div", + "if": "{{!(settings?.data?.settings?.api_key) || !(settings?.data?.settings?.sender_key)}}", + "props": { "className": "mb-4 rounded-lg border border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex-between gap-3" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2 items-start" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { "name": "triangle-exclamation", "size": "md", "className": "text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "props": { "className": "text-sm font-medium text-amber-800 dark:text-amber-200" }, + "text": "$t:sirsoft-message_bizppurio.templates.readiness.title" + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-1.5 flex-wrap mt-1.5" }, + "children": [ + { + "type": "basic", + "name": "Span", + "if": "{{!(settings?.data?.settings?.api_key)}}", + "props": { "className": "inline-flex items-center px-2 py-0.5 rounded text-xs bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-100" }, + "text": "$t:sirsoft-message_bizppurio.templates.readiness.api_key_missing" + }, + { + "type": "basic", + "name": "Span", + "if": "{{!(settings?.data?.settings?.sender_key)}}", + "props": { "className": "inline-flex items-center px-2 py-0.5 rounded text-xs bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-100" }, + "text": "$t:sirsoft-message_bizppurio.templates.readiness.sender_key_missing" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-secondary btn-sm whitespace-nowrap" }, + "actions": [ + { "type": "click", "handler": "replaceUrl", "params": { "query": { "tab": "connection" }, "mergeQuery": true } } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.readiness.go_settings" } + ] + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { "className": "text-xs text-amber-700 dark:text-amber-300 mt-2 leading-relaxed" }, + "text": "$t:sirsoft-message_bizppurio.templates.readiness.note" + } + ] + }, + { + "id": "templates_list_view", + "type": "basic", + "name": "Div", + "if": "{{(_local.templateView ?? 'list') === 'list'}}", + "children": [ + { + "id": "templates_toolbar", + "type": "basic", + "name": "Div", + "props": { "className": "flex-between gap-3 mb-4 flex-wrap" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2 items-center flex-wrap" }, + "children": [ + { + "type": "basic", + "name": "Select", + "props": { + "name": "templateStatus", + "value": "{{_local.templateStatus ?? ''}}", + "className": "input w-auto" + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "templateStatus": "{{$event.target.value}}" } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } + ] + } + ], + "children": [ + { "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:sirsoft-message_bizppurio.templates.list.filter_all" }, + { "type": "basic", "name": "Option", "props": { "value": "ACT" }, "text": "$t:sirsoft-message_bizppurio.templates.status.sendable" }, + { "type": "basic", "name": "Option", "props": { "value": "REQ" }, "text": "$t:sirsoft-message_bizppurio.templates.status.inspecting" }, + { "type": "basic", "name": "Option", "props": { "value": "REJ" }, "text": "$t:sirsoft-message_bizppurio.templates.status.rejected" }, + { "type": "basic", "name": "Option", "props": { "value": "REG" }, "text": "$t:sirsoft-message_bizppurio.templates.status.uninspected" }, + { "type": "basic", "name": "Option", "props": { "value": "STP" }, "text": "$t:sirsoft-message_bizppurio.templates.status.stopped" } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "value": "{{_local.templateKeyword ?? ''}}", + "placeholder": "$t:sirsoft-message_bizppurio.templates.list.search_placeholder", + "className": "input w-64" + }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateKeyword": "{{$event.target.value}}" } } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-secondary btn-sm" }, + "actions": [ + { "type": "click", "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "rotate", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list.refresh" } + ] + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-primary btn-sm flex-center gap-1.5" }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "templateView": "form", "templateForm": { "templateEmphasizeType": "NONE", "templateName": "", "templateContent": "", "categoryCode": "" }, "editingCode": null, "templateErrors": null, "templateSaving": false, "templateCodeMode": "auto", "categoryGroup": "", "showVarRef": false, "showOptional": false } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_categories" } } + ] + } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "plus", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list.new" } + ] + } + ] + }, + { + "id": "templates_table_card", + "type": "basic", + "name": "Div", + "props": { "className": "overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800" }, + "children": [ + { + "type": "basic", + "name": "Table", + "props": { "className": "w-full text-sm" }, + "children": [ + { + "type": "basic", + "name": "Thead", + "props": { "className": "bg-gray-50 dark:bg-gray-700/50" }, + "children": [ + { + "type": "basic", + "name": "Tr", + "children": [ + { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.name" }, + { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.code" }, + { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.status" }, + { "type": "basic", "name": "Th", "props": { "className": "text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.actions" } + ] + } + ] + }, + { + "type": "basic", + "name": "Tbody", + "if": "{{(alimtalk_templates?.data?.templates ?? []).length > 0}}", + "iteration": { + "source": "alimtalk_templates?.data?.templates ?? []", + "item_var": "tpl" + }, + "children": [ + { + "type": "basic", + "name": "Tr", + "props": { "className": "border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/40" }, + "children": [ + { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 font-medium text-gray-900 dark:text-gray-100" }, "text": "{{tpl.templateName ?? ''}}" }, + { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 font-mono text-xs text-gray-500 dark:text-gray-400" }, "text": "{{tpl.templateCode ?? ''}}" }, + { + "type": "basic", + "name": "Td", + "props": { "className": "px-4 py-3" }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{tpl.status_badge?.variant === 'green' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : tpl.status_badge?.variant === 'yellow' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : tpl.status_badge?.variant === 'red' ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : tpl.status_badge?.variant === 'purple' ? 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'}}" + }, + "text": "{{$t(tpl.status_badge?.label_key ?? 'sirsoft-message_bizppurio.templates.status.unknown')}}" + } + ] + }, + { + "type": "basic", + "name": "Td", + "props": { "className": "px-4 py-3 text-right" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-ghost btn-xs" }, + "actions": [ + { "type": "click", "handler": "apiCall", "auth_required": true, "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{{tpl.templateCode}}", "params": { "method": "GET" }, "onSuccess": [ { "handler": "setState", "params": { "target": "local", "templateDetail": "{{response.data.template}}" } }, { "handler": "openModal", "target": "alimtalk_template_detail_modal" } ] } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.actions.detail" } + ] + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Tbody", + "if": "{{(alimtalk_templates?.data?.templates ?? []).length === 0}}", + "children": [ + { + "type": "basic", + "name": "Tr", + "children": [ + { + "type": "basic", + "name": "Td", + "props": { "className": "px-4 py-12 text-center text-gray-400 dark:text-gray-500", "colSpan": 4 }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "font-medium" }, "text": "$t:sirsoft-message_bizppurio.templates.list.empty" }, + { "type": "basic", "name": "P", "props": { "className": "text-xs mt-1" }, "text": "$t:sirsoft-message_bizppurio.templates.list.empty_hint" } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "templates_form_view", + "type": "basic", + "name": "Div", + "if": "{{_local.templateView === 'form'}}", + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex-between mb-4" }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { "className": "section-heading-md text-gray-900 dark:text-gray-100" }, + "text": "{{_local.editingCode ? $t('sirsoft-message_bizppurio.templates.form.title_edit') : $t('sirsoft-message_bizppurio.templates.form.title_new')}}" + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-ghost btn-sm" }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateView": "list" } } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "arrow-left", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.back_to_list" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-2 gap-6" }, + "children": [ + { + "id": "template_form_fields", + "type": "basic", + "name": "Div", + "props": { "className": "space-y-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6" }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.name" }, + { + "type": "basic", + "name": "Input", + "props": { "type": "text", "maxLength": 200, "className": "input", "value": "{{_local.templateForm?.templateName ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateName": "{{$event.target.value}}" } } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.name_hint" } + ] + }, + { + "id": "template_code_field", + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.code" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "inline-flex gap-2 mt-1 mb-2" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateCodeMode ?? 'auto') === 'auto' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateCodeMode": "auto", "templateForm.templateCode": "" } } + ], + "children": [ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.code_auto" } ] + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateCodeMode ?? 'auto') === 'manual' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateCodeMode": "manual" } } + ], + "children": [ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.code_manual" } ] + } + ] + }, + { + "type": "basic", + "name": "Input", + "if": "{{_local.templateCodeMode === 'manual'}}", + "props": { "type": "text", "maxLength": 30, "className": "input font-mono", "placeholder": "TW_1234", "value": "{{_local.templateForm?.templateCode ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateCode": "{{$event.target.value}}" } } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.code_hint" } + ] + }, + { + "id": "template_category_field", + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.category" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "grid grid-cols-2 gap-2" }, + "children": [ + { + "type": "basic", + "name": "Select", + "props": { "className": "input", "value": "{{_local.categoryGroup ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "categoryGroup": "{{$event.target.value}}", "templateForm.categoryCode": "" } } + ], + "children": [ + { "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:sirsoft-message_bizppurio.templates.form.category_group" }, + { + "type": "basic", + "name": "Option", + "iteration": { + "source": "[...new Set((alimtalk_categories?.data?.categories ?? []).map(c => c.groupName))]", + "item_var": "grp" + }, + "props": { "value": "{{grp}}" }, + "text": "{{grp}}" + } + ] + }, + { + "type": "basic", + "name": "Select", + "props": { "className": "input", "disabled": "{{!_local.categoryGroup}}", "value": "{{_local.templateForm?.categoryCode ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.categoryCode": "{{$event.target.value}}" } } + ], + "children": [ + { "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:sirsoft-message_bizppurio.templates.form.category_item" }, + { + "type": "basic", + "name": "Option", + "iteration": { + "source": "(alimtalk_categories?.data?.categories ?? []).filter(c => c.groupName === _local.categoryGroup)", + "item_var": "cat" + }, + "props": { "value": "{{cat.code}}" }, + "text": "{{cat.name}}" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.emphasize_type" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "inline-flex gap-2 mt-1" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateForm?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" + }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateEmphasizeType": "NONE" } } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.type_none" } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateForm?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" + }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateEmphasizeType": "IMAGE" } } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.type_image" } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateForm?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" + }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateEmphasizeType": "TEXT" } } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.type_text" } + ] + } + ] + } + ] + }, + { + "id": "template_text_fields", + "type": "basic", + "name": "Div", + "if": "{{_local.templateForm?.templateEmphasizeType === 'TEXT'}}", + "props": { "className": "space-y-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600" }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.title_field" }, + { + "type": "basic", + "name": "Input", + "props": { "type": "text", "maxLength": 23, "className": "input", "value": "{{_local.templateForm?.templateTitle ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateTitle": "{{$event.target.value}}" } } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.title_hint" } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.subtitle_field" }, + { + "type": "basic", + "name": "Input", + "props": { "type": "text", "maxLength": 18, "className": "input", "value": "{{_local.templateForm?.templateSubtitle ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateSubtitle": "{{$event.target.value}}" } } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.subtitle_hint" } + ] + } + ] + }, + { + "id": "template_image_fields", + "type": "basic", + "name": "Div", + "if": "{{_local.templateForm?.templateEmphasizeType === 'IMAGE'}}", + "props": { "className": "space-y-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600" }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.image_upload" }, + { + "type": "basic", + "name": "Input", + "props": { "type": "file", "accept": ".jpg,.jpeg,.png", "className": "block w-full text-sm text-gray-600 dark:text-gray-300 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:bg-blue-50 dark:file:bg-blue-900/40 file:text-blue-700 dark:file:text-blue-300 file:cursor-pointer" }, + "actions": [ + { "type": "change", "handler": "sirsoft-message_bizppurio.uploadTemplateImage" } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.image_upload_hint" } + ] + }, + { + "type": "basic", + "name": "P", + "if": "{{_local.imageUploading}}", + "props": { "className": "text-sm text-blue-600 dark:text-blue-400 flex items-center gap-1.5" }, + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "spinner", "className": "spinner-sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.image_uploading" } + ] + }, + { + "type": "basic", + "name": "P", + "if": "{{_local.imageUploadError}}", + "props": { "className": "text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.imageUploadError}}" + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.templateForm?.templateImageUrl}}", + "props": { "className": "flex items-center gap-3 p-2 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600" }, + "children": [ + { "type": "basic", "name": "Img", "props": { "src": "{{_local.templateForm?.templateImageUrl}}", "className": "w-16 h-16 object-cover rounded" } }, + { + "type": "basic", + "name": "Div", + "props": { "className": "min-w-0 flex-1" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-700 dark:text-gray-200 truncate" }, "text": "{{_local.templateForm?.templateImageName ?? ''}}" }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "text-xs text-red-600 dark:text-red-400 underline mt-0.5" }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateImageUrl": "", "templateForm.templateImageName": "" } } + ], + "children": [ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.image_remove" } ] + } + ] + } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.image_maker_notice" } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.content" }, + { + "type": "basic", + "name": "Textarea", + "props": { + "rows": 14, + "maxLength": 1300, + "placeholder": "$t:sirsoft-message_bizppurio.templates.form.content_placeholder", + "className": "textarea font-mono resize-y leading-relaxed", + "value": "{{_local.templateForm?.templateContent ?? ''}}" + }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateContent": "{{$event.target.value}}" } } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.content_hint" } + ] + }, + { + "id": "template_variables", + "type": "basic", + "name": "Div", + "props": { "className": "rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 p-3 space-y-2" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-sm font-medium text-blue-900 dark:text-blue-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_title" }, + { "type": "basic", "name": "P", "props": { "className": "text-xs text-blue-800 dark:text-blue-300 leading-relaxed" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_help" }, + { "type": "basic", "name": "P", "props": { "className": "text-xs text-blue-700 dark:text-blue-300 mt-1" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_quick" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-1.5 flex-wrap" }, + "iteration": { + "source": "['name', 'app_name', 'order_number', 'site_url']", + "item_var": "varName" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "px-2 py-1 rounded bg-white dark:bg-gray-800 border border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300 text-xs font-mono hover:bg-blue-100 dark:hover:bg-blue-900/50" }, + "actions": [ + { "type": "click", "handler": "sirsoft-message_bizppurio.insertVariable", "params": { "variable": "{{varName}}", "target": "templateContent", "statePath": "templateForm.templateContent" } } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "{{'#{' + varName + '}'}}" } + ] + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { "className": "text-xs font-medium text-blue-800 dark:text-blue-300 mt-2" }, + "text": "$t:sirsoft-message_bizppurio.templates.form.variables_ref_toggle" + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "overflow-x-auto mt-1 rounded border border-blue-100 dark:border-blue-800 bg-white/60 dark:bg-gray-900/40" }, + "children": [ + { + "type": "basic", + "name": "Table", + "props": { "className": "w-full text-xs" }, + "children": [ + { + "type": "basic", + "name": "Thead", + "children": [ + { + "type": "basic", + "name": "Tr", + "children": [ + { "type": "basic", "name": "Th", "props": { "className": "text-left px-2 py-1 text-blue-900 dark:text-blue-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_ref_event" }, + { "type": "basic", "name": "Th", "props": { "className": "text-left px-2 py-1 text-blue-900 dark:text-blue-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_ref_vars" } + ] + } + ] + }, + { + "type": "basic", + "name": "Tbody", + "iteration": { + "source": "[{event: $t('sirsoft-message_bizppurio.templates.varref.welcome'), vars: '#{name} #{app_name} #{action_url} #{site_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.reset_password'), vars: '#{name} #{app_name} #{action_url} #{expire_minutes} #{site_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.order_confirmed'), vars: '#{name} #{app_name} #{order_number} #{total_amount} #{order_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.order_shipped'), vars: '#{name} #{app_name} #{order_number} #{carrier_name} #{tracking_number} #{order_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.new_comment'), vars: '#{name} #{app_name} #{board_name} #{post_title} #{comment_author} #{post_url}'}]", + "item_var": "vref" + }, + "children": [ + { + "type": "basic", + "name": "Tr", + "props": { "className": "border-t border-blue-100 dark:border-blue-800" }, + "children": [ + { "type": "basic", "name": "Td", "props": { "className": "px-2 py-1 text-blue-800 dark:text-blue-300 whitespace-nowrap" }, "text": "{{vref.event}}" }, + { "type": "basic", "name": "Td", "props": { "className": "px-2 py-1 text-blue-700 dark:text-blue-300 font-mono" }, "text": "{{vref.vars}}" } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "template_optional", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "text-sm text-gray-600 dark:text-gray-300 flex items-center gap-1" }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "showOptional": "{{!_local.showOptional}}" } } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "{{_local.showOptional ? 'chevron-down' : 'chevron-right'}}", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.optional_toggle" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.showOptional}}", + "props": { "className": "mt-2 space-y-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "form-hint mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.form.optional_help" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optExtra}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optExtra": "{{$event.target.checked}}", "templateForm.templateExtra": "" } } ] }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.extra" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.optExtra}}", + "props": { "className": "mt-1.5 pl-6" }, + "children": [ + { "type": "basic", "name": "Textarea", "props": { "rows": 2, "maxLength": 500, "className": "textarea resize-y", "value": "{{_local.templateForm?.templateExtra ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateExtra": "{{$event.target.value}}" } } ] }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.extra_hint" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optPreview}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optPreview": "{{$event.target.checked}}", "templateForm.templatePreviewMessage": "" } } ] }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.preview_message" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.optPreview}}", + "props": { "className": "mt-1.5 pl-6" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "text", "maxLength": 40, "className": "input", "value": "{{_local.templateForm?.templatePreviewMessage ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templatePreviewMessage": "{{$event.target.value}}" } } ] }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.preview_message_hint" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optButton}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optButton": "{{$event.target.checked}}", "templateForm.buttons": [] } } ] }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.button" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.optButton}}", + "props": { "className": "mt-1.5 pl-6 grid grid-cols-1 sm:grid-cols-3 gap-2" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "text", "maxLength": 20, "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_name", "value": "{{_local.templateForm?.buttons?.[0]?.name ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.buttons.0.name": "{{$event.target.value}}", "templateForm.buttons.0.linkType": "WL" } } ] }, + { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input sm:col-span-2", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_url", "value": "{{_local.templateForm?.buttons?.[0]?.linkMo ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.buttons.0.linkMo": "{{$event.target.value}}", "templateForm.buttons.0.linkType": "WL" } } ] }, + { "type": "basic", "name": "P", "props": { "className": "form-hint sm:col-span-3" }, "text": "$t:sirsoft-message_bizppurio.templates.form.button_hint" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optQuick}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optQuick": "{{$event.target.checked}}", "templateForm.quickReplies": [] } } ] }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.quick_reply" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.optQuick}}", + "props": { "className": "mt-1.5 pl-6 grid grid-cols-1 sm:grid-cols-3 gap-2" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "text", "maxLength": 20, "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_name", "value": "{{_local.templateForm?.quickReplies?.[0]?.name ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.quickReplies.0.name": "{{$event.target.value}}", "templateForm.quickReplies.0.linkType": "WL" } } ] }, + { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input sm:col-span-2", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_url", "value": "{{_local.templateForm?.quickReplies?.[0]?.linkMo ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.quickReplies.0.linkMo": "{{$event.target.value}}", "templateForm.quickReplies.0.linkType": "WL" } } ] }, + { "type": "basic", "name": "P", "props": { "className": "form-hint sm:col-span-3" }, "text": "$t:sirsoft-message_bizppurio.templates.form.quick_reply_hint" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optLink}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optLink": "{{$event.target.checked}}", "templateForm.templateRepresentLink": {} } } ] }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.represent_link" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.optLink}}", + "props": { "className": "mt-1.5 pl-6 space-y-2" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.link_pc", "value": "{{_local.templateForm?.templateRepresentLink?.linkPc ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateRepresentLink.linkPc": "{{$event.target.value}}" } } ] }, + { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.link_mo", "value": "{{_local.templateForm?.templateRepresentLink?.linkMo ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateRepresentLink.linkMo": "{{$event.target.value}}" } } ] } + ] + } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-start gap-2 cursor-pointer pt-1" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "mt-0.5", "checked": "{{!!_local.templateForm?.securityFlag}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.securityFlag": "{{$event.target.checked}}" } } ] }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-700 dark:text-gray-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.security_flag" }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.security_flag_hint" } + ] + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.templateErrors}}", + "props": { "className": "alert-danger" }, + "children": [ + { + "type": "basic", + "name": "Ul", + "props": { "className": "list-disc list-inside space-y-1" }, + "iteration": { + "source": "Object.values(_local.templateErrors ?? {}).flatMap(m => Array.isArray(m) ? m : [m])", + "item_var": "msg" + }, + "children": [ + { "type": "basic", "name": "Li", "props": { "className": "text-error-soft text-sm" }, "text": "{{msg}}" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex flex-col gap-2 pt-2 border-t border-gray-100 dark:border-gray-700" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "disabled": "{{_local.templateSaving}}", "className": "btn btn-secondary flex-1" }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "templateErrors": null, "templateSaving": true } }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates", + "params": { "method": "POST", "body": "{{_local.templateForm}}" }, + "onSuccess": [ + { "handler": "setState", "params": { "target": "local", "templateSaving": false } }, + { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-message_bizppurio.templates.toast.created" } }, + { "handler": "setState", "params": { "target": "local", "templateView": "list" } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } + ], + "onError": [ + { "handler": "setState", "params": { "target": "local", "templateSaving": false, "templateErrors": "{{error.errors ?? { _general: error.message }}}" } }, + { "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-message_bizppurio.templates.toast.action_failed" } } + ] + } + ] + } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.save_register" } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "disabled": "{{_local.templateSaving}}", "className": "btn btn-primary flex-1" }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "templateErrors": null, "templateSaving": true } }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates", + "params": { "method": "POST", "body": "{{Object.assign({}, _local.templateForm, { requestInspection: true })}}" }, + "onSuccess": [ + { "handler": "setState", "params": { "target": "local", "templateSaving": false } }, + { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-message_bizppurio.templates.toast.created_requested" } }, + { "handler": "setState", "params": { "target": "local", "templateView": "list" } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } + ], + "onError": [ + { "handler": "setState", "params": { "target": "local", "templateSaving": false, "templateErrors": "{{error.errors ?? { _general: error.message }}}" } }, + { "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-message_bizppurio.templates.toast.action_failed" } } + ] + } + ] + } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.save_and_request" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "rounded-lg bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-600 p-3 space-y-2 text-xs" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "font-medium text-gray-700 dark:text-gray-200 whitespace-nowrap" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_register" }, + { "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_register_hint" } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "font-medium text-blue-700 dark:text-blue-300 whitespace-nowrap" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_and_request" }, + { "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_and_request_hint" } + ] + } + ] + } + ] + } + ] + }, + { + "id": "template_form_preview", + "type": "basic", + "name": "Div", + "props": { "className": "bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-label mb-3 text-gray-700 dark:text-gray-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.preview_title" }, + { + "type": "basic", + "name": "Div", + "if": "{{(_local.templateForm?.templateContent ?? '') !== ''}}", + "props": { "className": "bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-600 p-4 whitespace-pre-wrap text-sm text-gray-900 dark:text-gray-100 leading-relaxed min-h-40" }, + "text": "{{_local.templateForm?.templateContent ?? ''}}" + }, + { + "type": "basic", + "name": "Div", + "if": "{{(_local.templateForm?.templateContent ?? '') === ''}}", + "props": { "className": "bg-white dark:bg-gray-900 rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-4 text-sm text-gray-400 dark:text-gray-500 min-h-40 flex items-center justify-center text-center" }, + "text": "$t:sirsoft-message_bizppurio.templates.form.preview_empty" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint mt-3" }, + "text": "$t:sirsoft-message_bizppurio.templates.form.type_item_list_notice" + } + ] + } + ] + } + ] + } + ] + } + ] } ] - } + }, + "modals": [ + { + "id": "alimtalk_template_detail_modal", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.templates.detail.title", + "size": "lg" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "space-y-3" }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.name" }, + { "type": "basic", "name": "P", "props": { "className": "text-body" }, "text": "{{_local.templateDetail?.templateName ?? ''}}" } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.code" }, + { "type": "basic", "name": "P", "props": { "className": "font-mono text-sm text-body" }, "text": "{{_local.templateDetail?.templateCode ?? ''}}" } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.content" }, + { "type": "basic", "name": "Div", "props": { "className": "bg-gray-50 dark:bg-gray-900/40 rounded p-3 whitespace-pre-wrap text-sm text-body" }, "text": "{{_local.templateDetail?.templateContent ?? ''}}" } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex justify-end pt-2" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-secondary" }, + "actions": [ + { "type": "click", "handler": "closeModal", "target": "alimtalk_template_detail_modal" } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.detail.close" } + ] + } + ] + } + ] + } + ] + } + ] } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php new file mode 100644 index 00000000..c2353e4b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php @@ -0,0 +1,284 @@ +guard(function () use ($request) { + $result = $this->service->list([ + 'status' => $request->query('status'), + 'keyword' => $request->query('keyword'), + 'page' => $request->query('page'), + 'count' => $request->query('count'), + ]); + + return ResponseHelper::success('messages.success', $result); + }); + } + + /** + * 알림톡 템플릿 상세를 실시간 조회합니다. + * + * @param string $templateCode 템플릿 코드 + */ + public function show(string $templateCode): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success('messages.success', [ + 'template' => $this->service->detail($templateCode), + ])); + } + + /** + * 템플릿 등록에 사용할 카테고리 전체를 조회합니다. + */ + public function categories(): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success('messages.success', [ + 'categories' => $this->service->categories(), + ])); + } + + /** + * 발신프로필(사용중) 정보를 조회합니다. + */ + public function profiles(): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success('messages.success', [ + 'profiles' => $this->service->senderProfiles(), + ])); + } + + /** + * 이미지형 템플릿용 이미지를 업로드하고 카카오 등록 URL 을 반환합니다. + * + * 업로드된 임시 파일을 카카오 이미지 업로드 API 로 전달하고, 반환된 URL 을 프론트가 + * templateImageUrl 로 사용한다. + * + * @param UploadTemplateImageRequest $request 이미지 파일 검증 Request + */ + public function uploadImage(UploadTemplateImageRequest $request): JsonResponse + { + return $this->guard(function () use ($request) { + $file = $request->file('image'); + + $url = $this->service->uploadImage( + $file->getRealPath(), + $file->getClientOriginalName(), + ); + + return ResponseHelper::success('messages.success', [ + 'image_url' => $url, + 'image_name' => $file->getClientOriginalName(), + ]); + }); + } + + /** + * 알림톡 템플릿을 신규 등록합니다. + * + * @param StoreAlimtalkTemplateRequest $request 유형별 검증 Request + */ + public function store(StoreAlimtalkTemplateRequest $request): JsonResponse + { + return $this->guard(function () use ($request) { + $data = $request->validated(); + $requestInspection = (bool) ($data['requestInspection'] ?? false); + + $template = $this->service->create($data); + + // [등록 후 검수요청]: 등록으로 받은 템플릿 코드로 즉시 검수를 요청한다. + if ($requestInspection) { + $templateCode = (string) ($template['templateCode'] ?? ''); + if ($templateCode !== '') { + $this->service->requestInspection($templateCode); + } + + return ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.created_requested', + ['template' => $template], + ); + } + + return ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.created', + ['template' => $template], + ); + }); + } + + /** + * 알림톡 템플릿을 수정합니다. (대기 + 검수 REG/REJ 상태만) + * + * @param UpdateAlimtalkTemplateRequest $request 유형별 검증 Request + * @param string $templateCode 기존 템플릿 코드 + */ + public function update(UpdateAlimtalkTemplateRequest $request, string $templateCode): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.updated', + ['template' => $this->service->update($templateCode, $request->validated())], + )); + } + + /** + * 알림톡 템플릿을 삭제합니다. (대기 + 검수 REG/REJ 상태만) + * + * @param string $templateCode 템플릿 코드 + */ + public function destroy(string $templateCode): JsonResponse + { + return $this->guard(function () use ($templateCode) { + $this->service->delete($templateCode); + + return ResponseHelper::success('sirsoft-message_bizppurio::messages.template.deleted'); + }); + } + + /** + * 검수를 요청합니다. (검수 REG 상태만) + * + * @param Request $request HTTP 요청(comment 선택) + * @param string $templateCode 템플릿 코드 + */ + public function requestInspection(Request $request, string $templateCode): JsonResponse + { + return $this->guard(function () use ($request, $templateCode) { + $this->service->requestInspection($templateCode, $request->input('comment')); + + return ResponseHelper::success('sirsoft-message_bizppurio::messages.template.requested'); + }); + } + + /** + * 검수 요청을 취소합니다. (검수 REQ 상태만) + * + * @param string $templateCode 템플릿 코드 + */ + public function cancelRequest(string $templateCode): JsonResponse + { + return $this->statusAction($templateCode, 'cancelRequest', 'request_canceled'); + } + + /** + * 승인된 템플릿을 중지합니다. (RDY/ACT 상태) + * + * @param string $templateCode 템플릿 코드 + */ + public function stop(string $templateCode): JsonResponse + { + return $this->statusAction($templateCode, 'stop', 'stopped'); + } + + /** + * 중지된 템플릿을 정상으로 되돌립니다. (STP 상태) + * + * @param string $templateCode 템플릿 코드 + */ + public function reuse(string $templateCode): JsonResponse + { + return $this->statusAction($templateCode, 'reuse', 'resumed'); + } + + /** + * 승인을 취소합니다(재검수 가능 상태로 복귀). (RDY/ACT 상태) + * + * @param string $templateCode 템플릿 코드 + */ + public function cancelApproval(string $templateCode): JsonResponse + { + return $this->statusAction($templateCode, 'cancelApproval', 'approval_canceled'); + } + + /** + * 휴면 템플릿을 해제합니다. (DMT 상태) + * + * @param string $templateCode 템플릿 코드 + */ + public function release(string $templateCode): JsonResponse + { + return $this->statusAction($templateCode, 'release', 'released'); + } + + /** + * senderKey + templateCode 만 필요한 단순 상태 변경 액션의 공통 처리. + * + * @param string $templateCode 템플릿 코드 + * @param string $method 서비스 메서드명 + * @param string $messageKey 성공 메시지 키(messages.template.*) + */ + private function statusAction(string $templateCode, string $method, string $messageKey): JsonResponse + { + return $this->guard(function () use ($templateCode, $method, $messageKey) { + $this->service->{$method}($templateCode); + + return ResponseHelper::success("sirsoft-message_bizppurio::messages.template.{$messageKey}"); + }); + } + + /** + * kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환합니다. + * + * 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 반려/차단/상태오류 원인을 + * 즉시 파악하게 한다. errors 에 result_code 를 실어 프론트가 코드 분기 가능. + * + * @param callable(): JsonResponse $callback 실제 처리 + */ + private function guard(callable $callback): JsonResponse + { + try { + return $callback(); + } catch (BizppurioApiException $e) { + return ResponseHelper::error( + 'sirsoft-message_bizppurio::messages.error.kakao_request_failed', + 422, + [ + 'kakao_message' => $e->getMessage(), + 'result_code' => $e->getResultCode(), + ], + ); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php new file mode 100644 index 00000000..fe3e6b36 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php @@ -0,0 +1,109 @@ + + */ + public function rules(): array + { + return [ + // 공통 필수 + 'templateName' => ['required', 'string', 'max:200'], + 'templateContent' => ['required', 'string', 'max:1300'], + 'categoryCode' => ['required', 'string', 'max:20'], + 'templateEmphasizeType' => ['required', Rule::in(['NONE', 'IMAGE', 'TEXT'])], + + // 템플릿 코드(선택, 자동 생성 가능). 영문·숫자·언더바·하이픈, 최대 30자 + 'templateCode' => ['nullable', 'string', 'max:30', 'regex:/^[A-Za-z0-9_-]+$/'], + + // 강조표기형(TEXT) 조건부 필수 — 강조표기 문구(변수 가능) + 보조문구(변수 불가) + 'templateTitle' => ['required_if:templateEmphasizeType,TEXT', 'nullable', 'string', 'max:23'], + 'templateSubtitle' => ['required_if:templateEmphasizeType,TEXT', 'nullable', 'string', 'max:18'], + + // 이미지형(IMAGE) 조건부 필수 — 이미지 파일명 + 링크 + 'templateImageName' => ['required_if:templateEmphasizeType,IMAGE', 'nullable', 'string', 'max:255'], + 'templateImageUrl' => ['required_if:templateEmphasizeType,IMAGE', 'nullable', 'string', 'url', 'max:500'], + + // 선택 항목(§6-4): 부가정보 · 미리보기 메시지 · 템플릿 보안 + 'templatePreviewMessage' => ['nullable', 'string', 'max:40'], + 'templateExtra' => ['nullable', 'string', 'max:500'], + 'securityFlag' => ['nullable', 'boolean'], + + // 등록 후 즉시 검수요청 여부([등록 후 검수요청] 버튼) — 서비스가 소비, kapi payload 제외 + 'requestInspection' => ['nullable', 'boolean'], + + // 버튼(최대 5) + 'buttons' => ['nullable', 'array', 'max:5'], + 'buttons.*.name' => ['required_with:buttons', 'string', 'max:20'], + 'buttons.*.linkType' => ['required_with:buttons', 'string', 'max:4'], + 'buttons.*.linkMo' => ['nullable', 'string', 'max:500'], + 'buttons.*.linkPc' => ['nullable', 'string', 'max:500'], + 'buttons.*.linkAnd' => ['nullable', 'string', 'max:500'], + 'buttons.*.linkIos' => ['nullable', 'string', 'max:500'], + + // 바로연결(최대 10) + 'quickReplies' => ['nullable', 'array', 'max:10'], + 'quickReplies.*.name' => ['required_with:quickReplies', 'string', 'max:20'], + 'quickReplies.*.linkType' => ['required_with:quickReplies', 'string', 'max:4'], + + // 대표 링크(선택) — PC/모바일 웹, 앱 스킴 + 'templateRepresentLink' => ['nullable', 'array'], + 'templateRepresentLink.linkPc' => ['nullable', 'string', 'max:500'], + 'templateRepresentLink.linkMo' => ['nullable', 'string', 'max:500'], + 'templateRepresentLink.linkAnd' => ['nullable', 'string', 'max:500'], + 'templateRepresentLink.linkIos' => ['nullable', 'string', 'max:500'], + ]; + } + + /** + * 검증 속성 라벨(다국어). + * + * @return array + */ + public function attributes(): array + { + return [ + 'templateName' => __('sirsoft-message_bizppurio::messages.template.fields.name'), + 'templateContent' => __('sirsoft-message_bizppurio::messages.template.fields.content'), + 'categoryCode' => __('sirsoft-message_bizppurio::messages.template.fields.category'), + 'templateEmphasizeType' => __('sirsoft-message_bizppurio::messages.template.fields.emphasize_type'), + 'templateCode' => __('sirsoft-message_bizppurio::messages.template.fields.code'), + 'templateTitle' => __('sirsoft-message_bizppurio::messages.template.fields.title'), + 'templateSubtitle' => __('sirsoft-message_bizppurio::messages.template.fields.subtitle'), + 'templateImageName' => __('sirsoft-message_bizppurio::messages.template.fields.image_name'), + 'templateImageUrl' => __('sirsoft-message_bizppurio::messages.template.fields.image_url'), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php new file mode 100644 index 00000000..0e6a0063 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php @@ -0,0 +1,16 @@ + + */ + public function rules(): array + { + return [ + // jpg/png, 최대 500KB (카카오 이미지형 제한) + 'image' => ['required', 'file', 'mimes:jpg,jpeg,png', 'max:500'], + ]; + } + + /** + * 검증 속성 라벨(다국어). + * + * @return array + */ + public function attributes(): array + { + return [ + 'image' => __('sirsoft-message_bizppurio::messages.template.fields.image'), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php new file mode 100644 index 00000000..7a71e4f2 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php @@ -0,0 +1,581 @@ + lang key, 'variant' => 배지 색]. + * variant 는 프론트가 배지 색상 클래스로 사용한다(green/yellow/red/gray/dark/purple). + * + * @var array + */ + private const STATUS_BADGES = [ + 'RDY' => ['label_key' => 'sendable', 'variant' => 'green'], + 'ACT' => ['label_key' => 'sendable', 'variant' => 'green'], + 'REQ' => ['label_key' => 'inspecting', 'variant' => 'yellow'], + 'REJ' => ['label_key' => 'rejected', 'variant' => 'red'], + 'REG' => ['label_key' => 'uninspected', 'variant' => 'gray'], + 'STP' => ['label_key' => 'stopped', 'variant' => 'dark'], + 'BLK' => ['label_key' => 'blocked', 'variant' => 'dark'], + 'DMT' => ['label_key' => 'dormant', 'variant' => 'purple'], + ]; + + /** + * serviceStatus → 상태별 가능 액션(정본 표, §6-3). + * + * @var array> + */ + private const STATUS_ACTIONS = [ + 'REG' => ['edit', 'delete', 'request'], + 'REQ' => ['cancel_request'], + 'REJ' => ['edit', 'delete'], + 'RDY' => ['stop', 'cancel_approval'], + 'ACT' => ['stop', 'cancel_approval'], + 'STP' => ['reuse'], + 'DMT' => ['release'], + 'BLK' => [], + ]; + + /** + * @param BizppurioKakaoApiClient $kakao 카카오 관리 API 클라이언트 + * @param PluginSettingsService $pluginSettings 환경설정 조회(sender_key) + */ + public function __construct( + private readonly BizppurioKakaoApiClient $kakao, + private readonly PluginSettingsService $pluginSettings, + ) {} + + /** + * 알림톡 템플릿 목록을 실시간 조회합니다. + * + * @param array $filters status(templateStatus)·keyword·page·count + * @return array{templates: array>, pagination: array} + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function list(array $filters = []): array + { + $params = [ + 'count' => (int) ($filters['count'] ?? self::DEFAULT_COUNT), + 'page' => max(1, (int) ($filters['page'] ?? 1)), + ]; + + if (! empty($filters['status'])) { + $params['templateStatus'] = (string) $filters['status']; + } + + if (! empty($filters['keyword'])) { + $params['keyword'] = (string) $filters['keyword']; + } + + $response = $this->kakao->getTemplateList($this->senderKey(), $params); + + $this->assertSuccess($response); + + $rows = (array) ($response['data']['list'] ?? $response['data'] ?? []); + + return [ + 'templates' => array_map(fn (array $row) => $this->decorate($row), $rows), + 'pagination' => [ + 'total' => (int) ($response['totalCount'] ?? count($rows)), + 'total_page' => (int) ($response['totalPage'] ?? 1), + 'current_page' => (int) ($response['currentPage'] ?? $params['page']), + 'per_page' => $params['count'], + ], + ]; + } + + /** + * 알림톡 템플릿 상세를 실시간 조회합니다. + * + * @param string $templateCode 템플릿 코드 + * @return array 배지·가능 액션이 부가된 템플릿 상세 + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function detail(string $templateCode): array + { + $response = $this->kakao->getTemplateDetail($this->senderKey(), $templateCode); + + $this->assertSuccess($response); + + return $this->decorate((array) ($response['data'] ?? [])); + } + + /** + * 템플릿 등록에 사용할 카테고리 목록 전체를 조회합니다. + * + * @return array> [{code, name, groupName}] + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function categories(): array + { + $response = $this->kakao->request('/v3/kakao/template/category/all'); + + $this->assertSuccess($response); + + return array_values((array) ($response['data'] ?? [])); + } + + /** + * 발신프로필(사용중) 정보를 조회합니다. + * + * @return array 발신프로필 응답 data + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function senderProfiles(): array + { + $response = $this->kakao->getSenderProfiles(); + + $this->assertSuccess($response); + + return (array) ($response['data'] ?? []); + } + + /** + * 이미지형 템플릿용 이미지를 카카오 서버에 업로드하고 등록 URL 을 반환합니다. + * + * 업로드 성공 시 카카오가 반환한 이미지 URL 을 `templateImageUrl` 로 사용한다. + * + * @param string $filePath 임시 업로드 파일 절대 경로 + * @param string $fileName 원본 파일명 + * @return string 카카오 서버에 등록된 이미지 URL + * + * @throws BizppurioApiException 업로드 실패 시 + */ + public function uploadImage(string $filePath, string $fileName): string + { + $response = $this->kakao->uploadTemplateImage($filePath, $fileName); + + $this->assertSuccess($response); + + return (string) ($response['image'] ?? ''); + } + + /** + * 알림톡 템플릿을 신규 등록합니다. + * + * senderKey·templateMessageType 은 서비스가 채우며(FormRequest 는 화면 입력만 검증), + * messageType 은 부가정보/채널추가 버튼 유무로 자동 계산한다. + * + * @param array $data 검증된 템플릿 입력 + * @return array 등록 결과(템플릿 상세) + * + * @throws BizppurioApiException 등록 실패 시 + */ + public function create(array $data): array + { + $payload = $this->buildPayload($data); + + $response = $this->kakao->request('/v3/kakao/template/add', $payload); + + $this->assertSuccess($response); + + $this->logActivity('sirsoft-message_bizppurio.alimtalk_template.create', [ + 'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_create', + 'description_params' => ['template_name' => (string) ($data['templateName'] ?? '')], + ]); + + return (array) ($response['data'] ?? []); + } + + /** + * 알림톡 템플릿을 수정합니다. (대기(R) + 검수 REG/REJ 상태만 가능) + * + * @param string $templateCode 기존 템플릿 코드 + * @param array $data 검증된 템플릿 입력 + * @return array 수정 결과(템플릿 상세) + * + * @throws BizppurioApiException 수정 실패 시 + */ + public function update(string $templateCode, array $data): array + { + $payload = array_merge($this->buildPayload($data), [ + 'templateCode' => $templateCode, + ]); + + $response = $this->kakao->request('/v3/kakao/template/update', $payload); + + $this->assertSuccess($response); + + $this->logActivity('sirsoft-message_bizppurio.alimtalk_template.update', [ + 'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_update', + 'description_params' => ['template_code' => $templateCode], + ]); + + return (array) ($response['data'] ?? []); + } + + /** + * 알림톡 템플릿을 삭제합니다. (대기(R) + 검수 REG/REJ 상태만 가능) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 삭제 실패 시 + */ + public function delete(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/delete', $templateCode, 'delete'); + } + + /** + * 검수를 요청합니다. (대기(R) + 검수 REG 상태만 가능) + * + * @param string $templateCode 템플릿 코드 + * @param string|null $comment 의견/문의(최대 500자) + * + * @throws BizppurioApiException 요청 실패 시 + */ + public function requestInspection(string $templateCode, ?string $comment = null): void + { + $params = [ + 'senderKey' => $this->senderKey(), + 'templateCode' => $templateCode, + ]; + + if ($comment !== null && $comment !== '') { + $params['comment'] = $comment; + } + + $response = $this->kakao->request('/v3/kakao/template/request', $params); + + $this->assertSuccess($response); + + $this->logActivity('sirsoft-message_bizppurio.alimtalk_template.request', [ + 'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_request', + 'description_params' => ['template_code' => $templateCode], + ]); + } + + /** + * 검수 요청을 취소합니다. (검수 REQ 상태만 가능) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 취소 실패 시 + */ + public function cancelRequest(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/cancel_request', $templateCode, 'cancel_request'); + } + + /** + * 승인된 템플릿을 중지합니다. (RDY/ACT 상태) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 중지 실패 시 + */ + public function stop(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/stop', $templateCode, 'stop'); + } + + /** + * 중지된 템플릿을 정상으로 되돌립니다. (STP 상태) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 해제 실패 시 + */ + public function reuse(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/reuse', $templateCode, 'reuse'); + } + + /** + * 승인을 취소합니다(재검수 요청 가능 상태로 복귀). (RDY/ACT 상태) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 취소 실패 시 + */ + public function cancelApproval(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/cancel_approval', $templateCode, 'cancel_approval'); + } + + /** + * 휴면 템플릿을 해제합니다. (DMT 상태) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 해제 실패 시 + */ + public function release(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/release', $templateCode, 'release'); + } + + /** + * senderKey + templateCode 만 필요한 단순 상태 변경 액션의 공통 처리. + * + * @param string $path kapi 엔드포인트 경로 + * @param string $templateCode 템플릿 코드 + * @param string $action 활동 로그 action 세그먼트 + * + * @throws BizppurioApiException 요청 실패 시 + */ + private function simpleAction(string $path, string $templateCode, string $action): void + { + $response = $this->kakao->request($path, [ + 'senderKey' => $this->senderKey(), + 'templateCode' => $templateCode, + ]); + + $this->assertSuccess($response); + + $this->logActivity("sirsoft-message_bizppurio.alimtalk_template.{$action}", [ + 'description_key' => "sirsoft-message_bizppurio::activity_log.description.alimtalk_template_{$action}", + 'description_params' => ['template_code' => $templateCode], + ]); + } + + /** + * 화면 입력을 kapi 등록/수정 payload 로 조립합니다. + * + * - senderKey 는 환경설정에서 주입 + * - templateMessageType 은 부가정보·채널추가(AC) 버튼 유무로 자동 계산 + * - 빈 선택항목은 payload 에서 제외(kapi 유형별 필수 규칙 위반 방지) + * + * @param array $data 검증된 입력 + * @return array kapi payload (bizId/apiKey 제외 — 클라이언트가 주입) + */ + private function buildPayload(array $data): array + { + $payload = [ + 'senderKey' => $this->senderKey(), + 'senderKeyType' => 'S', + 'templateName' => (string) $data['templateName'], + 'templateContent' => (string) $data['templateContent'], + 'categoryCode' => (string) $data['categoryCode'], + 'templateEmphasizeType' => (string) ($data['templateEmphasizeType'] ?? 'NONE'), + ]; + + if (! empty($data['templateCode'])) { + $payload['templateCode'] = (string) $data['templateCode']; + } + + // 강조표기형(TEXT) + if ($payload['templateEmphasizeType'] === 'TEXT') { + $payload['templateTitle'] = (string) ($data['templateTitle'] ?? ''); + $payload['templateSubtitle'] = (string) ($data['templateSubtitle'] ?? ''); + } + + // 이미지형(IMAGE) + if ($payload['templateEmphasizeType'] === 'IMAGE') { + $payload['templateImageName'] = (string) ($data['templateImageName'] ?? ''); + $payload['templateImageUrl'] = (string) ($data['templateImageUrl'] ?? ''); + } + + // 선택항목(§6-4): 부가정보 · 미리보기 메시지 + foreach (['templatePreviewMessage', 'templateExtra'] as $optional) { + if (! empty($data[$optional])) { + $payload[$optional] = (string) $data[$optional]; + } + } + + if (array_key_exists('securityFlag', $data)) { + $payload['securityFlag'] = (bool) $data['securityFlag']; + } + + // 선택항목: 버튼(최대 5) · 바로연결(최대 10) + $buttons = $this->normalizeList($data['buttons'] ?? []); + if ($buttons !== []) { + $payload['buttons'] = $buttons; + } + + $quickReplies = $this->normalizeList($data['quickReplies'] ?? []); + if ($quickReplies !== []) { + $payload['quickReplies'] = $quickReplies; + } + + // 선택항목: 대표 링크(빈 링크 필드 제거 후 하나라도 있으면 포함) + $representLink = array_filter( + (array) ($data['templateRepresentLink'] ?? []), + static fn ($v) => is_string($v) && $v !== '', + ); + if ($representLink !== []) { + $payload['templateRepresentLink'] = $representLink; + } + + $payload['templateMessageType'] = $this->resolveMessageType($payload); + + return $payload; + } + + /** + * templateMessageType 을 부가정보·채널추가(AC) 버튼 유무로 계산합니다. + * + * - 부가정보(templateExtra) 있음 → EX + * - 채널추가(AC) 버튼 있음 → AD + * - 둘 다 → MI + * - 없음 → BA + * + * @param array $payload 조립 중인 payload + * @return string BA/EX/AD/MI + */ + private function resolveMessageType(array $payload): string + { + $hasExtra = ! empty($payload['templateExtra']); + $hasAddChannel = false; + + foreach ((array) ($payload['buttons'] ?? []) as $button) { + if (($button['linkType'] ?? null) === 'AC') { + $hasAddChannel = true; + break; + } + } + + return match (true) { + $hasExtra && $hasAddChannel => 'MI', + $hasExtra => 'EX', + $hasAddChannel => 'AD', + default => 'BA', + }; + } + + /** + * 버튼/바로연결 배열에서 빈 항목을 제거하고 정규화합니다. + * + * @param mixed $list 입력 배열 + * @return array> + */ + private function normalizeList(mixed $list): array + { + if (! is_array($list)) { + return []; + } + + return array_values(array_filter($list, static fn ($item) => is_array($item) && ! empty($item['name']) && ! empty($item['linkType']))); + } + + /** + * 템플릿 행에 상태 배지·가능 액션을 부가합니다. + * + * serviceStatus(목록) 또는 inspectionStatus/status(상세)에서 배지 기준 상태를 도출한다. + * + * @param array $row kapi 템플릿 행 + * @return array 배지·액션이 부가된 행 + */ + private function decorate(array $row): array + { + $status = (string) ($row['serviceStatus'] ?? $this->deriveStatus($row)); + $badge = self::STATUS_BADGES[$status] ?? ['label_key' => 'unknown', 'variant' => 'gray']; + + $row['service_status'] = $status; + $row['status_badge'] = [ + 'label_key' => 'sirsoft-message_bizppurio::messages.template.status.'.$badge['label_key'], + 'variant' => $badge['variant'], + ]; + $row['available_actions'] = self::STATUS_ACTIONS[$status] ?? []; + + return $row; + } + + /** + * 상세 응답에서 serviceStatus 가 없을 때 status/inspectionStatus 로 상태를 추정합니다. + * + * 상세 조회는 serviceStatus 대신 status(S/A/R)+inspectionStatus(REG/REQ/REJ/APR)를 + * 내려주므로, 목록과 동일한 배지 체계로 환원한다. + * + * @param array $row kapi 템플릿 상세 행 + * @return string serviceStatus 코드 + */ + private function deriveStatus(array $row): string + { + $inspection = (string) ($row['inspectionStatus'] ?? ''); + $status = (string) ($row['status'] ?? ''); + $block = (bool) ($row['block'] ?? false); + $dormant = (bool) ($row['dormant'] ?? false); + + return match (true) { + $block => 'BLK', + $dormant => 'DMT', + $inspection === 'REQ' => 'REQ', + $inspection === 'REJ' => 'REJ', + $inspection === 'APR' && $status === 'S' => 'STP', + $inspection === 'APR' && $status === 'A' => 'ACT', + $inspection === 'APR' => 'RDY', + default => 'REG', + }; + } + + /** + * 환경설정에서 발신프로필 키(sender_key)를 조회합니다. + * + * @return string 발신프로필 키 + * + * @throws BizppurioApiException 미설정 시 + */ + private function senderKey(): string + { + $settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? []; + $senderKey = (string) ($settings['sender_key'] ?? ''); + + if ($senderKey === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.sender_key_missing'), + ); + } + + return $senderKey; + } + + /** + * kapi 응답이 성공(200)이 아니면 message 를 담아 예외를 던집니다. + * + * @param array $response kapi 응답 + * + * @throws BizppurioApiException 실패 코드 시 + */ + private function assertSuccess(array $response): void + { + if ($this->kakao->isSuccess($response)) { + return; + } + + $message = (string) ($response['message'] ?? ''); + $code = (string) ($response['code'] ?? ''); + + throw new BizppurioApiException( + $message !== '' + ? $message + : __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), + resultCode: $code !== '' ? $code : null, + ); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php index 1404fcff..7edad0f8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php @@ -87,6 +87,48 @@ class BizppurioKakaoApiClient ]); } + /** + * 알림톡 이미지형 템플릿용 이미지를 업로드합니다. (`/v3/kakao/image/alimtalk/template`) + * + * multipart/form-data 로 이미지 파일을 전송하고, 성공 시 카카오 서버에 등록된 이미지 + * URL(`image`)을 반환받는다. 이미지 제한: jpg/png, 최대 500KB, 가로 500px 이상. + * + * @param string $filePath 업로드할 이미지 파일의 절대 경로 + * @param string $fileName 원본 파일명 + * @return array 응답 배열 (code/message/image) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function uploadTemplateImage(string $filePath, string $fileName): array + { + [$bizId, $apiKey] = $this->credentials(); + + $response = $this->http() + ->attach('image', file_get_contents($filePath), $fileName) + ->post(self::BASE_URL.'/v3/kakao/image/alimtalk/template', [ + 'bizId' => $bizId, + 'apiKey' => $apiKey, + ]); + + if ($response->failed()) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), + httpStatus: $response->status(), + ); + } + + $result = $response->json(); + + if (! is_array($result)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.invalid_response'), + httpStatus: $response->status(), + ); + } + + return $result; + } + /** * 카카오 관리 API 의 임의 엔드포인트를 호출합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index 6e34e5b6..e5897118 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Middleware\EnforceIdentityPolicy; use App\Http\Middleware\RefreshTokenExpiration; use Illuminate\Support\Facades\Route; +use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController; use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController; use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist; @@ -45,4 +46,40 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g ]); })->middleware('permission:admin,core.plugins.read') ->name('report.url'); + + /* + |---------------------------------------------------------------------- + | 알림톡 템플릿 관리 (Phase 5) — 카카오 관리 API(kapi) 실시간 위임 + |---------------------------------------------------------------------- + | + | 조회(list/detail/categories/profiles) = messaging.view + | 변경(store/update/destroy/검수/상태변경) = messaging.manage + | 템플릿은 DB 저장 없이 매 요청 실시간 조회. 설정 페이지 알림톡 템플릿 탭이 소비. + */ + Route::prefix('alimtalk-templates')->name('alimtalk-templates.')->group(function () { + // 조회 (view) + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { + Route::get('/', [AlimtalkTemplateController::class, 'index'])->name('index'); + Route::get('/categories', [AlimtalkTemplateController::class, 'categories'])->name('categories'); + Route::get('/profiles', [AlimtalkTemplateController::class, 'profiles'])->name('profiles'); + Route::get('/{templateCode}', [AlimtalkTemplateController::class, 'show'])->name('show'); + }); + + // 변경 (manage) + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () { + Route::post('/', [AlimtalkTemplateController::class, 'store'])->name('store'); + // 이미지형 템플릿 이미지 업로드 (정적 경로 — {templateCode} 보다 먼저 선언) + Route::post('/image', [AlimtalkTemplateController::class, 'uploadImage'])->name('image'); + Route::put('/{templateCode}', [AlimtalkTemplateController::class, 'update'])->name('update'); + Route::delete('/{templateCode}', [AlimtalkTemplateController::class, 'destroy'])->name('destroy'); + + // 검수·상태 변경 + Route::post('/{templateCode}/request', [AlimtalkTemplateController::class, 'requestInspection'])->name('request'); + Route::post('/{templateCode}/cancel-request', [AlimtalkTemplateController::class, 'cancelRequest'])->name('cancel-request'); + Route::post('/{templateCode}/stop', [AlimtalkTemplateController::class, 'stop'])->name('stop'); + Route::post('/{templateCode}/reuse', [AlimtalkTemplateController::class, 'reuse'])->name('reuse'); + Route::post('/{templateCode}/cancel-approval', [AlimtalkTemplateController::class, 'cancelApproval'])->name('cancel-approval'); + Route::post('/{templateCode}/release', [AlimtalkTemplateController::class, 'release'])->name('release'); + }); + }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php new file mode 100644 index 00000000..ef196997 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php @@ -0,0 +1,301 @@ +app->instance(AlimtalkTemplateService::class, $mock); + + return $mock; + } + + /** + * 지정 권한을 가진 admin 사용자를 생성한다. + * + * @param array $permissionIdentifiers + */ + private function adminWith(array $permissionIdentifiers): User + { + $user = User::factory()->create(); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $testRole = Role::create([ + 'identifier' => 'bizppurio_tpl_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + + $permissionIds = []; + foreach ($permissionIdentifiers as $identifier) { + $permissionIds[] = Permission::firstOrCreate( + ['identifier' => $identifier], + ['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin'] + )->id; + } + $testRole->permissions()->sync($permissionIds); + + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } + + /** + * @param array $permissions + * @return array + */ + private function authHeaders(array $permissions): array + { + $user = $this->adminWith($permissions); + $token = $user->createToken('test-token')->plainTextToken; + + return [ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json', + ]; + } + + /** + * @scenario auth=view,service=ok + * + * @effects list_returns_templates_with_status_badge + */ + public function test_view권한으로_목록을_조회한다(): void + { + $this->mockService()->shouldReceive('list')->once()->andReturn([ + 'templates' => [ + ['templateCode' => 'TW_1', 'templateName' => '주문완료', 'status_badge' => ['variant' => 'green']], + ], + 'pagination' => ['total' => 1, 'total_page' => 1, 'current_page' => 1, 'per_page' => 20], + ]); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->getJson(self::BASE); + + $response->assertStatus(200); + $response->assertJsonPath('data.templates.0.templateCode', 'TW_1'); + $response->assertJsonPath('data.templates.0.status_badge.variant', 'green'); + } + + /** + * @scenario auth=guest + * + * @effects list_requires_authentication_returns_401 + */ + public function test_비인증은_401(): void + { + $this->getJson(self::BASE)->assertStatus(401); + } + + /** + * @scenario auth=view_only,action=store + * + * @effects store_requires_manage_permission_returns_403 + */ + public function test_view권한만으로_등록시_403(): void + { + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->postJson(self::BASE, [ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + ]); + + $response->assertStatus(403); + } + + /** + * @scenario auth=manage,service=ok,type=NONE + * + * @effects store_creates_template_via_service + */ + public function test_manage권한으로_기본형_등록에_성공한다(): void + { + $this->mockService()->shouldReceive('create')->once()->andReturn(['templateCode' => 'NEW']); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE, [ + 'templateName' => '가입환영', + 'templateContent' => '#{name}님 환영합니다', + 'categoryCode' => '001001', + 'templateEmphasizeType' => 'NONE', + ]); + + $response->assertStatus(200); + $response->assertJsonPath('data.template.templateCode', 'NEW'); + } + + /** + * @scenario auth=manage,service=ok,requestInspection=true + * + * @effects store_with_request_inspection_flag_registers_then_requests_inspection + */ + public function test_등록후검수요청_플래그로_등록과_검수요청을_이어서_수행한다(): void + { + $service = $this->mockService(); + $service->shouldReceive('create')->once()->andReturn(['templateCode' => 'TW_NEW']); + $service->shouldReceive('requestInspection')->once()->with('TW_NEW'); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE, [ + 'templateName' => '가입환영', + 'templateContent' => '#{name}님 환영합니다', + 'categoryCode' => '001001', + 'templateEmphasizeType' => 'NONE', + 'requestInspection' => true, + ]); + + $response->assertStatus(200); + $response->assertJsonPath('data.template.templateCode', 'TW_NEW'); + } + + /** + * @scenario auth=manage,type=TEXT,missing=title + * + * @effects store_validates_text_type_requires_title_returns_422 + */ + public function test_강조표기형에_강조문구_누락시_422(): void + { + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE, [ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'TEXT', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['templateTitle', 'templateSubtitle']); + } + + /** + * @scenario auth=manage,type=IMAGE,missing=image + * + * @effects store_validates_image_type_requires_image_fields_returns_422 + */ + public function test_이미지형에_이미지필드_누락시_422(): void + { + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE, [ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'IMAGE', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['templateImageName', 'templateImageUrl']); + } + + /** + * @scenario auth=manage,service=throws + * + * @effects kapi_failure_is_surfaced_as_422_with_result_code + */ + public function test_kapi_실패는_422로_전파된다(): void + { + $this->mockService()->shouldReceive('create')->once() + ->andThrow(new BizppurioApiException('템플릿 불일치', resultCode: '7204')); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE, [ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('errors.result_code', '7204'); + $response->assertJsonPath('errors.kakao_message', '템플릿 불일치'); + } + + /** + * @scenario auth=manage,action=stop + * + * @effects stop_action_delegates_to_service_stop + */ + public function test_중지_상태변경에_성공한다(): void + { + $this->mockService()->shouldReceive('stop')->once()->with('TW_1'); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE.'/TW_1/stop'); + + $response->assertStatus(200); + } + + /** + * @scenario auth=manage,action=upload_image + * + * @effects image_upload_returns_kakao_url + */ + public function test_이미지_업로드가_카카오_ur_l을_반환한다(): void + { + $this->mockService()->shouldReceive('uploadImage')->once() + ->andReturn('https://mud-kage.kakao.com/dn/abc/banner.jpg'); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->post(self::BASE.'/image', [ + 'image' => UploadedFile::fake()->image('banner.jpg', 600, 400), + ], ['Accept' => 'application/json']); + + $response->assertStatus(200); + $response->assertJsonPath('data.image_url', 'https://mud-kage.kakao.com/dn/abc/banner.jpg'); + } + + /** + * @scenario auth=manage,action=upload_image,invalid=type + * + * @effects image_upload_rejects_non_image_returns_422 + */ + public function test_이미지가_아닌_파일_업로드는_422다(): void + { + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->post(self::BASE.'/image', [ + 'image' => UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf'), + ], ['Accept' => 'application/json']); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['image']); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php index 3798f8f0..ef9e92c9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php @@ -12,8 +12,8 @@ use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** * 비즈뿌리오 메시징 플러그인 설치 스모크 테스트. * - * plugin.php 가 선언하는 권한/메뉴/알림 정의/설정 스키마 구조와 - * activate/deactivate 의 메뉴 동기화·정리 동작을 검증한다. + * plugin.php 가 선언하는 권한/알림 정의/설정 스키마 구조와 + * 관리자 메뉴 미생성(⚑ 결정 3) 을 검증한다. */ class InstallationTest extends PluginTestCase { @@ -109,35 +109,28 @@ class InstallationTest extends PluginTestCase } } - public function test_activate_시_관리자_메뉴가_계층으로_생성된다(): void + /** + * 관리자 메뉴를 추가하지 않는다 (화면 배치 결정 2026-07-14, ⚑ 결정 3). + * + * 플러그인 메뉴 추가는 정식 기능이 아니므로 getAdminMenus()·메뉴 sync 를 + * 폐기했다. 진입은 코어 소유 설정 페이지(/admin/plugins/{id}/settings) 하나로 + * 통일한다. 활성화해도 플러그인 소속 메뉴 row 가 생성되지 않아야 한다. + */ + 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(), - '비활성화 시 플러그인 소속 메뉴가 전부 제거되어야 한다.' + '메뉴 폐기 결정에 따라 플러그인 소속 메뉴가 생성되면 안 된다.' + ); + + $this->assertNull( + Menu::where('slug', 'sirsoft-message_bizppurio') + ->where('extension_type', ExtensionOwnerType::Plugin->value) + ->first(), + '최상위 메시지 발송 관리 메뉴가 남아 있으면 안 된다.' ); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php new file mode 100644 index 00000000..4ef4e27e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php @@ -0,0 +1,274 @@ + $settings + */ + private function service(array $settings = ['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40']): AlimtalkTemplateService + { + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); + + $kakao = new BizppurioKakaoApiClient($pluginSettings); + + return new AlimtalkTemplateService($kakao, $pluginSettings); + } + + public function test_목록은_상태_배지와_가능액션을_부가한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'totalCount' => 1, + 'totalPage' => 1, + 'currentPage' => 1, + 'data' => ['list' => [ + ['templateCode' => 'TW_1', 'templateName' => '주문완료', 'serviceStatus' => 'ACT'], + ]], + ], 200), + ]); + + $result = $this->service()->list(['status' => 'ACT']); + + $this->assertCount(1, $result['templates']); + $tpl = $result['templates'][0]; + $this->assertSame('ACT', $tpl['service_status']); + $this->assertSame('green', $tpl['status_badge']['variant']); + $this->assertEqualsCanonicalizing(['stop', 'cancel_approval'], $tpl['available_actions']); + $this->assertSame(1, $result['pagination']['total']); + } + + public function test_목록은_status_keyword를_kapi에_전달한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['list' => []]], 200)]); + + $this->service()->list(['status' => 'REQ', 'keyword' => '주문', 'page' => 2, 'count' => 10]); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/list') + && $request['templateStatus'] === 'REQ' + && $request['keyword'] === '주문' + && $request['page'] === 2 + && $request['count'] === 10 + && $request['senderKey'] === 'SK_40'; + }); + } + + public function test_상세는_status_inspection으로_배지를_추론한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'data' => [ + 'templateCode' => 'TW_2', + 'inspectionStatus' => 'APR', + 'status' => 'A', + 'block' => false, + 'dormant' => false, + ], + ], 200), + ]); + + $detail = $this->service()->detail('TW_2'); + + // inspection=APR + status=A → ACT(정상) + $this->assertSame('ACT', $detail['service_status']); + $this->assertSame('green', $detail['status_badge']['variant']); + } + + public function test_등록은_부가정보시_message_type_e_x로_계산한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['templateCode' => 'NEW']], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + 'templateExtra' => '부가정보', + ]); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/add') + && $request['templateMessageType'] === 'EX' + && $request['templateExtra'] === '부가정보' + && $request['senderKey'] === 'SK_40'; + }); + } + + public function test_등록은_채널추가버튼시_message_type_a_d로_계산한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + 'buttons' => [['name' => '채널추가', 'linkType' => 'AC']], + ]); + + Http::assertSent(fn ($request) => $request['templateMessageType'] === 'AD'); + } + + public function test_등록은_부가정보와_채널추가_둘다면_m_i로_계산한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + 'templateExtra' => '부가', + 'buttons' => [['name' => '채널추가', 'linkType' => 'AC']], + ]); + + Http::assertSent(fn ($request) => $request['templateMessageType'] === 'MI'); + } + + public function test_등록은_옵션없으면_b_a로_계산한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + ]); + + Http::assertSent(fn ($request) => $request['templateMessageType'] === 'BA'); + } + + public function test_강조표기형은_title_subtitle을_전송한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'TEXT', + 'templateTitle' => '강조', + 'templateSubtitle' => '보조', + ]); + + Http::assertSent(fn ($request) => $request['templateEmphasizeType'] === 'TEXT' + && $request['templateTitle'] === '강조' + && $request['templateSubtitle'] === '보조'); + } + + public function test_버튼과_바로연결과_대표링크를_전송한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + 'buttons' => [['name' => '웹링크', 'linkType' => 'WL', 'linkMo' => 'https://m.example.com']], + 'quickReplies' => [['name' => '바로가기', 'linkType' => 'WL']], + 'templateRepresentLink' => ['linkPc' => 'https://example.com', 'linkMo' => ''], + ]); + + Http::assertSent(function ($request) { + return $request['buttons'][0]['linkType'] === 'WL' + && $request['quickReplies'][0]['name'] === '바로가기' + // 빈 링크 필드(linkMo='')는 제거되고 linkPc 만 포함 + && $request['templateRepresentLink'] === ['linkPc' => 'https://example.com']; + }); + } + + public function test_이미지_업로드는_카카오_이미지_ur_l을_반환한다(): void + { + Http::fake([ + 'kapi.ppurio.com/v3/kakao/image/*' => Http::response([ + 'code' => '200', + 'image' => 'https://mud-kage.kakao.com/dn/abc/image.jpg', + ], 200), + ]); + + $path = tempnam(sys_get_temp_dir(), 'img'); + file_put_contents($path, 'fake-image-bytes'); + + $url = $this->service()->uploadImage($path, 'banner.jpg'); + + @unlink($path); + + $this->assertSame('https://mud-kage.kakao.com/dn/abc/image.jpg', $url); + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/image/alimtalk/template')); + } + + public function test_검수요청은_템플릿코드와_comment를_전송한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->service()->requestInspection('TW_1', '검토 부탁드립니다'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/request') + && $request['templateCode'] === 'TW_1' + && $request['comment'] === '검토 부탁드립니다'); + } + + public function test_중지_상태변경은_stop_엔드포인트를_호출한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->service()->stop('TW_1'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/stop') + && $request['templateCode'] === 'TW_1'); + } + + public function test_발신프로필_키_미설정시_예외(): void + { + $this->expectException(BizppurioApiException::class); + + $this->service(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => '']) + ->list(); + } + + public function test_kapi_실패코드시_예외에_결과코드가_담긴다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '7204', 'message' => '템플릿 불일치'], 200), + ]); + + try { + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + ]); + $this->fail('예외가 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame('7204', $e->getResultCode()); + $this->assertStringContainsString('템플릿 불일치', $e->getMessage()); + } + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php index 973b0dc1..347fa5c0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php @@ -89,6 +89,33 @@ class BizppurioKakaoApiClientTest extends PluginTestCase && $request['templateName'] === 'T'); } + public function test_이미지_업로드는_multipart로_전송하고_ur_l을_받는다(): void + { + Http::fake([ + 'kapi.ppurio.com/v3/kakao/image/*' => Http::response([ + 'code' => '200', + 'image' => 'https://mud-kage.kakao.com/dn/xyz/img.jpg', + ], 200), + ]); + + $path = tempnam(sys_get_temp_dir(), 'img'); + file_put_contents($path, 'bytes'); + + $result = $this->client()->uploadTemplateImage($path, 'banner.jpg'); + + @unlink($path); + + $this->assertSame('https://mud-kage.kakao.com/dn/xyz/img.jpg', $result['image']); + Http::assertSent(function ($request) { + // multipart 요청은 배열 접근($request['key'])이 불가하므로 URL·메서드·multipart 여부로 검증 + $bodyData = collect($request->data())->keyBy('name'); + + return str_contains($request->url(), '/v3/kakao/image/alimtalk/template') + && $request->isMultipart() + && ($bodyData['bizId']['contents'] ?? null) === 'biz01'; + }); + } + public function test_자격증명_미설정시_예외(): void { $client = new BizppurioKakaoApiClient( diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml new file mode 100644 index 00000000..524549dc --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml @@ -0,0 +1,104 @@ +# audit:allow test-scenario-coverage reason: | +# 알림톡 템플릿 관리 화면은 설정 페이지(plugin_settings.json)의 탭으로 배치되며(⚑ 결정 2·3, +# 독립 페이지·메뉴 없음), kapi 실시간 위임이라 DB 저장이 없다. axes cross product 의 UI 구조 +# (탭 전환·목록/폼 서브뷰·유형별 필드·변수삽입·상세 모달·i18n) effects 는 Vitest +# (alimtalk_templates.test.tsx) 정적 검증으로 가드된다. 권한 경계(view/manage)·유형별 +# 조건부 검증(422)·kapi 실패 전파(422)·상태변경 위임은 PHPUnit Feature +# (AlimtalkTemplateControllerTest) 로, messageType 자동계산·상태 배지/액션 매핑·kapi 파라미터 +# 전달은 Unit(AlimtalkTemplateServiceTest) 로 Http::fake 왕복 관찰한다. 런타임 Playwright spec 은 +# 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 카카오 관리 API 자격증명 필요) 단계에서 최초 +# 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을 추가한다. + +feature: 알림톡 템플릿 관리 (Phase 5) + +description: | + 설정 페이지 알림톡 템플릿 탭에서 카카오 관리 API(kapi.ppurio.com)로 알림톡 템플릿을 + 실시간 조회·등록·수정·삭제·검수·상태변경한다. DB 저장 없이 매 요청 kapi 위임이며, 저장은 + 하지 않는다(연동 binding 은 Phase 6). 진입은 코어 소유 설정 페이지 하나로 통일(메뉴 없음). + + 핵심 동작: + - 탭 네비게이션: 환경설정 ↔ 알림톡 템플릿 (activeSettingsTab, 배타 전환) + - 목록 서브뷰: 상태 필터(templateStatus) + 검색(keyword) + 상태 배지(serviceStatus) + 상세 + - 폼 서브뷰(목록↔폼 전환): 유형별 필드(NONE/IMAGE/TEXT, ITEM_LIST 제외) + 변수 클릭삽입 + - templateMessageType(BA/EX/AD/MI)은 부가정보·채널추가(AC) 버튼 유무로 백엔드 자동 계산 + - 권한: 조회=messaging.view / 변경=messaging.manage + - kapi 실패(반려·차단·상태오류)는 그대로 422 로 전파(운영자가 사유 즉시 확인) + - readiness: api_key·sender_key 미설정 시 안내 + [설정하기] 탭 이동 + +axes: + auth: [view, manage, guest] + emphasize_type: [NONE, IMAGE, TEXT] + message_type_driver: [none, extra, add_channel, both] + save_mode: [register, register_and_request] + kapi_result: [ok, fail] + +exclusions: + - { auth: guest, emphasize_type: IMAGE, reason: "비인증은 유형 무관 401 — 유형 axis 무의미" } + - { auth: view, kapi_result: fail, reason: "view 는 변경 액션 불가(403) — kapi 도달 전 차단" } + +effects: + # 탭 네비게이션 (UI) + - tab_navigation_has_connection_and_templates_tabs + - templates_tab_updates_url_query_and_refetches_list + - tab_panels_toggle_exclusively_by_query_tab + - templates_tab_persists_across_reload_via_url_query + - init_actions_loads_list_when_entering_templates_tab + # 데이터소스 (UI) + - templates_and_categories_datasources_target_admin_api + # 목록 서브뷰 (UI) + - list_view_shown_when_template_view_is_list + - toolbar_has_status_filter_and_keyword_search + - table_card_always_shows_header_and_branches_tbody_by_count + - table_iterates_templates_with_status_badge + - new_template_button_switches_to_form_and_resets_form + # 폼 서브뷰 (UI, 계획서 §6-4) + - form_view_shown_when_template_view_is_form + - common_fields_name_and_content_use_manual_value_binding + - template_code_field_auto_or_manual_toggle + - category_two_level_dropdown_from_category_all + - text_type_fields_shown_when_emphasize_type_is_text + - image_type_file_upload_shown_when_emphasize_type_is_image + - type_buttons_expose_only_none_image_text_not_item_list + - variable_buttons_insert_into_content_state_via_insert_variable_handler + - variable_reference_table_always_visible_inline + - optional_six_items_checkbox_expand_extra_preview_button_quickreply_link_security + - optional_no_header_field + - preview_reflects_content_state + - save_split_into_register_and_register_then_request_inspection_with_help_box + # 상세 모달 & readiness (UI) + - detail_modal_defined + - readiness_notice_shown_when_api_key_or_sender_key_missing + # 권한 경계 (백엔드) + - list_requires_view_permission + - guest_list_returns_401 + - store_requires_manage_permission_returns_403_for_view_only + - manage_can_create_basic_template + - register_then_request_inspection_flag_creates_then_requests + # 유형별 검증 (백엔드) + - text_type_missing_title_subtitle_returns_422 + - image_type_missing_image_fields_returns_422 + # messageType 자동계산 (백엔드) + - message_type_none_computes_ba + - message_type_extra_computes_ex + - message_type_add_channel_computes_ad + - message_type_both_computes_mi + # 상태 배지/액션 & kapi 전달 (백엔드) + - service_status_maps_to_badge_and_available_actions + - detail_derives_status_from_inspection_and_status + - list_passes_status_keyword_to_kapi + - text_type_sends_title_and_subtitle + - inspection_request_sends_code_and_comment + - stop_action_calls_kapi_stop_endpoint + # kapi 실패 전파 (백엔드) + - kapi_failure_surfaced_as_422_with_result_code + - sender_key_missing_throws + # 다국어 + - all_referenced_i18n_keys_exist_in_ko_and_en + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php + +audit: + status: codified \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml index 047d746d..02ae94e0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -24,7 +24,8 @@ description: | - 리포트 수신 설정: 발송 결과 수신 주소를 사이트 도메인 기준 절대 URL 로 조회·복사 - 크리덴셜(password/api_key/sender_key)은 type=password 마스킹 + frontend_schema expose:false - 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT - - 설치·활성화 시 권한 2개(view/manage)·메뉴 3개·잔액부족 알림 정의 생성 + 설정 초기값 로드 + - 설치·활성화 시 권한 2개(view/manage)·잔액부족 알림 정의 생성 + 설정 초기값 로드 + (관리자 메뉴는 추가하지 않음 — ⚑ 결정 3, 진입은 코어 소유 설정 페이지 하나) ※ 리포트 실제 수신 처리(POST /webhook)·알림톡 채널 기본 body 시드는 Phase 4~6 으로 이관. @@ -65,8 +66,7 @@ effects: - all_referenced_i18n_keys_exist_in_ko_and_en # 설치·활성화 (백엔드) - install_creates_view_and_manage_permissions - - activate_creates_hierarchical_admin_menus - - deactivate_removes_plugin_menus + - activate_does_not_create_admin_menus # ⚑ 결정 3: 플러그인 메뉴 폐기, 진입은 설정 페이지 하나로 통일 - balance_low_notification_targets_admin - settings_defaults_load_with_test_mode_on diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts new file mode 100644 index 00000000..e9023699 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from 'vite'; +import path from 'path'; + +/** + * 비즈뿌리오 메시징 플러그인 프론트엔드 에셋 빌드 설정. + * + * 커스텀 핸들러(insertVariable) 를 IIFE 번들로 빌드해 플러그인 활성화 시 로드한다. + * 출력물은 dist/js/plugin.iife.js 이며 plugin.json assets.js.output 과 일치한다. + */ +export default defineConfig({ + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + + build: { + lib: { + entry: path.resolve(__dirname, 'resources/js/index.ts'), + name: 'SirsoftMessageBizppurio', + fileName: 'plugin', + formats: ['iife'], + }, + outDir: 'dist', + emptyOutDir: true, + sourcemap: true, + rollupOptions: { + output: { + entryFileNames: 'js/plugin.iife.js', + chunkFileNames: 'js/[name]-[hash].js', + }, + }, + minify: 'esbuild', + target: 'es2020', + chunkSizeWarningLimit: 500, + }, + + resolve: { + alias: { + '@': path.resolve(__dirname, 'resources/js'), + }, + }, +});