diff --git a/AGENTS.md b/AGENTS.md index 3b25388d..45fae97e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,7 @@ | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 | | `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) | 7 / 13 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 21 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 | | `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 | | `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 | diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md index 57ad5f67..1dc7a8a9 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md @@ -8,4 +8,4 @@ ### Added -- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 요청 과다로 인한 일시적 발송 실패 사유, 미승인 템플릿 연결 시도 시 안내 문구도 포함됩니다. +- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 작성/검수 신청/승인 관리·템플릿 통합 관리·발송 이력 화면과 발송 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 템플릿 작성 시 입력값 오류 안내도 항목 이름까지 일본어로 표시됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php index 417251d4..db1bf3d7 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php @@ -6,6 +6,10 @@ return [ 'lms' => 'LMS', 'alimtalk' => 'アラートトーク', ], + 'channel_group' => [ + 'text' => '文字', + 'alimtalk' => '通知トーク', + ], 'status' => [ 'pending' => '待機中', 'sent' => '送信中', @@ -26,11 +30,11 @@ return [ 'channels' => [ 'source_label' => 'ビズプリオ', 'sms' => [ - 'name' => 'SMS/LMSテキスト', + 'name' => 'ビズプリオ 文字', 'description' => 'ビズプリオを通じてテキスト(SMS/LMS)で通知を送信します。', ], 'alimtalk' => [ - 'name' => 'カカオアラートトーク', + 'name' => 'ビズプリオ アラートトーク', 'description' => 'ビズプリオを通じてカカオアラートトークで通知を送信します。', ], ], @@ -51,33 +55,102 @@ return [ 'error' => [ 'credentials_missing' => 'ビズプリオのアイディとパスワードを先に設定してください。', 'token_issue_failed' => 'ビズプリオ認証トークン発行に失敗しました。', + 'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)', 'send_failed' => 'メッセージ送信リクエストに失敗しました。', 'send_retryable' => 'メッセージ送信が一時的に失敗しました。(コード: :code)', 'invalid_response' => 'ビズプリオレスポンスを解析できません。', + 'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。', 'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。', 'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。', 'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。', - 'template_not_sendable' => '送信可能(承認)ステータスではないテンプレートです。(コード: :code)', - 'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)', - 'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。', + 'image_unreadable' => 'アップロードした画像ファイルを読み込めません。', ], 'send_skipped' => [ - 'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)', - 'alimtalk_kakao_content_unavailable' => 'カカオ承認テンプレート内容を照会できないため送信をスキップしました。(通知タイプ: :type)', - 'sms_template_missing' => 'SMSテンプレートがないため送信をスキップしました。(通知タイプ: :type)', + 'alimtalk_template_not_approved' => '承認されたアラートトークテンプレートがないか、アラートトーク送信がオフになっているため送信をスキップしました。(通知タイプ: :type)', + 'sms_template_missing' => 'SMS本文が設定されていないため送信をスキップしました。(通知タイプ: :type)', 'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)', 'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)', ], - 'binding' => [ - 'saved' => 'アラートトーク連携を保存しました。', - 'removed' => 'アラートトーク連携を解除しました。', + 'template' => [ + 'created' => '通知テンプレートを保存しました。', + 'updated' => '通知テンプレートを更新しました。', + 'requested' => '審査を申請しました。承認結果は自動的に同期され、[更新]ですぐに確認できます。', + 'request_cancelled' => '審査申請を取り消しました。', + 'approval_cancelled' => '承認を取り消しました。この通知のアラートトーク送信が停止されました。', + 'released' => '休眠状態を解除しました。', + 'synced' => 'カカオ審査ステータスを同期しました。', + 'deleted' => '通知テンプレートを削除しました。', + 'image_uploaded' => '画像をアップロードしました。', + 'content_locked' => '現在のステータス(:status)ではアラートトークの内容を修正できません。審査中の場合は申請の取り消しを、承認済みの場合は承認の取り消しを先に行ってください。', + 'content_missing' => 'アラートトークテンプレートの内容を先に作成してください。', + 'request_not_allowed' => '現在のステータス(:status)では審査を申請できません。', + 'cancel_request_not_allowed' => '審査中ステータスではないため、申請を取り消せません。(現在: :status)', + 'cancel_approval_not_allowed' => '承認ステータスではないため、承認を取り消せません。(現在: :status)', + 'release_not_allowed' => '休眠ステータスではないため、解除できません。(現在: :status)', + 'code_generation_failed' => 'テンプレートコードを採番できませんでした。しばらくしてからもう一度お試しください。', ], - 'cache' => [ - 'cleared' => 'アラートトークテンプレート内容キャッシュを初期化しました。次回送信から最新内容が反映されます。', - ], - 'channel_group' => [ - 'text' => '文字', - 'alimtalk' => '通知トーク', + 'validation' => [ + 'link_mo_required' => 'ウェブリンク(WL)ボタンにはモバイルリンクが必要です。', + 'link_and_required' => 'アプリリンク(AL)ボタンにはAndroidスキームが必要です。', + 'link_ios_required' => 'アプリリンク(AL)ボタンにはiOSスキームが必要です。', + 'tel_number_required' => '電話(TN)ボタンには電話番号が必要です。', + 'plugin_id_required' => 'プラグイン(P1~P3)ボタンにはプラグインIDが必要です。', + 'highlight_title_too_long' => 'アイテムハイライトのタイトルは:max文字以内である必要があります。', + 'highlight_description_too_long' => 'アイテムハイライトの説明は:max文字以内である必要があります。', + 'image_ratio_invalid' => '画像は横:縦 2:1 の比率である必要があります。(例: 1000×500px)', + + // 検証エラー文に使うフィールドラベル (FormRequest::attributes) + 'attributes' => [ + 'notification_type' => '通知タイプ', + 'content' => 'アラートトーク内容', + 'template_name' => 'テンプレート名', + 'message_type' => 'メッセージタイプ', + 'emphasize_type' => '強調タイプ', + 'template_content' => '本文', + 'preview_message' => 'プレビュー文言', + 'category_code' => 'カテゴリ', + 'security_flag' => 'セキュリティテンプレート', + 'extra' => '付加情報', + 'title' => '強調タイトル', + 'subtitle' => '強調サブタイトル', + 'header' => 'ヘッダー', + 'image_name' => '画像ファイル名', + 'image_url' => '画像URL', + 'item' => 'アイテムリスト', + 'item_list' => 'アイテム一覧', + 'item_title' => 'アイテム名', + 'item_description' => 'アイテム説明', + 'summary' => '要約', + 'summary_title' => '要約タイトル', + 'summary_description' => '要約説明', + 'highlight' => 'ハイライト', + 'highlight_title' => 'ハイライトタイトル', + 'highlight_description' => 'ハイライト説明', + 'highlight_image_url' => 'ハイライト画像URL', + 'represent_link' => '代表リンク', + 'buttons' => 'ボタン', + 'button_name' => 'ボタン名', + 'button_link_type' => 'ボタンリンクタイプ', + 'quick_replies' => 'クイックリプライ', + 'quick_reply_name' => 'クイックリプライ名', + 'quick_reply_link_type' => 'クイックリプライリンクタイプ', + 'link_mo' => 'モバイルリンク', + 'link_pc' => 'PCリンク', + 'link_and' => 'Androidスキーム', + 'link_ios' => 'iOSスキーム', + 'tel_number' => '電話番号', + 'plugin_id' => 'プラグインID', + 'image' => '画像ファイル', + 'sms_body' => 'SMS本文', + 'alimtalk_enabled' => 'アラートトーク利用', + 'fallback_sms_enabled' => '代替SMS利用', + 'sms_only' => 'SMS単独送信', + 'is_active' => '有効', + 'status' => 'ステータス', + 'search' => '検索キーワード', + 'page' => 'ページ', + 'per_page' => '1ページあたりの件数', + ], ], 'token_check' => [ 'success' => '認証が正常に確認されました。ユーザーIDとパスワードが正しいです。', diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json index 25e709b3..897c15aa 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json @@ -34,6 +34,15 @@ "label": "ビズプリオモジュールパスワード", "hint": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)" }, + "connection_check": { + "label": "接続確認", + "hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)", + "button": "接続確認", + "checking": "確認中...", + "success": "認証が正常に確認されました。IDとパスワードが正しいです。", + "failed": "認証確認に失敗しました。", + "unsaved_changes": "変更内容を先に保存してください。" + }, "api_key": { "label": "API キー", "hint": "API キーは ビズプリオ カスタマーセンターに ID とともに申し込むと確認後、発行されます。" @@ -45,23 +54,6 @@ "sender_key": { "label": "アラート톡 発信プロファイル キー", "hint": "アラート톡 発送・テンプレート確認に使用する発信プロファイル キー(40文字)です。" - }, - "template_cache_minutes": { - "label": "アラート톡 内容 キャッシュ 時間(分)", - "hint": "カカオ テンプレート内容をこの時間だけ再利用します。0 = 毎回最新(発送が多い場合は非推奨)。", - "clear_cache": "キャッシュ 初期化", - "clear_cache_hint": "カカオでテンプレートを修正した場合は押して即座に反映してください。", - "clear_cache_success": "テンプレート内容 キャッシュを初期化しました。次の発送から最新内容が反映されます。", - "clear_cache_failed": "キャッシュ 初期化に失敗しました。" - }, - "connection_check": { - "label": "接続確認", - "hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)", - "button": "接続確認", - "checking": "確認中...", - "success": "認証が正常に確認されました。IDとパスワードが正しいです。", - "failed": "認証確認に失敗しました。", - "unsaved_changes": "変更内容を先に保存してください。" } }, "report": { @@ -71,13 +63,9 @@ "copy": "コピー", "copied": "レポート受信アドレスがクリップボードにコピーされました。" }, - "cache": { - "section_title": "アラート톡 発送内容 キャッシュ", - "section_description": "アラート톡は発送する際、カカオに登録されたテンプレート内容(本文・ボタン)をそのまま送信する必要があります。発送するたびにカカオに内容をリクエストしないよう、一度取得した内容を一定時間再利用(キャッシュ)します。これにより、発送が多くてもカカオ確認の制限に引っかからず、素早く発送されます。" - }, "tabs": { "connection": "環境設定", - "templates": "アラート톡 テンプレート" + "templates": "通知テンプレート管理" }, "preparation": { "intro": "文字・カカオ アラート톡を発送するには、まず ビズプリオ コンソールで以下の事前準備を完了する必要があります。", @@ -85,31 +73,16 @@ "sms_sender": "発信番号を登録してください。", "kakao_label": "カカオ アラート톡", "kakao_channel": "카카오톡 ビジネスチャネルを作成し、発信プロファイルを登録してください。", - "kakao_template": "アラート톡 テンプレートを登録して承認を受けてください。", + "kakao_template": "アラームトークテンプレートは管理者の通知設定(ビズプリオタブ)で作成して審査を申請します。", "kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。", "console_link": "ビズプリオ コンソールを開く" } }, + "channels": { + "bizppurio_tab": "ビズプリオ" + }, "binding": { - "section_title": "カカオ アラート톡 連動", - "list_guide": "各 通知に発送するカカオ アラート톡 テンプレートを接続してください。[接続]で承認されたテンプレートを指定すると、該当イベント発生時にアラート톡が自動発送されます。", - "section_hint": "承認されたアラート톡 テンプレートのみ接続できます。保存するとこの 通知に即座に反映されます。", - "modal_title": "アラート톡 接続 · {name}", - "unbound": "未接続", - "unavailable": "使用不可 — 再接続が必要", - "btn_connect": "接続", - "btn_change": "接続を変更", - "fallback_on": "SMS 代替 ON", - "fallback_off": "SMS 代替 OFF", - "connected_template": "接続テンプレート", - "none": "接続しない", - "no_approved_templates": "発送可能な(承認された)アラート톡 テンプレートがありません。まずテンプレートを登録・検査してください。", - "templates_load_failed": "テンプレート リストを読み込みできませんでした。プラグイン 設定(認証情報)を確認してください。", - "fallback_sms": "失敗時 SMS で代替発送", - "fallback_hint": "アラート톡 発送が失敗すると、この 通知の本文内容が文字(SMS)で代わりに発送されます。", - "variables_hint": "提供変数(発送時に自動置換)", - "saved": "アラート톡 連動を保存しました。", - "save_error": "アラート톡 連動 保存に失敗しました。" + "variables_hint": "提供変数(クリックすると本文のカーソル位置に挿入されます)" }, "banner": { "not_ready": "発送に必要な 設定が完了していません。", @@ -124,70 +97,174 @@ "detail_title": "文字・アラート톡 発送 結果", "channel_label": "発送チャネル: {channel}", "sent_content_label": "実際の発送内容", - "sent_content_hint": "アラート톡 はカカオ承認テンプレートの実際の内容で発送され、上記の\"本文\"と異なる場合があります。" + "sent_content_hint": "アラート톡 は承認確定されたテンプレート内容で発送され、上記の\"本文\"と異なる場合があります。" }, "editor": { "data_source": { "dispatch_results": "ビズプリオ送信結果" } }, + "template": { + "tab_guide": "通知ごとにアラームトークテンプレートを作成して審査を申請してください。カカオ承認後にアラームトークが自動送信され、代替SMS・SMS単独送信もこの画面で設定します。", + "status": { + "unwritten": "未作成", + "draft": "作成中", + "requested": "審査中", + "approved": "承認済み", + "rejected": "却下", + "stopped": "停止", + "blocked": "ブロック", + "dormant": "休眠" + }, + "row": { + "alimtalk_label": "アラームトーク", + "requested_on": "{date} 申請", + "synced_on": "{date} 同期", + "alimtalk_enabled": "送信を使用", + "btn_view_reason": "理由を表示", + "btn_compose": "アラームトークテンプレート作成", + "btn_edit": "編集", + "btn_edit_approved": "編集 (承認取り消し)", + "btn_request": "審査申請", + "btn_cancel_request": "申請取り消し", + "btn_release": "休眠解除", + "btn_refresh": "更新", + "requested_toast": "審査を申請しました。", + "cancel_request_toast": "審査申請を取り消しました。", + "released_toast": "休眠状態を解除しました。", + "synced_toast": "カカオ審査ステータスを同期しました。", + "fallback_sms": "代替SMS", + "sms_only": "SMS単独", + "sms_body_prefix": "本文:", + "sms_body_missing": "SMS本文未設定", + "btn_edit_sms": "SMS本文を編集" + }, + "form": { + "modal_title": "アラームトークテンプレート — {name}", + "rejected_banner": "却下されたテンプレートです。以下の理由を確認し、修正後に再度申請してください。", + "sender_profile": "発信プロフィール", + "sender_profile_placeholder": "発信プロフィール選択(参考用)", + "sender_profile_hint": "審査申請は環境設定に保存された発信プロフィールキーで行われます。", + "category": "カテゴリ", + "category_placeholder": "カテゴリ選択", + "name": "テンプレート名", + "message_type": "メッセージ種別", + "templateMessageType_options": { + "BA": "基本型", + "EX": "付加情報型", + "AD": "チャネル追加型", + "MI": "複合型" + }, + "emphasize_type": "強調種別", + "templateEmphasizeType_options": { + "NONE": "なし", + "TEXT": "強調表記", + "IMAGE": "画像", + "ITEM_LIST": "アイテムリスト" + }, + "emphasize_title": "強調表記タイトル", + "emphasize_subtitle": "強調表記サブタイトル", + "image": "画像", + "image_hint": "jpg/png · 500KB以下 · 横500px以上 · 横:縦 2:1 (アップロードするとURLが自動入力されます)", + "image_uploading": "画像をアップロード中...", + "image_upload_failed": "画像のアップロードに失敗しました。", + "content": "本文", + "extra": "付加情報", + "header": "ヘッダー", + "header_hint": "本文上部に表示される最大16文字の文言(任意)", + "buttons": "ボタン (最大5個)", + "button_add": "ボタン追加", + "button_name": "ボタン名 (最大14文字)", + "quick_replies": "クイックリンク (最大10個)", + "quick_reply_add": "クイックリンク追加", + "link_mo": "モバイルリンク (必須)", + "link_pc": "PCリンク (任意)", + "link_and": "Androidスキーム (必須)", + "link_ios": "iOSスキーム (必須)", + "tel_number": "電話番号 (必須)", + "plugin_id": "プラグインID (必須)", + "item_highlight": "アイテムハイライト (任意)", + "hl_title": "ハイライトタイトル", + "hl_description": "ハイライト説明", + "hl_image_url": "サムネイル画像URL (任意)", + "item_list": "アイテム一覧 (2~10個)", + "item_list_hint": "アイテムは最小2個、最大10個です。(タイトル6文字・説明23文字以内)", + "item_add": "アイテム追加", + "item_title": "タイトル", + "item_description": "説明", + "item_summary": "要約(価格情報)", + "summary_description": "要約値 (14文字以内)", + "btn_remove_item": "アイテム削除", + "btn_remove_button": "ボタン削除", + "btn_remove_quick_reply": "クイックリプライ削除", + "btn_save": "保存", + "btn_save_request": "保存して審査申請", + "saved_toast": "通知テンプレートを保存しました。", + "requested_toast": "審査を申請しました。承認されるとアラームトーク送信が開始されます。" + }, + "sms": { + "modal_title": "SMS本文 — {name}", + "hint": "代替SMSとSMS単独送信に共通で使用する本文です。#{변수} は送信時に自動置換されます。", + "btn_save": "保存", + "saved_toast": "SMS本文を保存しました。" + }, + "cancel_approval": { + "modal_title": "承認取り消しの確認", + "warning": "「{name}」テンプレートの承認を取り消すと、直ちにこの通知のアラームトーク送信が停止されます。(代替SMS・SMS単独送信はSMS設定に従って継続されます。)修正後に再度審査を申請して承認を受けると、アラームトーク送信が再開されます。", + "btn_confirm": "承認を取り消して編集", + "done_toast": "承認を取り消しました。修正後に再度審査を申請してください。" + }, + "rejection": { + "modal_title": "却下理由 — {name}", + "btn_close": "閉じる" + } + }, + "manage": { + "guide": "すべての通知のビズプリオテンプレート状態を一目で管理します。作成・審査申請は通知設定画面と同じように動作します。", + "filter_all": "すべてのステータス", + "search_placeholder": "通知名/タイプ検索", + "btn_search": "検索", + "total_count": "合計 {count}件", + "btn_compose": "作成", + "btn_edit": "編集", + "btn_delete": "削除", + "empty": "登録されたビズプリオ通知テンプレートがありません。", + "empty_hint": "管理者の通知設定のビズプリオタブでアラームトークテンプレートを作成するか、SMSを設定するとここに表示されます。", + "sms_only": "単独", + "sms_fallback": "代替", + "owner": { + "core": "コア", + "module": "モジュール", + "plugin": "プラグイン" + }, + "columns": { + "notification": "通知", + "owner": "所属", + "status": "アラームトークステータス", + "sms": "SMS", + "requested_at": "申請日", + "synced_at": "同期", + "actions": "管理" + }, + "delete": { + "modal_title": "テンプレート削除", + "warning": "「{name}」のビズプリオテンプレート設定を削除します。カカオに登録されたテンプレートは削除可能なステータス(登録/却下)の場合のみ一緒に削除されます。", + "btn_confirm": "削除", + "done_with_kakao": "テンプレートを削除しました。(カカオ側テンプレートを含む)", + "done_db_only": "テンプレート設定を削除しました。カカオ側テンプレートはステータス制約により残っています。" + } + }, "templates": { - "title": "アラームトークテンプレート", - "description": "カカオアラームトークテンプレートを登録·審査·管理します。承認されたテンプレートのみアラーム連動に使用できます。", "readiness": { "title": "アラームトークテンプレートを使用するには、以下の設定が必要です", "go_settings": "環境設定に移動", "missing_label": "未設定項目", "api_key_missing": "カカオ管理API キー", "sender_key_missing": "アラームトーク送信プロフィールキー", - "note": "上記項目を環境設定タブで入力すると、テンプレート照会·登録が可能になります。実際の送信は審査モードをオフにして運用に切り替えた後、カカオ承認を受けたテンプレートのみ使用できます。" - }, - "list_error": { - "title": "テンプレート一覧を読み込めませんでした" - }, - "list_notice": { - "console_desc": "テンプレートの登録·編集·審査はビズプリオコンソールで進めてください。", - "console_link": "ビズプリオコンソールを開く" + "note": "上記項目を環境設定タブで入力すると、テンプレートの作成・審査申請が可能になります。実際の送信は検査モードをオフにして運用に切り替えた後、承認されたテンプレートのみ使用できます。" }, "list": { - "refresh": "更新", - "search": "検索", - "search_placeholder": "テンプレート名検索(2~50文字)", - "filter_all": "すべてのステータス", - "empty": "登録されたアラームトークテンプレートがありません。", - "empty_hint": "ビズプリオコンソールで登録したテンプレートがここに表示されます。", - "load_failed": "テンプレート一覧を読み込めませんでした。送信プロフィールキーとAPI キーを確認してください。", - "columns": { - "no": "番号", - "name": "テンプレート名", - "code": "コード", - "status": "ステータス", - "requested_at": "登録申請日", - "processed_at": "処理日", - "actions": "内容" - } - }, - "status": { - "sendable": "送信可能", - "inspecting": "審査中", - "rejected": "却下", - "uninspected": "未審査", - "stopped": "停止", - "blocked": "ブロック", - "dormant": "休止", - "unknown": "不明" - }, - "status_sub": { - "rdy": "(使用前)" - }, - "status_guide": { - "title": "ステータスバッジ", - "sendable_label": "送信可能", - "sendable": "承認完了。このステータスのみアラームに接続·送信できます。", - "inspecting_label": "審査中", - "inspecting": "カカオ審査進行中(営業日2~3日)。", - "pending_label": "未審査·却下", - "pending": "まだ送信できません。コンソールで審査申請·修正してください。" + "refresh": "更新" }, "link_type": { "WL": "ウェブリンク", @@ -203,25 +280,6 @@ "P1": "画像セキュア送信", "P2": "個人情報利用", "P3": "ワンクリック決済" - }, - "actions": { - "detail": "詳細" - }, - "detail": { - "title": "テンプレート詳細", - "close": "閉じる", - "buttons": "ボタン", - "extra": "付加情報", - "category": "カテゴリ", - "code": "テンプレートコード", - "content": "テンプレート内容", - "emphasize_type": "テンプレート種別", - "image_upload": "画像添付", - "subtitle_field": "補助文句", - "title_field": "強調表記文句", - "type_image": "画像形", - "type_none": "基本形", - "type_text": "強調表記形" } } } diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 0d390800..c44dd02b 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -10,6 +10,11 @@ - 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.) - 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다. +- 게시판 알림 설정 화면에서 비즈뿌리오 알림톡 템플릿을 작성해 검수를 신청하고, 카카오 승인 후 알림톡이 자동 발송됩니다. 대체 SMS·SMS 단독 발송도 같은 화면에서 설정합니다. (비즈뿌리오 메시지 발송 플러그인 설치 시) + +### Changed + +- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다. ## [1.0.5] - 2026-08-22 diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-bizppurio-tab-filter.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-bizppurio-tab-filter.test.ts new file mode 100644 index 00000000..88d04429 --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-bizppurio-tab-filter.test.ts @@ -0,0 +1,158 @@ +// e2e:allow 레이아웃 조건식 실평가 전용 — 브라우저 흐름은 비즈뿌리오 플러그인 spec 이 담당한다. +/** + * 게시판 알림 설정 — 채널 서브탭 필터 조합 실평가 (#597 §14.2 T8) + * + * 이 면의 서브탭 필터는 admin_basic·이커머스와 같은 식을 공유한다. 그 동일성은 + * admin_basic 쪽 패리티 테스트가 고정하지만, 그 테스트는 **admin_basic 템플릿의 + * 러너에서만** 수집된다 — 게시판 모듈만 단독으로 Vitest 를 돌리면 이 면의 필터는 + * 한 번도 평가되지 않는다. 그래서 각 면이 자기 파일을 자기 러너에서 평가한다. + * + * 검증 대상: 확장이 선언한 통합 탭 메타(hidden_tab / tab_channels)를 필터가 실제로 + * 해석하는가. 문자열 동일성 단언은 `!==` 를 `===` 로 바꾸는 오타를 잡지 못한다. + * + * @scenario resource=notification_definitions_tab,endpoint=admin_board_settings,observation=tab_filter + * + * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active + */ + +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +const TAB_LAYOUT = path.resolve( + __dirname, + '../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json', +); + +/** availableChannels 응답 형태 (코어 2 + 비즈뿌리오 2) */ +const CHANNELS = [ + { id: 'mail', source: 'core', name: '메일' }, + { id: 'database', source: 'core', name: '사이트내 알림' }, + { id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true }, + { + id: 'alimtalk', + source: 'sirsoft-message_bizppurio', + name: '비즈뿌리오 알림톡', + tab_channels: ['sms', 'alimtalk'], + tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab', + }, +]; + +/** + * id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다. + * + * @param node 탐색할 JSON 노드 + * @returns 서브탭 컨테이너 노드 (없으면 null) + */ +function findSubTabsContainer(node: unknown): Record | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findSubTabsContainer(child); + if (found) return found; + } + return null; + } + if (!node || typeof node !== 'object') return null; + + const record = node as Record; + if (typeof record.id === 'string' && record.id.endsWith('channel_sub_tabs')) return record; + + for (const value of Object.values(record)) { + const found = findSubTabsContainer(value); + if (found) return found; + } + return null; +} + +/** + * 서브탭 컨테이너 안에서 iteration 을 가진 탭 버튼 노드를 찾습니다. + * + * @param node 탐색할 JSON 노드 + * @returns iteration 보유 노드 (없으면 null) + */ +function findIterationNode(node: unknown): Record | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findIterationNode(child); + if (found) return found; + } + return null; + } + if (!node || typeof node !== 'object') return null; + + const record = node as Record; + if (record.iteration && typeof record.iteration.source === 'string') return record; + + for (const value of Object.values(record)) { + const found = findIterationNode(value); + if (found) return found; + } + return null; +} + +describe('게시판 알림 설정 — 채널 서브탭 필터 실평가 (#597)', () => { + const layout = JSON.parse(fs.readFileSync(TAB_LAYOUT, 'utf-8')); + const container = findSubTabsContainer(layout); + const tabNode = container ? findIterationNode(container) : null; + const expr = String(tabNode?.iteration?.source ?? '').replace(/^\{\{|\}\}$/g, ''); + + /** + * 필터 표현식 원문을 실행해 노출 탭 id 목록을 반환합니다. + * + * @param saved _local.form.notifications.channels 저장값 + * @param channels availableChannels 채널 목록 + * @returns 필터를 통과한 채널 id 배열 + */ + function visibleTabs( + saved: Array<{ id: string; is_active: boolean }>, + channels: Array> = CHANNELS, + ): string[] { + // eslint-disable-next-line no-new-func + const fn = new Function('availableChannels', '_local', `return ${expr};`); + const result = fn( + { data: { channels } }, + { form: { notifications: { channels: saved } } }, + ) as Array<{ id: string }>; + return result.map((c) => c.id); + } + + it('서브탭 필터 표현식을 레이아웃에서 추출할 수 있다', () => { + expect(container, '*channel_sub_tabs 컨테이너를 찾지 못했다').toBeTruthy(); + expect(expr, '탭 iteration source 가 비어 있다').not.toBe(''); + }); + + it('sms·alimtalk 모두 활성 → 통합 탭 1개만 노출, sms 개별 탭은 숨김', () => { + expect(visibleTabs([ + { id: 'sms', is_active: true }, + { id: 'alimtalk', is_active: true }, + ])).toEqual(['mail', 'database', 'alimtalk']); + }); + + it('sms 만 활성(alimtalk 비활성) → tab_channels 규칙으로 통합 탭 노출', () => { + expect(visibleTabs([ + { id: 'sms', is_active: true }, + { id: 'alimtalk', is_active: false }, + ])).toEqual(['mail', 'database', 'alimtalk']); + }); + + it('sms·alimtalk 모두 비활성 → 통합 탭 미노출', () => { + expect(visibleTabs([ + { id: 'sms', is_active: false }, + { id: 'alimtalk', is_active: false }, + ])).toEqual(['mail', 'database']); + }); + + it('확장 채널 저장값 자체가 없으면(미저장=opt-in 전) 통합 탭 미노출', () => { + expect(visibleTabs([])).toEqual(['mail', 'database']); + }); + + it('코어 채널은 저장값이 명시적 false 일 때만 숨는다 (기본 노출)', () => { + expect(visibleTabs([{ id: 'mail', is_active: false }])).toEqual(['database']); + }); + + it('tab_channels 미선언 확장 채널은 자기 자신의 활성 저장 기준으로 판정된다', () => { + const withPlain = [...CHANNELS, { id: 'push', source: 'some-plugin', name: '푸시' }]; + expect(visibleTabs([{ id: 'push', is_active: true }], withPlain)).toEqual(['mail', 'database', 'push']); + expect(visibleTabs([{ id: 'push', is_active: false }], withPlain)).toEqual(['mail', 'database']); + }); +}); diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json index 952537ec..c4dba2a8 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json @@ -140,7 +140,7 @@ "type": "basic", "name": "Button", "iteration": { - "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}", + "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.hidden_tab !== true && (c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0))}}", "item_var": "ch", "index_var": "chIdx" }, @@ -160,7 +160,7 @@ { "type": "basic", "name": "Span", - "text": "{{ch.name ?? ch.id}}" + "text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}" } ], "actions": [ diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 932a7524..639b1aa7 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -9,6 +9,11 @@ ### Added - 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.) +- 이커머스 알림 설정 화면에서 비즈뿌리오 알림톡 템플릿을 작성해 검수를 신청하고, 카카오 승인 후 알림톡이 자동 발송됩니다. 대체 SMS·SMS 단독 발송도 같은 화면에서 설정합니다. (비즈뿌리오 메시지 발송 플러그인 설치 시) + +### Changed + +- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다. ### Fixed diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-bizppurio-tab-filter.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-bizppurio-tab-filter.test.ts new file mode 100644 index 00000000..5184c250 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-bizppurio-tab-filter.test.ts @@ -0,0 +1,158 @@ +// e2e:allow 레이아웃 조건식 실평가 전용 — 브라우저 흐름은 비즈뿌리오 플러그인 spec 이 담당한다. +/** + * 이커머스 알림 설정 — 채널 서브탭 필터 조합 실평가 (#597 §14.2 T8) + * + * 이 면의 서브탭 필터는 admin_basic·게시판와 같은 식을 공유한다. 그 동일성은 + * admin_basic 쪽 패리티 테스트가 고정하지만, 그 테스트는 **admin_basic 템플릿의 + * 러너에서만** 수집된다 — 이커머스 모듈만 단독으로 Vitest 를 돌리면 이 면의 필터는 + * 한 번도 평가되지 않는다. 그래서 각 면이 자기 파일을 자기 러너에서 평가한다. + * + * 검증 대상: 확장이 선언한 통합 탭 메타(hidden_tab / tab_channels)를 필터가 실제로 + * 해석하는가. 문자열 동일성 단언은 `!==` 를 `===` 로 바꾸는 오타를 잡지 못한다. + * + * @scenario resource=notification_definitions_tab,endpoint=admin_ecommerce_settings,observation=tab_filter + * + * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active + */ + +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +const TAB_LAYOUT = path.resolve( + __dirname, + '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json', +); + +/** availableChannels 응답 형태 (코어 2 + 비즈뿌리오 2) */ +const CHANNELS = [ + { id: 'mail', source: 'core', name: '메일' }, + { id: 'database', source: 'core', name: '사이트내 알림' }, + { id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true }, + { + id: 'alimtalk', + source: 'sirsoft-message_bizppurio', + name: '비즈뿌리오 알림톡', + tab_channels: ['sms', 'alimtalk'], + tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab', + }, +]; + +/** + * id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다. + * + * @param node 탐색할 JSON 노드 + * @returns 서브탭 컨테이너 노드 (없으면 null) + */ +function findSubTabsContainer(node: unknown): Record | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findSubTabsContainer(child); + if (found) return found; + } + return null; + } + if (!node || typeof node !== 'object') return null; + + const record = node as Record; + if (typeof record.id === 'string' && record.id.endsWith('channel_sub_tabs')) return record; + + for (const value of Object.values(record)) { + const found = findSubTabsContainer(value); + if (found) return found; + } + return null; +} + +/** + * 서브탭 컨테이너 안에서 iteration 을 가진 탭 버튼 노드를 찾습니다. + * + * @param node 탐색할 JSON 노드 + * @returns iteration 보유 노드 (없으면 null) + */ +function findIterationNode(node: unknown): Record | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findIterationNode(child); + if (found) return found; + } + return null; + } + if (!node || typeof node !== 'object') return null; + + const record = node as Record; + if (record.iteration && typeof record.iteration.source === 'string') return record; + + for (const value of Object.values(record)) { + const found = findIterationNode(value); + if (found) return found; + } + return null; +} + +describe('이커머스 알림 설정 — 채널 서브탭 필터 실평가 (#597)', () => { + const layout = JSON.parse(fs.readFileSync(TAB_LAYOUT, 'utf-8')); + const container = findSubTabsContainer(layout); + const tabNode = container ? findIterationNode(container) : null; + const expr = String(tabNode?.iteration?.source ?? '').replace(/^\{\{|\}\}$/g, ''); + + /** + * 필터 표현식 원문을 실행해 노출 탭 id 목록을 반환합니다. + * + * @param saved _local.form.notifications.channels 저장값 + * @param channels availableChannels 채널 목록 + * @returns 필터를 통과한 채널 id 배열 + */ + function visibleTabs( + saved: Array<{ id: string; is_active: boolean }>, + channels: Array> = CHANNELS, + ): string[] { + // eslint-disable-next-line no-new-func + const fn = new Function('availableChannels', '_local', `return ${expr};`); + const result = fn( + { data: { channels } }, + { form: { notifications: { channels: saved } } }, + ) as Array<{ id: string }>; + return result.map((c) => c.id); + } + + it('서브탭 필터 표현식을 레이아웃에서 추출할 수 있다', () => { + expect(container, '*channel_sub_tabs 컨테이너를 찾지 못했다').toBeTruthy(); + expect(expr, '탭 iteration source 가 비어 있다').not.toBe(''); + }); + + it('sms·alimtalk 모두 활성 → 통합 탭 1개만 노출, sms 개별 탭은 숨김', () => { + expect(visibleTabs([ + { id: 'sms', is_active: true }, + { id: 'alimtalk', is_active: true }, + ])).toEqual(['mail', 'database', 'alimtalk']); + }); + + it('sms 만 활성(alimtalk 비활성) → tab_channels 규칙으로 통합 탭 노출', () => { + expect(visibleTabs([ + { id: 'sms', is_active: true }, + { id: 'alimtalk', is_active: false }, + ])).toEqual(['mail', 'database', 'alimtalk']); + }); + + it('sms·alimtalk 모두 비활성 → 통합 탭 미노출', () => { + expect(visibleTabs([ + { id: 'sms', is_active: false }, + { id: 'alimtalk', is_active: false }, + ])).toEqual(['mail', 'database']); + }); + + it('확장 채널 저장값 자체가 없으면(미저장=opt-in 전) 통합 탭 미노출', () => { + expect(visibleTabs([])).toEqual(['mail', 'database']); + }); + + it('코어 채널은 저장값이 명시적 false 일 때만 숨는다 (기본 노출)', () => { + expect(visibleTabs([{ id: 'mail', is_active: false }])).toEqual(['database']); + }); + + it('tab_channels 미선언 확장 채널은 자기 자신의 활성 저장 기준으로 판정된다', () => { + const withPlain = [...CHANNELS, { id: 'push', source: 'some-plugin', name: '푸시' }]; + expect(visibleTabs([{ id: 'push', is_active: true }], withPlain)).toEqual(['mail', 'database', 'push']); + expect(visibleTabs([{ id: 'push', is_active: false }], withPlain)).toEqual(['mail', 'database']); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json index 9bc531cf..91ce4d59 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json @@ -139,7 +139,7 @@ "type": "basic", "name": "Button", "iteration": { - "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}", + "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.hidden_tab !== true && (c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0))}}", "item_var": "ch", "index_var": "chIdx" }, @@ -159,7 +159,7 @@ { "type": "basic", "name": "Span", - "text": "{{ch.name ?? ch.id}}" + "text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}" } ], "actions": [ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index eb97c84f..efed6a42 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -9,6 +9,8 @@ ### Added - 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다. -- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. 발송 가능(승인) 상태가 아닌 템플릿을 연결하려 하면 저장을 거부하고 사유를 안내합니다. +- 알림 설정 화면의 '비즈뿌리오' 통합 탭에서 알림별 카카오 알림톡 템플릿을 직접 작성해 검수를 신청하고, 승인되면 그 내용으로 자동 발송됩니다. 기본형·부가정보형·채널추가형·복합형과 강조표기·이미지·아이템리스트 등 카카오 전 유형과 버튼·바로연결을 지원하며, 검수 결과는 30분 주기로 자동 확인되고 [새로고침]으로 즉시 확인할 수 있습니다. 반려되면 사유를 화면에서 확인하고 수정 후 다시 신청합니다. 게시판·이커머스 알림 설정 화면에서도 동일하게 동작합니다. +- 알림별 대체 SMS(알림톡 실패 시)와 SMS 단독 발송 본문을 같은 화면에서 설정할 수 있습니다. 문자 본문은 언어별로 입력하며 회원이 사용하는 언어로 발송됩니다(해당 언어가 비어 있으면 기본 언어로 발송). [SMS 단독]을 선택한 알림은 템플릿이 승인되어 있어도 알림톡을 보내지 않고 문자로만 발송합니다. 승인 취소 시에는 알림톡 발송이 즉시 중단됨을 경고합니다. +- 플러그인 설정의 '알림 템플릿 관리' 화면에서 모든 알림의 템플릿 상태를 한눈에 보고 검색·필터·수정·삭제할 수 있습니다. - 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. 요청 과다로 인한 일시적 발송 실패도 사유가 표시됩니다. - 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/README.md b/plugins/_bundled/sirsoft-message_bizppurio/README.md index d5ed4ac4..f1b231fc 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/README.md @@ -2,7 +2,7 @@

Version - G7 + G7 PHP License

@@ -11,7 +11,7 @@ G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가입·주문 등 코어/모듈이 발화하는 알림을 문자와 알림톡으로도 자동 발송합니다. 발송 결과는 비즈뿌리오가 보내는 webhook 통보로 수신해 성공/실패와 실패 사유를 발송 이력에 기록합니다. -[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 연동](#알림톡-템플릿-연동) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트) +[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 관리](#알림톡-템플릿-관리) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트) --- @@ -19,14 +19,14 @@ G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가 - 문자(SMS/LMS) 발송 — 본문 길이에 따라 SMS/LMS 자동 선택 - 카카오 알림톡 발송 — 승인된 템플릿의 본문·버튼·바로연결·강조표기·아이템리스트·대표링크까지 반영 -- 알림톡 미승인/미연결 시 문자로 자동 대체발송(옵션) +- 알림톡 미승인·발송 불가 시 문자로 자동 대체발송(옵션) - 회원·비회원 대상 알림에 문자 채널 연동 (비회원은 주문 시 입력한 연락처 사용) - 비즈뿌리오 webhook 리포트 수신으로 발송 결과(성공/실패/사유) 자동 기록 - 검수(테스트) 모드 — 실제 발송 없이 화면·흐름 검증 - 지갑 잔액 부족·후불 한도 초과 시 관리자 알림 (반복 발송 방지 쿨다운 적용) - 관리자 "알림 발송 이력" 화면에 문자·알림톡 결과 통합 표시 -- 알림톡 템플릿 목록·상태·내용 조회 및 알림 연결 (템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행) -- 알림톡 템플릿이 카카오에서 삭제·차단·승인취소된 경우 "사용 불가 — 재연결 필요" 표시 +- 알림별 카카오 알림톡 템플릿을 관리자 화면에서 직접 작성 → 검수 신청 → 승인 후 자동 발송 +- 검수 결과(승인·반려)를 30분 주기로 자동 확인하고, 반려 사유를 화면에서 확인해 수정 후 재신청 --- @@ -34,11 +34,11 @@ G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가 | 구분 | 항목 | 내용 | |------|------|------| -| 플랫폼 | G7 | `>= 7.0.3` | +| 플랫폼 | G7 | `>= 7.0.6` | | 플랫폼 | PHP | `^8.2` | | 사전 준비 | 비즈뿌리오 계정 | 가입 + API 사용 승인 | | 사전 준비 | 문자 발송 | 발신번호 사전 등록 (비즈뿌리오 콘솔) | -| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 + 발송할 템플릿의 카카오 검수 승인 | +| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 (템플릿은 이 플러그인 화면에서 작성·검수 신청) | > 운영 모드로 전환하려면 비즈뿌리오 아이디·비밀번호·API 키·발신번호가 모두 입력되어야 하며, 이 시점부터 **실제 발송과 비용이 발생**합니다. @@ -59,7 +59,7 @@ npm install npm run build ``` -그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널 기본 템플릿(제목·본문·수신자)이 알림 설정에 자동으로 채워집니다. +그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널이 알림 설정에 등록됩니다. --- @@ -75,7 +75,6 @@ npm run build | 발신번호 | 운영 시 필수 | 문자 발송용 발신번호. 비즈뿌리오 콘솔에 사전 등록된 번호만 사용 가능 | | 알림톡 발신프로필 키 | 알림톡 사용 시 필수 | 카카오 알림톡 발송에 사용할 발신프로필 키 | | 잔액부족 알림 재발송 간격(초) | 선택 (기본 3600) | 잔액 부족/한도 초과 실패 시 관리자 알림의 최소 재발송 간격. 대량 실패 시 반복 발송 방지 | -| 알림톡 내용 캐시 시간(분) | 선택 (기본 60) | 카카오 템플릿 내용 재사용 시간. 0이면 매 발송마다 최신 조회, [캐시 초기화] 버튼으로 즉시 반영 가능 | > 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다. 비밀번호·API 키·발신프로필 키는 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다. @@ -99,8 +98,8 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook ```text 코어/모듈이 알림 발화 (예: 회원가입, 주문 완료) -→ 알림 설정에 연결된 문자·알림톡 채널로 발송 작업 큐잉 -→ 알림톡: 카카오 승인 템플릿 사용, 미승인/미연결 시 문자로 대체발송(옵션) +→ 알림 설정에서 켜 둔 문자·알림톡 채널로 발송 작업 큐잉 +→ 알림톡: 승인된 템플릿의 저장 내용으로 발송, 발송 불가 시 문자로 대체발송(옵션) → 문자: 본문 길이에 따라 SMS/LMS 자동 선택 → 비즈뿌리오 발송 API 호출 → 비즈뿌리오가 webhook 으로 결과(성공/실패/사유) 통보 @@ -111,19 +110,44 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook --- -## 알림톡 템플릿 연동 +## 알림톡 템플릿 관리 -카카오 알림톡 템플릿의 **등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서** 진행합니다. 이 플러그인의 설정 화면(알림톡 템플릿 탭)은 콘솔에 등록된 템플릿의 목록·상태·내용을 조회하고, 발송가능(승인) 상태의 템플릿을 알림에 연결하는 역할만 담당합니다. +카카오 알림톡 템플릿의 **작성·검수 신청·취소·삭제를 관리자 화면에서 직접** 수행합니다. 비즈뿌리오 콘솔을 따로 열 필요가 없습니다. + +작성 위치는 두 곳이고 화면 구성은 같습니다. + +- **알림 설정 > 비즈뿌리오 탭** — 알림 항목마다 알림톡 템플릿과 대체 SMS 를 함께 설정합니다. 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다. +- **플러그인 설정 > 알림 템플릿 관리** — 전체 알림의 템플릿 상태를 한 화면에서 보고 검색·필터링합니다. + +기본 흐름은 **작성 → 검수 신청 → (카카오 승인) → 자동 발송** 입니다. 승인된 시점의 내용이 발송 기준으로 저장되며, 발송할 때마다 카카오를 조회하지 않습니다. + +메시지 유형(기본형·부가정보형·채널추가형·복합형)과 강조 유형(없음·강조표기·이미지·아이템리스트), 버튼(최대 5개)·바로연결(최대 10개)을 지원합니다. 본문에는 `#{변수}` 형식으로 알림 변수를 넣을 수 있습니다. + +이미지 강조 유형에 쓸 이미지는 화면에서 직접 업로드하며, 카카오 규격에 따라 **jpg/png · 500KB 이하 · 가로 500px 이상 · 가로:세로 2:1** 비율이어야 합니다. 규격에 맞지 않으면 업로드 단계에서 사유와 함께 거부됩니다. | 상태 | 발송 가능 | 설명 | |:---:|:---:|------| -| 발송가능 | ✅ | 카카오 검수 승인 완료, 알림에 연결해 발송 가능 | -| 검수중 | ❌ | 카카오 검수 진행 중 | -| 반려 | ❌ | 카카오 검수 반려, 콘솔에서 재신청 필요 | -| 미검수 | ❌ | 콘솔에 등록만 되고 검수 신청 전 상태 | -| 중지 | ❌ | 사용 중지된 템플릿 | +| 미작성 | ❌ | 템플릿 내용을 아직 작성하지 않은 상태 | +| 작성중 | ❌ | 저장했으나 검수를 신청하기 전 상태 (내용 수정 가능) | +| 검수중 | ❌ | 카카오 검수 진행 중. 내용을 고치려면 먼저 [신청 취소] | +| 승인 | ✅ | 검수 승인 완료 — 이 알림이 발생하면 알림톡이 발송됩니다 | +| 반려 | ❌ | 카카오 검수 반려. 화면에서 사유를 확인하고 수정 후 재신청 | +| 중지 | ❌ | 카카오에서 사용 중지된 템플릿 | +| 차단 | ❌ | 카카오에서 차단된 템플릿 | +| 휴면 | ❌ | 장기 미사용으로 휴면 전환. 화면의 [휴면 해제]로 복구 | -알림에 연결한 템플릿이 이후 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요"가 표시됩니다. 이 경우 알림톡 템플릿 탭에서 다른 승인 템플릿으로 다시 연결해야 합니다. +검수 결과는 30분 주기로 자동 확인하며, 각 행의 [새로고침]으로 즉시 확인할 수도 있습니다. + +승인된 템플릿의 내용을 고치려면 [수정]을 눌러 승인을 먼저 취소해야 합니다. **승인을 취소하면 그 즉시 해당 알림의 알림톡 발송이 중단**되므로(대체 SMS 는 설정에 따라 계속 발송), 화면에서 확인 후 진행합니다. + +### 문자(SMS) 본문 + +알림마다 문자 본문을 따로 입력합니다. 쓰임은 두 가지이고 본문은 하나를 공유합니다. + +- **대체 SMS** — 알림톡 발송이 실패했을 때(수신 거부·미가입 등) 같은 번호로 문자를 대신 보냅니다. +- **SMS 단독** — 알림톡을 쓰지 않고 문자로만 보냅니다. 이 항목을 켜면 템플릿이 승인 상태여도 알림톡을 보내지 않습니다. + +문자 본문은 **언어별로 입력**하며, 회원이 사용하는 언어의 본문으로 발송됩니다. 해당 언어의 본문이 비어 있으면 기본 언어 본문으로 발송하고, 기본 언어도 비어 있으면 그 알림의 문자 발송을 건너뜁니다. 알림톡 본문은 카카오가 승인한 원문 그대로만 발송할 수 있어 언어 구분이 없습니다. --- @@ -189,9 +213,9 @@ HookManager::addAction( | 문서 | 내용 | |------|------| | [webhook.md](docs/api/webhook.md) | 비즈뿌리오 발송 결과 리포트 수신 | -| [templates.md](docs/api/templates.md) | 알림톡 템플릿 조회 | -| [alimtalk-templates.md](docs/api/alimtalk-templates.md) | 알림톡 템플릿 관리자 API | -| [notification-bindings.md](docs/api/notification-bindings.md) | 알림-템플릿 연결 | +| [templates.md](docs/api/templates.md) | 알림톡 템플릿 라이프사이클 관리 (작성·검수 신청·상태 동기화·발송 설정) | +| [alimtalk-templates.md](docs/api/alimtalk-templates.md) | 알림톡 카테고리·발신프로필 조회 | +| [token.md](docs/api/token.md) | 비즈뿌리오 인증 토큰 | | [dispatch-results.md](docs/api/dispatch-results.md) | 발송 결과 조회 | | [report.md](docs/api/report.md) | 발송 결과 리포트 URL 조회 | @@ -200,7 +224,7 @@ HookManager::addAction( | 권한 | 설명 | |------|------| | `sirsoft-message_bizppurio.messaging.view` | 발송 이력, 알림톡 템플릿, 발송 결과 조회 | -| `sirsoft-message_bizppurio.messaging.manage` | 알림톡 템플릿 연결, 캐시 초기화 등 관리 작업 | +| `sirsoft-message_bizppurio.messaging.manage` | 알림톡 템플릿 작성·검수 신청·신청/승인 취소·상태 동기화·삭제, 대체 SMS 설정 등 관리 작업 | --- diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php deleted file mode 100644 index e22d555f..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php +++ /dev/null @@ -1,51 +0,0 @@ -bigIncrements('id')->comment('연결 설정 PK'); - $table->string('notification_type', 100)->comment('코어 notification_definitions.type (연결 대상 알림)'); - $table->string('channel', 20)->default('alimtalk')->comment('채널 (1차 alimtalk 고정)'); - $table->string('template_code', 50)->comment('연결한 카카오 알림톡 템플릿 코드'); - $table->string('template_name', 255)->comment('템플릿 이름 (사람 식별 + 고아 감지용 스냅샷)'); - $table->boolean('fallback_sms_enabled')->default(false)->comment('개별 대체발송 ON/OFF (실패 시 SMS/LMS 대체)'); - $table->boolean('is_active')->default(true)->comment('연동 활성 여부'); - $table->timestamps(); - - $table->unique(['notification_type', 'channel'], 'bizppurio_binding_type_channel_unique'); - }); - - if (DB::getDriverName() === 'mysql') { - Schema::table('bizppurio_notification_bindings', function (Blueprint $table) { - $table->comment('비즈뿌리오 이벤트↔알림톡 템플릿 연결 설정'); - }); - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('bizppurio_notification_bindings'); - } -}; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_templates_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_templates_table.php new file mode 100644 index 00000000..441d4a7d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_templates_table.php @@ -0,0 +1,68 @@ +bigIncrements('id')->comment('템플릿 PK'); + $table->string('notification_type', 100)->comment('코어 notification_definitions.type (알림 1건당 1행)'); + $table->boolean('alimtalk_enabled')->default(false)->comment('알림톡 발송 사용 여부'); + $table->string('template_code', 30)->nullable()->comment('자체 채번한 카카오 템플릿 코드 (등록 전 null)'); + $table->string('sender_key', 100)->nullable()->comment('신청 당시 발신프로필 키 스냅샷'); + $table->json('content')->nullable()->comment('카카오 등록 페이로드 (templateName/templateMessageType/templateEmphasizeType/templateContent/buttons 등 kapi add 필드 그대로 — 유형별 컬럼 분해 없이 JSON 단일 컬럼)'); + $table->json('approved_content')->nullable()->comment('승인 확정 시점 content 스냅샷 (발송 SSoT — 승인 취소돼도 유지)'); + $table->string('status', 20)->default('draft')->comment('검수 상태: draft/requested/approved/rejected/stopped/blocked/dormant (BizppurioTemplateStatus enum)'); + $table->json('inspection_detail')->nullable()->comment('kapi detail 의 comments 배열 스냅샷 (반려 사유 원문)'); + $table->timestamp('requested_at')->nullable()->comment('검수 신청 시각'); + $table->timestamp('approved_at')->nullable()->comment('승인 확정 시각'); + $table->timestamp('last_synced_at')->nullable()->comment('마지막 카카오 상태 동기화 시각'); + $table->boolean('fallback_sms_enabled')->default(false)->comment('알림톡 실패 시 SMS 대체발송 여부 (resend 위임)'); + $table->json('sms_body')->nullable()->comment('대체 SMS 겸 SMS 단독 본문의 다국어 맵 ({"ko": "...", "en": "..."}, #{var} 치환) — 수신자 로케일로 렌더'); + $table->boolean('sms_only')->default(false)->comment('알림톡 없이 SMS 만 발송하는 알림 여부 (표시·알림톡 게이트용)'); + $table->boolean('is_active')->default(true)->comment('활성 여부'); + $table->timestamps(); + + $table->unique('notification_type', 'bizppurio_template_type_unique'); + $table->index('status', 'bizppurio_template_status_idx'); + }); + + if (DB::getDriverName() === 'mysql') { + Schema::table('bizppurio_templates', function (Blueprint $table) { + $table->comment('비즈뿌리오 알림 템플릿 (알림톡 등록·검수 상태 + SMS 본문)'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bizppurio_templates'); + } +}; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js index b29c9bd6..2642b4a2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js +++ b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js @@ -1 +1 @@ -var SirsoftMessageBizppurio=function(r){"use strict";const i={},t="sirsoft-message_bizppurio",o=window.G7Core?.createLogger?.(`Plugin:${t}`)??{log:(...e)=>console.log(`[Plugin:${t}]`,...e),warn:(...e)=>console.warn(`[Plugin:${t}]`,...e),error:(...e)=>console.error(`[Plugin:${t}]`,...e)};function a(e){Object.entries(i).forEach(([n,c])=>{e.registerHandler(`${t}.${n}`,c,{category:"plugin",source:t})}),o.log(`${Object.keys(i).length} handler(s) registered:`,Object.keys(i).map(n=>`${t}.${n}`))}function g(e){const n=window.G7Core?.getActionDispatcher?.();if(n){a(n);return}if(!e){o.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let c=0;const l=50,u=()=>{const d=window.G7Core?.getActionDispatcher?.();if(d){a(d);return}++c<=l?setTimeout(u,100):o.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};u()}function s(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>g(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();g(!e)}}return s(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:t,handlers:Object.keys(i),initPlugin:s}),r.initPlugin=s,Object.defineProperty(r,Symbol.toStringTag,{value:"Module"}),r}({}); +var SirsoftMessageBizppurio=function($){"use strict";const I=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)},D="bz_template_content",B="bz_tpl_modal.content.templateContent";function E(e,n){const t=window.G7Core,r=e.params??{},o=String(r.variable??"").trim(),i=String(r.target??D),l=String(r.stateTarget??"global"),a=String(r.statePath??B);if(o===""){I.warn("삽입할 변수명이 지정되지 않았습니다.");return}const c=l==="local"?t?.state?.setLocal:t?.state?.setGlobal??t?.state?.set,S=l==="local"?t?.state?.getLocal:t?.state?.getGlobal??t?.state?.get;if(!c||!S){I.error("G7Core.state API 를 사용할 수 없습니다.");return}const m=l==="local"?n?.state??S()??{}:S()??{},s=String(C(m,a)??""),d=`#{${o}}`,u=document.querySelector(`textarea[name="${i}"], input[name="${i}"]`),g=(u?.value??"")===s,h=g?u?.selectionStart??s.length:s.length,_=g?u?.selectionEnd??s.length:s.length,N=s.slice(0,h)+d+s.slice(_);if(c({[a]:N}),u){const M=h+d.length;requestAnimationFrame(()=>{try{u.focus(),u.setSelectionRange(M,M)}catch{}})}I.log(`변수 삽입: ${d} → ${l}:${a}`)}function C(e,n){return n.split(".").reduce((t,r)=>t?.[r],e)}const f=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)},O="/api/plugins/sirsoft-message_bizppurio/admin/templates/image",L="sirsoft-message_bizppurio.template.form.image_upload_failed";function j(){if(typeof window>"u")return{};const e=localStorage.getItem("g7_locale");return e?{"Accept-Language":e}:{}}function z(e){const n=e?.t?.(L);return typeof n=="string"&&n!==L?n:"Failed to upload the image."}function k(e,n){return n==="local"?e?.state?.setLocal??null:e?.state?.setGlobal??e?.state?.set??null}function F(e,n){return n==="local"?e?.state?.getLocal??null:e?.state?.getGlobal??e?.state?.get??null}function V(e,n){return n.split(".").reduce((t,r)=>t?.[r],e)}function w(e,n,t){const r=F(e,n);if(!r)return!0;try{return V(r()??{},`${t}.uploading`)===!0}catch{return!0}}let P=!1;async function H(e,n){const t=window.G7Core,r=e.params??{},o=String(r.stateTarget??"global"),i=String(r.statePathUrl??"bz_tpl_modal.content.templateImageUrl"),l=String(r.statePathName??"bz_tpl_modal.content.templateImageName"),a=String(r.statePathStatus??"bz_tpl_upload"),c=k(t,o);if(!c){f.error("G7Core.state setter 를 사용할 수 없습니다.");return}const m=(n?.event??e?.event)?.target,s=m?.files?.[0];if(s){if(P){f.warn("이미 업로드가 진행 중입니다 — 새 파일 선택을 무시합니다."),m&&(m.value="");return}P=!0,c({[`${a}.uploading`]:!0,[`${a}.error`]:null});try{const d=localStorage.getItem("auth_token")??"",u=new FormData;u.append("image",s,s.name);const v=await fetch(O,{method:"POST",headers:{Accept:"application/json",...d?{Authorization:`Bearer ${d}`}:{},...j()},body:u}),g=await v.json().catch(()=>({}));if(!v.ok||g?.success===!1){const _=g?.errors?.bizppurio_message||g?.errors?.image?.[0]||g?.message||z(t);w(t,o,a)&&y(c,a,i,l,_),f.warn("이미지 업로드 실패:",_);return}const h=String(g?.data?.url??"").trim();if(h===""){w(t,o,a)&&y(c,a,i,l,z(t)),f.warn("이미지 업로드 응답에 url 이 없습니다:",g);return}if(!w(t,o,a)){f.warn("모달이 다시 시딩되어 업로드 결과를 버립니다:",h);return}c({[`${a}.uploading`]:!1,[`${a}.error`]:null,[i]:h,[l]:s.name}),f.log("이미지 업로드 완료:",h)}catch(d){w(t,o,a)&&y(c,a,i,l,z(t)),f.error("이미지 업로드 예외:",d)}finally{P=!1,m&&(m.value="")}}}function y(e,n,t,r,o){e({[`${n}.uploading`]:!1,[`${n}.error`]:o,[t]:"",[r]:""})}const b={insertVariable:E,uploadTemplateImage:H},p="sirsoft-message_bizppurio",T=window.G7Core?.createLogger?.(`Plugin:${p}`)??{log:(...e)=>console.log(`[Plugin:${p}]`,...e),warn:(...e)=>console.warn(`[Plugin:${p}]`,...e),error:(...e)=>console.error(`[Plugin:${p}]`,...e)};function G(e){Object.entries(b).forEach(([n,t])=>{e.registerHandler(`${p}.${n}`,t,{category:"plugin",source:p})}),T.log(`${Object.keys(b).length} handler(s) registered:`,Object.keys(b).map(n=>`${p}.${n}`))}function U(e){const n=window.G7Core?.getActionDispatcher?.();if(n){G(n);return}if(!e){T.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let t=0;const r=50,o=()=>{const i=window.G7Core?.getActionDispatcher?.();if(i){G(i);return}++t<=r?setTimeout(o,100):T.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};o()}function A(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>U(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();U(!e)}}return A(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:p,handlers:Object.keys(b),initPlugin:A}),$.initPlugin=A,Object.defineProperty($,Symbol.toStringTag,{value:"Module"}),$}({}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index d98fadfc..280895ca 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -4,15 +4,14 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 7 · **엔드포인트 수**: 13 +- **문서 수**: 6 · **엔드포인트 수**: 21 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | -| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 4 | +| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 2 | | [dispatch-results.md](dispatch-results.md) | `dispatch-results` | 2 | -| [notification-bindings.md](notification-bindings.md) | `notification-bindings` | 3 | | [report.md](report.md) | `report` | 1 | -| [templates.md](templates.md) | `templates` | 1 | +| [templates.md](templates.md) | `templates` | 14 | | [token.md](token.md) | `token` | 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 index 4bff9007..16bfe23a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md @@ -7,58 +7,18 @@ ## TL;DR (5초 요약) ```text -1. 이 문서는 실제 API 호출로 실측한 Alimtalk Templates 엔드포인트 레퍼런스입니다 +1. 이 문서는 알림톡 템플릿 작성 모달의 참조 조회(카테고리·발신프로필) 엔드포인트 레퍼런스입니다 2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) -3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 -4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 -5. 설명(TODO) 칸은 사람이 채웁니다 +3. 실시간 템플릿 목록/상세 화면(구 Phase 5)은 DB 기반 라이프사이클(templates.md)로 대체되어 제거됐습니다 +4. kapi 실패는 422 + errors.bizppurio_message(카카오 사유 원문) 규약을 따릅니다 +5. 갱신: 코드 변경 후 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` - -**요청 파라미터** - -| 파라미터 | 위치 | 타입 | 필수 | 제약 | 설명 | -| --- | --- | --- | --- | --- | --- | -| status | query | string | 아니오 | max 30 | kapi `templateStatus` 필터 값(어휘는 kapi 정의를 따름) | -| keyword | query | string | 아니오 | max 50 | 템플릿명/코드 검색어 | -| page | query | integer | 아니오 | min 1 | 페이지 번호(기본 1) | -| count | query | integer | 아니오 | min 1 | 페이지당 건수(기본값은 서버 설정) | - -**요청 예시** - -```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`)이 없는 경우 | - - - -**설명** +작성 모달 참조 조회(카테고리·발신프로필) 전용 문서다. 알림톡 템플릿 작성 모달의 카테고리 셀렉트·발신프로필 +셀렉트가 소비하는 kapi(카카오 관리 API) 조회를 프록시한다. 템플릿의 등록·검수·승인 라이프사이클 API 는 +[templates.md](templates.md) 를 참조. ### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories @@ -82,11 +42,24 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| categories | array | `[{...}]` | kapi `/v3/kakao/template/category/all` 조회 결과 배열. 각 항목: `code`(카테고리 코드 — `content.categoryCode` 에 사용), `name`(소분류명), `groupName`(대분류명) | **응답 예시** - +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "categories": [ + { "code": "001001", "name": "회원가입", "groupName": "회원" }, + { "code": "004001", "name": "구매완료", "groupName": "구매" } + ] + } +} +``` **에러 응답** @@ -97,7 +70,11 @@ Authorization: Bearer {YOUR_TOKEN} -**설명** +**설명** + +템플릿 등록에 사용할 카카오 카테고리 전체(대분류·소분류)를 조회한다(`data.categories`). 작성 모달의 +카테고리 셀렉트가 소비하며, 선택된 소분류 코드가 템플릿 생성/수정의 `content.categoryCode` 값이 된다. +kapi 자격증명 미설정·호출 실패 시 422 + `errors.bizppurio_message`(카카오 사유 원문) 를 반환한다. ### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles @@ -121,97 +98,21 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | - - - -**설명** - - -### 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 파라미터에 해당하는 리소스가 없는 경우 | - - - -**설명** - - -### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cache.clear` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@clearCache` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -_요청 파라미터 없음._ - -**요청 예시** - -```http -POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -``` - -**응답 필드** (`data` 내부) - -| 필드 | 타입 | 설명 | -| --- | --- | --- | -| `cleared` | integer | 초기화한 캐시 키 수(연결된 고유 알림톡 템플릿 코드 수) | +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| profiles | array | `[{...}]` | kapi `/v3/kakao/profile/use` 응답 `data.success` 배열(조회 실패 프로필 `fail` 은 제외). 각 항목: `senderKey`(발신프로필 키), `name`(프로필명), `status`, `block`, `dormant`, `categoryCode` 등 kapi 원형 필드 | **응답 예시** ```json { "success": true, - "message": "알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.", - "data": { "cleared": 3 } + "message": "성공적으로 처리되었습니다.", + "data": { + "profiles": [ + { "senderKey": "05aa099bcbc5220a8c0b2...", "name": "@우리상점", "status": "A", "block": false, "dormant": false } + ] + } } ``` @@ -220,10 +121,11 @@ Authorization: Bearer {YOUR_TOKEN} | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + **설명** -발송 시 알림톡은 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을 카카오 상세조회로 가져와 채우며, 그 결과를 template_code 단위로 캐시한다(기본 1시간, 환경설정 `template_cache_ttl` 로 조정, 0이면 캐시 끔). 카카오 콘솔에서 템플릿 내용을 방금 변경해 캐시 만료를 기다리지 않고 즉시 반영하고 싶을 때 이 엔드포인트로 캐시를 비운다. 연결(binding)된 모든 알림톡 템플릿의 캐시를 초기화하며, 다음 발송에서 최신 내용으로 재조회된다. 카카오 API 를 호출하지 않고 로컬 캐시만 비우므로 rate limit 에 영향을 주지 않는다. 관리자 화면(알림톡 템플릿 탭)의 "내용 캐시 초기화" 버튼이 이 엔드포인트를 호출한다. - - +발신프로필(사용중) 상태 정보를 조회한다(`data.profiles`). 작성 모달의 발신프로필 셀렉트·상태 표시가 +소비한다. kapi 자격증명 미설정·호출 실패 시 422 + `errors.bizppurio_message`(카카오 사유 원문) 를 반환한다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md index 81b4f96b..24b69d66 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md @@ -51,7 +51,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| results | array | `[]` | | +| results | array | `[]` | 요청한 `notification_log_ids` 각각의 비즈뿌리오 발송 결과. 해당 알림 로그로 발송된 건이 없으면 빈 배열 | **응답 예시** @@ -152,7 +152,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| results | object | `{"21":{"status":"pending","status_label":"대기","result_cod…` | | +| results | object | `{"21":{"status":"pending","status_label":"대기","result_cod…` | `notification_log_id` 를 키로 하는 결과 맵. 각 값은 `status`(pending/sent/failed) · `status_label`(현재 로케일 라벨) · `result_code`(비즈뿌리오 결과 코드) · 실패 사유를 담는다 | **응답 예시** @@ -324,6 +324,11 @@ HTTP/1.1 200 -**설명** +**설명** + +알림 발송 이력 화면이 최근 발송 건의 비즈뿌리오 결과를 한 번에 받아 결과 컬럼에 주입할 때 사용한다. +`lookup` 이 지정한 로그 ID 만 조회하는 것과 달리, 이 엔드포인트는 최근 구간을 서버가 정해 돌려주므로 +화면이 조회 대상을 미리 알 필요가 없다. 결과는 `notification_log_id` 를 키로 하는 맵이며, +비즈뿌리오로 발송되지 않은 알림은 키 자체가 없다 — 화면은 키 부재를 "해당 없음" 으로 그린다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md deleted file mode 100644 index 4ea771f9..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md +++ /dev/null @@ -1,196 +0,0 @@ -# Notification Bindings API 레퍼런스 - -> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. - ---- - -## TL;DR (5초 요약) - -```text -1. 이 문서는 실제 API 호출로 실측한 Notification Bindings 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) -3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 -4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 -5. 설명(TODO) 칸은 사람이 채웁니다 -``` - ---- - - -### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.index` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@index` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` - -**요청 파라미터** - -_요청 파라미터 없음._ - -**요청 예시** - -```http -GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -``` - -**응답 필드** (`data` 내부) - -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| bindings | array | `[]` | | - -**응답 예시** - -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "messages.success", - "data": { - "bindings": [] - } -} -``` - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | - - - -**설명** - -알림 설정 화면의 알림톡 탭이 소비하는 목록이다. 코어 알림 정의 중 채널에 `alimtalk` 을 포함하는 활성 알림 전체를, 연결된 알림톡 템플릿(binding)과 조인해 반환한다. 미연결 알림은 `is_bound=false` 이고 `template_code`/`template_name` 은 null, `fallback_sms_enabled` 는 false 다. `variables` 는 코어 알림 정의의 변수 목록으로, 편집 모달의 "제공 변수" 안내에 쓰인다. - - -### POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.store` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@store` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| notification_type | body | string | 예 | max 100 | | -| template_code | body | string | 아니오 | max 50 | | -| template_name | body | string | 아니오 | max 255 | template 이름 (식별자) | -| fallback_sms_enabled | body | boolean | 아니오 | — | | - -**요청 예시** - -```http -POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -Content-Type: application/json - -{ - "notification_type": "예시값", - "template_code": "예시값", - "template_name": "예시 이름", - "fallback_sms_enabled": true -} -``` - -**응답 필드** (`data` 내부) - -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| bindings | object | `{"실측 예시값":{"notification_type":"실측 예시값","template_code":"…` | | - -**응답 예시** - -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "messages.binding.saved", - "data": { - "bindings": { - "실측 예시값": { - "notification_type": "실측 예시값", - "template_code": "실측 예시값", - "template_name": "실측 예시값", - "fallback_sms_enabled": true - } - } - } -} -``` - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 422 | Unprocessable Entity | `template_code` 가 카카오 승인 상태(RDY/ACT)가 아니거나, 카카오 승인 목록 조회 자체가 실패(자격증명 미설정·장애)한 경우 (`error.kakao_message`/`error.result_code`) | - - - -**설명** - -알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 저장 전 카카오 승인 상태(RDY/ACT)를 서버측에서 재검증하며, 미승인 코드거나 카카오 조회 자체가 실패하면 422 로 거부한다(편집 모달 드롭다운의 승인 템플릿 필터는 화면 단계일 뿐이라, 이를 우회한 직접 API 호출로 미승인 템플릿이 저장되는 것을 막기 위함). 연결 해제(`template_code` 빈 값)는 이 검증을 거치지 않는다. - - -### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.approved-templates` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@approvedTemplates` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` - -**요청 파라미터** - -_요청 파라미터 없음._ - -**요청 예시** - -```http -GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-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`)이 없는 경우 | - - - -**설명** - -연동 편집 모달의 "연결 템플릿" 드롭다운을 채우는 소스다. 카카오 템플릿 목록 중 serviceStatus 가 RDY(발송전)·ACT(정상)인 승인 템플릿만 반환한다. 자격증명(bizId·apiKey·senderKey) 미설정이거나 kapi 조회 실패 시 422 로 카카오 사유를 그대로 전달한다. - - diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md index 3f8a4284..1ebe76ef 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md @@ -41,7 +41,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| url | string | `http://g7-issue.eh.test/api/plugins/s…` | | +| url | string | `https://api.example.com/api/plugins/sirsoft-message_bizppurio/webhook` | 비즈뿌리오 콘솔에 등록할 발송 결과 리포트 수신 URL (현재 사이트 기준 절대 URL) | **응답 예시** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md index 6f360c96..b2e4a93b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md @@ -7,11 +7,11 @@ ## TL;DR (5초 요약) ```text -1. 이 문서는 실제 API 호출로 실측한 Templates 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) -3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 -4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 -5. 설명(TODO) 칸은 사람이 채웁니다 +1. 이 문서는 Templates 엔드포인트 레퍼런스입니다 (readiness 1건은 실측 @generated, 라이프사이클 13건은 코드 사양 기반 수기 작성) +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시 + 응답 필드 표 + 응답 예시(envelope) +3. 알림 템플릿 라이프사이클: 등록(draft) → 검수 신청(requested) → 승인(approved) 후 발송 활성화 — 발송 판정은 DB 가 유일한 근거 +4. kapi(카카오 관리 API) 실패는 422 + errors.bizppurio_message(사유 원문)·errors.result_code / 상태 전이 위반도 422 +5. 갱신: 코드 변경 후 이 문서를 수기 동기화 (라이프사이클 항목은 kapi 자격증명 없이 실측 불가) ``` --- @@ -41,9 +41,9 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| api_key_set | boolean | `false` | | -| sender_key_set | boolean | `false` | | -| ready | boolean | `false` | | +| api_key_set | boolean | `false` | 카카오 관리 API 키(`api_key`) 저장 여부 — 값 자체는 sensitive 라 노출하지 않고 저장 여부만 내려준다 | +| sender_key_set | boolean | `false` | 알림톡 발신프로필 키(`sender_key`) 저장 여부 | +| ready | boolean | `false` | 두 값이 모두 저장되어 템플릿 작성·검수 신청이 가능한 상태인지 (알림 설정 탭 readiness 배너가 소비) | **응답 예시** @@ -71,5 +71,985 @@ HTTP/1.1 200 -**설명** +**설명** +카카오 관리에 필요한 자격증명(`api_key`·`sender_key`)은 sensitive 설정이라 코어 설정 조회 응답에서 제거된다. +값 자체는 노출하지 않고 저장 여부(boolean)만 내려주며, 알림톡 탭 readiness 배너가 소비한다. + +--- + +## 알림 템플릿 라이프사이클 + +> 아래 13개 엔드포인트는 kapi(카카오 관리 API) 자격증명 없이는 실측할 수 없어 `api:docgen` 실측 블록 대신 +> 코드 사양(컨트롤러 직렬화·FormRequest 검증·서비스 상태 전이) 기반으로 수기 작성했다. 응답 예시값은 합리적 예시다. + +시스템 등록(**draft**) → 검수 신청(**requested**) → 승인(**approved**) 후 알림톡 발송이 활성화된다. +발송 판정은 DB(`bizppurio_templates`)가 유일한 근거이며(실시간 조회 폐지), 카카오와의 상태 정합은 +스케줄러(`bizppurio:sync-template-status`)와 수동 sync 가 유지한다. + +- **인증**: 전 엔드포인트 `auth:sanctum` + 관리자 +- **권한**: 조회(목록/맵/상세) = `permission:sirsoft-message_bizppurio.messaging.view` / 그 외 변경 = `permission:sirsoft-message_bizppurio.messaging.manage` +- **status 어휘**: `draft`(작성중) / `requested`(검수중) / `approved`(승인됨) / `rejected`(반려됨) / `stopped`(발송중지) / `blocked`(차단됨) / `dormant`(휴면) + +**공통 실패 규약** + +| 상태코드 | 발생 조건 | 응답 형태 | +| --- | --- | --- | +| 422 (kapi 실패) | 카카오 관리 API 호출이 실패한 경우 | `errors.bizppurio_message` 에 카카오가 준 실패 사유 원문, `errors.result_code` 에 결과 코드 (관리자 전용 면 — 조치 근거 제공) | +| 422 (상태 전이 위반) | 현재 상태에서 허용되지 않는 액션인 경우 | 위반 사유 메시지 (예: "현재 상태(requested)에서는 알림톡 내용을 수정할 수 없습니다. …") | +| 422 (검증 실패) | 요청 파라미터가 검증 규칙을 위반한 경우 | `error.errors` 에 필드별 메시지 | +| 404 | `{id}` 에 해당하는 템플릿이 없는 경우 | `messages.not_found` | +| 401 / 403 | 토큰 없음·만료 / 요구 권한 없음 | 표준 | + +kapi 실패 422 응답 예시: + +```json +{ + "success": false, + "message": "카카오 요청이 실패했습니다.", + "error": { + "errors": { + "bizppurio_message": "이미 사용중인 템플릿 코드입니다.", + "result_code": "504" + } + } +} +``` + +인프라 장애·코드 결함은 4xx 로 위장하지 않는다 — 도메인 예외(kapi 실패·상태 전이 위반) 외의 예외는 500 으로 드러난다. + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/templates + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.index` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| status | query | string | 아니오 | `draft`/`requested`/`approved`/`rejected`/`stopped`/`blocked`/`dormant` | 우리 상태 enum 필터 | +| search | query | string | 아니오 | max 100 | 알림 유형·알림 정의 이름 검색어 | +| page | query | integer | 아니오 | min 1 | 페이지 번호 (기본 1) | +| per_page | query | integer | 아니오 | min 1 · max 100 | 페이지 크기 (기본 20) | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/templates?status=approved&page=1&per_page=20 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data.templates[]` — 목록 행. 대형 JSON(content 등)은 제외되고 존재 여부(has_content)만 실림) + +| 필드 | 타입 | 용도/설명 | +| --- | --- | --- | +| id | integer | 템플릿 PK | +| notification_type | string | 코어 알림 정의 type (`notification_definitions.type`) | +| definition_name | object\|null | 조인된 알림 정의 다국어 이름 (예: `{"ko": "...", "en": "..."}`) | +| definition_variables | array | 알림 정의가 제공하는 치환 변수 목록 | +| definition_extension_type | string\|null | 알림 정의 소유 확장 유형 (`core`/`module`/`plugin`) | +| definition_extension_identifier | string\|null | 알림 정의 소유 확장 식별자 | +| alimtalk_enabled | boolean | 알림톡 사용 여부 | +| template_code | string\|null | 자체 채번된 카카오 템플릿 코드 (검수 신청 전이면 null) | +| status | string | 상태 (status 어휘 참조) | +| has_content | boolean | 알림톡 content(카카오 등록 페이로드) 작성 여부 | +| requested_at | string\|null | 검수 신청 시각 (ISO 8601) | +| approved_at | string\|null | 승인 시각 (ISO 8601) | +| last_synced_at | string\|null | 마지막 카카오 동기화 시각 (ISO 8601) | +| fallback_sms_enabled | boolean | 알림톡 실패 시 SMS 대체발송 여부 | +| sms_only | boolean | SMS 단독 발송 여부 (알림톡 미사용) | +| is_active | boolean | 발송 활성 여부 | + +**응답 필드** (`data.pagination`) + +| 필드 | 타입 | 용도/설명 | +| --- | --- | --- | +| total | integer | 총 건수 | +| last_page | integer | 마지막 페이지 번호 | +| current_page | integer | 현재 페이지 번호 | +| per_page | integer | 페이지 크기 | + +**응답 예시** + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "templates": [ + { + "id": 3, + "notification_type": "order_completed", + "definition_name": { "ko": "주문 완료", "en": "Order completed" }, + "definition_variables": ["user_name", "order_id"], + "definition_extension_type": "module", + "definition_extension_identifier": "sirsoft-ecommerce", + "alimtalk_enabled": true, + "template_code": "g7_1a2b3c4d_1", + "status": "approved", + "has_content": true, + "requested_at": "2026-08-10T09:12:00+09:00", + "approved_at": "2026-08-12T14:30:00+09:00", + "last_synced_at": "2026-08-19T03:00:00+09:00", + "fallback_sms_enabled": true, + "sms_only": false, + "is_active": true + } + ], + "pagination": { + "total": 1, + "last_page": 1, + "current_page": 1, + "per_page": 20 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 | + +**설명** + +템플릿 관리 화면의 DB 목록 조회다. 알림 정의(`notification_definitions`)를 조인해 라벨(definition_name)과 +소속 확장을 함께 내려주며, 대형 JSON(content·approved_content·inspection_detail)은 목록에서 제외된다 — +상세는 `GET /admin/templates/{id}` 로 조회한다. + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/templates/map + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.map` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@map` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/templates/map HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data.templates` — 키는 `notification_type`, 값은 요약 객체) + +| 필드 | 타입 | 용도/설명 | +| --- | --- | --- | +| id | integer | 템플릿 PK | +| notification_type | string | 코어 알림 정의 type | +| alimtalk_enabled | boolean | 알림톡 사용 여부 | +| template_code | string\|null | 카카오 템플릿 코드 (검수 신청 전이면 null) | +| status | string | 상태 (status 어휘 참조) | +| has_content | boolean | 알림톡 content 작성 여부 | +| requested_at | string\|null | 검수 신청 시각 (ISO 8601) | +| approved_at | string\|null | 승인 시각 (ISO 8601) | +| last_synced_at | string\|null | 마지막 동기화 시각 (ISO 8601) | +| inspection_detail | array\|null | 반려 사유(comments) 등 검수 상세 | +| fallback_sms_enabled | boolean | SMS 대체발송 여부 | +| sms_body | object\|null | 대체/단독 SMS 본문의 로케일 맵 (`{"ko": "...", "en": "..."}`) | +| sms_only | boolean | SMS 단독 발송 여부 | +| is_active | boolean | 발송 활성 여부 | + +**응답 예시** + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "templates": { + "order_completed": { + "id": 3, + "notification_type": "order_completed", + "alimtalk_enabled": true, + "template_code": "g7_1a2b3c4d_1", + "status": "approved", + "has_content": true, + "requested_at": "2026-08-10T09:12:00+09:00", + "approved_at": "2026-08-12T14:30:00+09:00", + "last_synced_at": "2026-08-19T03:00:00+09:00", + "inspection_detail": null, + "fallback_sms_enabled": true, + "sms_body": {"ko": "#{user_name}님, 주문이 완료되었습니다.", "en": "#{user_name}, your order is complete."}, + "sms_only": false, + "is_active": true + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + +**설명** + +알림 설정 3면(코어/게시판/이커머스)의 행 하단 UI 가 소비하는 `notification_type` 키 요약 맵이다. +행 수는 알림 정의 수에 묶인 설정성 데이터라 페이지네이션 없이 전량 내려준다. + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/templates/{id} + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.show` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | integer | 예 | 숫자만 | 템플릿 PK | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/templates/3 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data.template` — 상세 객체. 변경 계열 엔드포인트들도 동일 형태를 반환한다) + +| 필드 | 타입 | 용도/설명 | +| --- | --- | --- | +| id | integer | 템플릿 PK | +| notification_type | string | 코어 알림 정의 type | +| alimtalk_enabled | boolean | 알림톡 사용 여부 | +| template_code | string\|null | 자체 채번된 카카오 템플릿 코드 | +| sender_key | string\|null | 검수 신청 시점의 발신프로필 키 스냅샷 | +| content | object\|null | 카카오 등록 페이로드 (작성중 내용 — kapi add/update 에 그대로 사용) | +| approved_content | object\|null | 승인 시점에 동결된 content 스냅샷 (발송 SSoT) | +| status | string | 상태 (status 어휘 참조) | +| is_approved | boolean | `status === approved` 편의 플래그 | +| inspection_detail | array\|null | 반려 사유(comments) 등 검수 상세 | +| requested_at | string\|null | 검수 신청 시각 (ISO 8601) | +| approved_at | string\|null | 승인 시각 (ISO 8601) | +| last_synced_at | string\|null | 마지막 동기화 시각 (ISO 8601) | +| fallback_sms_enabled | boolean | SMS 대체발송 여부 | +| sms_body | object\|null | 대체/단독 SMS 본문의 로케일 맵 (`{"ko": "...", "en": "..."}`) | +| sms_only | boolean | SMS 단독 발송 여부 | +| is_active | boolean | 발송 활성 여부 | + +**응답 예시** + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "template": { + "id": 3, + "notification_type": "order_completed", + "alimtalk_enabled": true, + "template_code": "g7_1a2b3c4d_1", + "sender_key": "b4c9...{MASKED}", + "content": { + "templateName": "주문 완료 안내", + "templateMessageType": "BA", + "templateEmphasizeType": "NONE", + "templateContent": "#{user_name}님, 주문 #{order_id} 이(가) 완료되었습니다.", + "categoryCode": "004001", + "buttons": [ + { "name": "주문 확인", "linkType": "WL", "linkMo": "https://example.com/orders" } + ] + }, + "approved_content": { + "templateName": "주문 완료 안내", + "templateMessageType": "BA", + "templateEmphasizeType": "NONE", + "templateContent": "#{user_name}님, 주문 #{order_id} 이(가) 완료되었습니다.", + "categoryCode": "004001", + "buttons": [ + { "name": "주문 확인", "linkType": "WL", "linkMo": "https://example.com/orders" } + ] + }, + "status": "approved", + "is_approved": true, + "inspection_detail": null, + "requested_at": "2026-08-10T09:12:00+09:00", + "approved_at": "2026-08-12T14:30:00+09:00", + "last_synced_at": "2026-08-19T03:00:00+09:00", + "fallback_sms_enabled": true, + "sms_body": {"ko": "#{user_name}님, 주문이 완료되었습니다.", "en": "#{user_name}, your order is complete."}, + "sms_only": false, + "is_active": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | +| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 | + +**설명** + +작성 모달·상세 패널이 소비하는 단건 상세다. `content`(작성중)와 `approved_content`(승인 동결 스냅샷)가 +분리되어 있어, 승인 후 content 를 다시 고쳐도 발송은 `approved_content` 기준으로 유지된다. + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/templates + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.store` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notification_type | body | string | 예 | max 100 · 알림 정의 존재(exists) · 미등록(unique) | 대상 코어 알림 정의 type (알림 1건당 1행) | +| alimtalk_enabled | body | boolean | 아니오 | — | 알림톡 사용 여부 | +| fallback_sms_enabled | body | boolean | 아니오 | — | SMS 대체발송 여부 | +| sms_body | body | object | 아니오 | nullable · 로케일별 max 2000 | 대체/단독 SMS 본문의 로케일 맵. 키는 시스템 지원 로케일(`app.translatable_locales`), 값은 각 언어 본문 | +| sms_only | body | boolean | 아니오 | — | SMS 단독 발송 여부 | +| is_active | body | boolean | 아니오 | — | 발송 활성 여부 | +| content | body | object | 아니오 | nullable | 카카오 등록 페이로드 (아래 매트릭스). SMS 단독 알림은 content 없이 생성 가능 — 검수 신청 시점에 존재를 재검한다 | + +**content 검증 매트릭스** (`content` 를 보냈을 때만 적용. 서버가 선택 유형과 무관한 필드·빈 값을 검증 전에 제거(prune)하므로 화면은 폼 상태 전체를 그대로 보내도 된다) + +| 필드 | 타입 | 필수 | 제약 | +| --- | --- | --- | --- | +| content.templateName | string | content 있으면 필수 | max 200 | +| content.templateMessageType | string | content 있으면 필수 | `BA`(기본형)/`EX`(부가정보형)/`AD`(채널추가형)/`MI`(복합형) | +| content.templateEmphasizeType | string | content 있으면 필수 | `NONE`/`TEXT`(강조표기)/`IMAGE`(이미지형)/`ITEM_LIST`(아이템리스트) | +| content.templateContent | string | content 있으면 필수 | max 1000 | +| content.templatePreviewMessage | string | 아니오 | max 40 | +| content.categoryCode | string | content 있으면 필수 | max 20 (`GET /admin/alimtalk-templates/categories` 의 코드) | +| content.securityFlag | boolean | 아니오 | — | +| content.templateExtra | string | messageType `EX`/`MI` 이면 필수 | 부가정보 | +| content.templateTitle / templateSubtitle | string | emphasizeType `TEXT` 이면 필수쌍 | 길이 수치 미기재 항목은 kapi 가 최종 게이트 | +| content.templateHeader | string | 아니오 | max 16 | +| content.templateImageName / templateImageUrl | string | emphasizeType `IMAGE` 이면 필수쌍 | url max 500 — 업로드 프록시(`POST /admin/templates/image`) 응답값만 기입 | +| content.templateItem | object | emphasizeType `ITEM_LIST` 이면 필수 | `list` 2~10개 필수 · 각 `title` max 6 · `description` max 23 / `summary.title` max 6 · `summary.description` max 14 | +| content.templateItemHighlight | object | 아니오 | 썸네일(imageUrl) 없으면 title ≤30·description ≤19, 있으면 ≤21·≤13 / imageUrl max 500 | +| content.templateRepresentLink | object | 아니오 | linkMo/linkPc/linkAnd/linkIos 각 max 500 | +| content.buttons | array | 아니오 | max 5 · 각 `name` 필수 max 14 · `linkType` in `WL,AL,DS,BK,MD,AC,BC,BT,P1,P2,P3,TN,MP` | +| content.quickReplies | array | 아니오 | max 10 · 각 `name` 필수 · `linkType` in `WL,AL,BK,MD,BC,BT` | + +버튼·바로연결 항목별 조건부 필수: `WL` → `linkMo` / `AL` → `linkAnd`+`linkIos` 둘 다 / `TN` → `telNumber` / `P1`~`P3` → `pluginId`. + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "notification_type": "order_completed", + "alimtalk_enabled": true, + "fallback_sms_enabled": true, + "sms_body": {"ko": "#{user_name}님, 주문이 완료되었습니다.", "en": "#{user_name}, your order is complete."}, + "content": { + "templateName": "주문 완료 안내", + "templateMessageType": "BA", + "templateEmphasizeType": "NONE", + "templateContent": "#{user_name}님, 주문 #{order_id} 이(가) 완료되었습니다.", + "categoryCode": "004001", + "buttons": [ + { "name": "주문 확인", "linkType": "WL", "linkMo": "https://example.com/orders" } + ] + } +} +``` + +**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일) + +**응답 예시** + +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "알림 템플릿을 저장했습니다.", + "data": { + "template": { + "id": 4, + "notification_type": "order_completed", + "alimtalk_enabled": true, + "template_code": null, + "sender_key": null, + "content": { "templateName": "주문 완료 안내", "templateMessageType": "BA", "templateEmphasizeType": "NONE", "templateContent": "#{user_name}님, 주문 #{order_id} 이(가) 완료되었습니다.", "categoryCode": "004001", "buttons": [{ "name": "주문 확인", "linkType": "WL", "linkMo": "https://example.com/orders" }] }, + "approved_content": null, + "status": "draft", + "is_approved": false, + "inspection_detail": null, + "requested_at": null, + "approved_at": null, + "last_synced_at": null, + "fallback_sms_enabled": true, + "sms_body": {"ko": "#{user_name}님, 주문이 완료되었습니다.", "en": "#{user_name}, your order is complete."}, + "sms_only": false, + "is_active": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 검증 실패 (알림 정의 부재·이미 등록된 notification_type·content 매트릭스 위반 등) | + +**설명** + +템플릿 행을 `status=draft` 로 생성한다. 이 시점에는 kapi 를 호출하지 않는다 — 카카오 등록은 +검수 신청(`POST /admin/templates/{id}/request`) 시점에 수행된다. + + +### PUT /api/plugins/sirsoft-message_bizppurio/admin/templates/{id} + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.update` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | integer | 예 | 숫자만 | 템플릿 PK | +| alimtalk_enabled | body | boolean | 아니오 | — | 알림톡 사용 여부 | +| fallback_sms_enabled | body | boolean | 아니오 | — | SMS 대체발송 여부 | +| sms_body | body | object | 아니오 | nullable · 로케일별 max 2000 | 대체/단독 SMS 본문의 로케일 맵. 키는 시스템 지원 로케일(`app.translatable_locales`), 값은 각 언어 본문 | +| sms_only | body | boolean | 아니오 | — | SMS 단독 발송 여부 | +| is_active | body | boolean | 아니오 | — | 발송 활성 여부 | +| content | body | object | 아니오 | nullable · Store 와 동일 매트릭스 | 카카오 등록 페이로드 | + +`notification_type` 은 행 정체성이라 수정 대상이 아니다 — 전달돼도 무시된다. + +**요청 예시** + +```http +PUT /api/plugins/sirsoft-message_bizppurio/admin/templates/4 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "content": { + "templateName": "주문 완료 안내", + "templateMessageType": "BA", + "templateEmphasizeType": "NONE", + "templateContent": "#{user_name}님, 주문 #{order_id} 이(가) 결제 완료되었습니다.", + "categoryCode": "004001" + } +} +``` + +**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일) + +**응답 예시** + +```json +{ + "success": true, + "message": "알림 템플릿을 수정했습니다.", + "data": { + "template": { "id": 4, "status": "draft", "...": "상세 객체 필드 동일" } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 | +| 422 | Unprocessable Entity | 검증 실패, 또는 content 변경 불가 상태 ("현재 상태(:status)에서는 알림톡 내용을 수정할 수 없습니다. …") | + +**설명** + +`content` 필드를 **포함한 요청**은 `draft`/`rejected` 상태에서만 허용된다 — +`requested` 는 검수 취소를, `approved` 는 승인 취소를 먼저 해야 하며, 위반 시 422 를 반환한다. +판정은 `content` 키의 포함 여부만 보므로, 기존 값과 동일한 `content` 를 다시 보내는 것도 거부된다. +발송 설정 필드(alimtalk_enabled/fallback_sms_enabled/sms_body/sms_only/is_active)는 라이프사이클과 +무관하므로 상태와 관계없이 수정할 수 있다 — 단 그 경우 요청 본문에 `content` 를 싣지 않아야 한다. + + +### PUT /api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{notificationType} + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.delivery` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@upsertDelivery` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notificationType | path | string | 예 | max 100 · 알림 정의 존재(exists) | 코어 알림 정의 type | +| alimtalk_enabled | body | boolean | 아니오 | — | 알림톡 사용 여부 | +| fallback_sms_enabled | body | boolean | 아니오 | — | SMS 대체발송 여부 | +| sms_body | body | object | 아니오 | nullable · 로케일별 max 2000 | 대체/단독 SMS 본문의 로케일 맵. 키는 시스템 지원 로케일(`app.translatable_locales`), 값은 각 언어 본문 | +| sms_only | body | boolean | 아니오 | — | SMS 단독 발송 여부 | +| is_active | body | boolean | 아니오 | — | 발송 활성 여부 | + +**요청 예시** + +```http +PUT /api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/order_completed HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "fallback_sms_enabled": true, + "sms_body": {"ko": "#{user_name}님, 주문이 완료되었습니다.", "en": "#{user_name}, your order is complete."} +} +``` + +**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일) + +**응답 예시** + +```json +{ + "success": true, + "message": "알림 템플릿을 수정했습니다.", + "data": { + "template": { "id": 4, "notification_type": "order_completed", "status": "draft", "...": "상세 객체 필드 동일" } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | `notificationType` 에 해당하는 알림 정의가 없는 경우 등 검증 실패 | + +**설명** + +알림 설정 탭 행 하단 토글(알림톡 사용·대체 SMS·SMS 본문·SMS 단독·활성)의 즉시 저장 경로다. +대상 행이 없으면 `draft` 로 생성한다(upsert). 알림톡 content 는 이 경로로 변경할 수 없다(작성 모달 전용) +— content 를 건드리지 않으므로 상태 가드도 없다. + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/request + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.request` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@requestInspection` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | integer | 예 | 숫자만 | 템플릿 PK | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/templates/4/request HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일) + +**응답 예시** + +```json +{ + "success": true, + "message": "검수를 신청했습니다. 승인 결과는 자동 동기화되며 [새로고침]으로 즉시 확인할 수 있습니다.", + "data": { + "template": { + "id": 4, + "template_code": "g7_1a2b3c4d_1", + "status": "requested", + "requested_at": "2026-08-19T10:00:00+09:00", + "last_synced_at": "2026-08-19T10:00:00+09:00", + "...": "상세 객체 필드 동일" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 | +| 422 | Unprocessable Entity | content 미작성 / 신청 불가 상태(draft·rejected 외) / kapi 실패(`errors.bizppurio_message`·`errors.result_code`) / 발신프로필 키(sender_key) 미설정 / 템플릿 코드 채번 실패 | + +**설명** + +검수 신청의 전체 시퀀스: ① `template_code` 가 없으면(최초) 자체 채번 — `g7_{md5(notification_type) 앞 8자}_{세대}` +형식을 우리 DB 와 kapi codeCheck 양쪽에서 검증하고 충돌 시 세대를 올려 최대 3회 재시도 → +② kapi add(최초) 또는 update(재신청) 로 카카오에 등록 → ③ kapi request 로 검수 신청 → ④ `status=requested`. +add 성공 시점에만 template_code 를 행에 확정하므로, add 자체가 실패하면 다음 시도에서 다시 채번부터 진행된다. +`draft`/`rejected` 상태에서만 신청할 수 있으며(requested 는 이미 검수중, approved 는 승인 취소 먼저), +content 미작성이면 422 를 반환한다. 성공 시 `inspection_detail` 은 초기화된다. + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/cancel-request + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.cancel-request` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@cancelRequest` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | integer | 예 | 숫자만 | 템플릿 PK | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/templates/4/cancel-request HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일) + +**응답 예시** + +```json +{ + "success": true, + "message": "검수 신청을 취소했습니다.", + "data": { + "template": { "id": 4, "status": "draft", "...": "상세 객체 필드 동일" } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 | +| 422 | Unprocessable Entity | `requested` 상태가 아닌 경우 / kapi 실패 | + +**설명** + +kapi 검수 취소(카카오측 REQ→REG)를 호출한 뒤 우리 상태를 `draft` 로 되돌린다. +`requested` 상태에서만 호출할 수 있다. + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/cancel-approval + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.cancel-approval` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@cancelApproval` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | integer | 예 | 숫자만 | 템플릿 PK | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/templates/3/cancel-approval HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일) + +**응답 예시** + +```json +{ + "success": true, + "message": "승인을 취소했습니다. 이 알림의 알림톡 발송이 중단되었습니다.", + "data": { + "template": { "id": 3, "status": "draft", "approved_content": { "...": "스냅샷 유지" }, "...": "상세 객체 필드 동일" } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 | +| 422 | Unprocessable Entity | `approved` 상태가 아닌 경우 / kapi 실패 | + +**설명** + +kapi 승인 취소(카카오측 승인→등록 복귀)를 호출한 뒤 우리 상태를 `draft` 로 되돌린다. +`approved_content` 스냅샷은 유지되지만 status 가 `approved` 를 벗어나므로 **알림톡 발송이 즉시 차단**된다 +— 운영자 확인 모달이 이 효과를 사전 경고한다. `approved` 상태에서만 호출할 수 있다. + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/release + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.release` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@release` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | integer | 예 | 숫자만 | 템플릿 PK | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/templates/3/release HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일) + +**응답 예시** + +```json +{ + "success": true, + "message": "휴면 상태를 해제했습니다.", + "data": { + "template": { "id": 3, "status": "approved", "last_synced_at": "2026-08-19T10:05:00+09:00", "...": "상세 객체 필드 동일" } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 | +| 422 | Unprocessable Entity | `dormant` 상태가 아닌 경우 / kapi 실패 | + +**설명** + +kapi 휴면 해제를 호출한 뒤, 카카오측 실제 상태를 되받아 재동기화(sync)해 반영한다 — +응답의 status 는 해제 후 카카오가 알려준 실제 상태다. `dormant` 상태에서만 호출할 수 있다. + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/sync + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.sync` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@sync` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | integer | 예 | 숫자만 | 템플릿 PK | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/templates/4/sync HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일) + +**응답 예시** + +```json +{ + "success": true, + "message": "카카오 검수 상태를 동기화했습니다.", + "data": { + "template": { + "id": 4, + "status": "approved", + "approved_content": { "...": "승인 전이 시 content 동결 스냅샷" }, + "approved_at": "2026-08-19T10:10:00+09:00", + "last_synced_at": "2026-08-19T10:10:00+09:00", + "...": "상세 객체 필드 동일" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 | +| 422 | Unprocessable Entity | kapi 실패 | + +**설명** + +관리 화면 [새로고침] 버튼의 수동 상태 동기화다. kapi 상세를 조회해 카카오측 serviceStatus 를 우리 +상태로 환원해 반영한다. **승인으로 전이하는 시점에 `content` 를 `approved_content` 로 동결**(발송 SSoT)하고 +`approved_at` 을 확정하며, **반려 시 상세의 comments 를 `inspection_detail` 로 저장**한다. +알 수 없는 serviceStatus 는 상태를 덮어쓰지 않고 `last_synced_at` 만 갱신한다. +`template_code` 가 없는(카카오 등록 전) 행은 동기화 대상이 아니므로 행을 그대로 반환한다. +검수중(requested) 행 전체의 자동 동기화는 스케줄 커맨드(`bizppurio:sync-template-status`)가 담당한다. + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/image + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.image` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@uploadImage` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| image | body (multipart) | file | 예 | jpg/jpeg/png · ≤500KB · 가로 ≥500px · 가로:세로 = 2:1 | 이미지형(IMAGE) 템플릿용 이미지 파일 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/templates/image HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----boundary + +------boundary +Content-Disposition: form-data; name="image"; filename="banner.png" +Content-Type: image/png + +(binary) +------boundary-- +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 용도/설명 | +| --- | --- | --- | +| url | string | 카카오가 반환한 이미지 URL — `content.templateImageUrl` 에 그대로 기입한다 | + +**응답 예시** + +```json +{ + "success": true, + "message": "이미지를 업로드했습니다.", + "data": { + "url": "https://mud-kage.kakao.com/dn/example/img_1234567890.png" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 파일 형식/크기/치수/비율 위반 (프록시 단계 사전 검증) / kapi 업로드 실패 | + +**설명** + +이미지형(`templateEmphasizeType=IMAGE`) 템플릿용 이미지의 kapi 업로드 프록시다. kapi 제약 +(jpg/png · ≤500KB · 가로 ≥500px · 가로:세로 2:1)을 프록시 단계에서 사전 검증해 kapi 왕복 없이 +즉시 인라인 오류를 돌려준다. 성공 시 `data.url` 이 카카오 이미지 URL 이며, 화면은 이 값만 +`content.templateImageUrl` 에 기입한다(임의 URL 기입 금지). + + +### DELETE /api/plugins/sirsoft-message_bizppurio/admin/templates/{id} + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.destroy` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | integer | 예 | 숫자만 | 템플릿 PK | + +**요청 예시** + +```http +DELETE /api/plugins/sirsoft-message_bizppurio/admin/templates/4 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 용도/설명 | +| --- | --- | --- | +| kakao_deleted | boolean | 카카오측 템플릿도 함께 삭제했는지 여부 | +| kakao_skip_reason | string\|null | 카카오측 미삭제 사유: `not_registered`(카카오 등록 전) / `state_not_deletable`(삭제 가능 상태 아님) / kapi 삭제 실패 시 카카오 사유 원문. 삭제했으면 null | + +**응답 예시** + +```json +{ + "success": true, + "message": "알림 템플릿을 삭제했습니다.", + "data": { + "kakao_deleted": false, + "kakao_skip_reason": "state_not_deletable" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 | + +**설명** + +DB 행은 항상 삭제한다. 카카오측 템플릿은 삭제 가능 상태일 때만 동반 삭제한다 — kapi delete 는 +등록(REG)/반려(REJ) 상태에서만 허용되므로, 우리 상태가 `draft`/`rejected` 면 kapi 삭제를 시도하고, +그 외 상태(또는 kapi 삭제 실패)면 카카오측은 그대로 두고 DB 행만 삭제한다. 어느 쪽이든 응답의 +`kakao_deleted`·`kakao_skip_reason` 으로 결과를 명시해 운영자가 알 수 있게 한다(kapi 실패가 있어도 +이 엔드포인트는 422 가 아닌 200 으로 완료된다). diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md index f8cea569..2e390b3d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md @@ -27,15 +27,15 @@ | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| DEVICE | body | string | 아니오 | max 20 | | -| CMSGID | body | string | 아니오 | max 64 | | -| MSGID | body | string | 아니오 | max 64 | | -| PHONE | body | string | 아니오 | max 20 | | -| MEDIA | body | string | 아니오 | max 10 | | -| RESULT | body | string | 예 | max 10 | | -| REFKEY | body | string | 예 | max 32 | | -| TELRES | body | string | 아니오 | max 10 | | -| KAORES | body | string | 아니오 | max 10 | | +| DEVICE | body | string | 아니오 | max 20 | 발송 메시지 유형(SMS/LMS/MMS/AT 등 비즈뿌리오 코드) | +| CMSGID | body | string | 아니오 | max 64 | 비즈뿌리오 메시지 키(발송 요청 시 채번된 식별자) | +| MSGID | body | string | 아니오 | max 64 | 비즈뿌리오 내부 메시지 ID | +| PHONE | body | string | 아니오 | max 20 | 수신 전화번호 | +| MEDIA | body | string | 아니오 | max 10 | 실제 발송된 매체 유형(대체발송 시 요청 유형과 다를 수 있음) | +| RESULT | body | string | 예 | max 10 | 발송 결과 코드 — ResultCodeResolver 가 성공/실패와 사유로 해석 | +| REFKEY | body | string | 예 | max 32 | 발송 시 G7 이 부여한 참조 키 — 이 값으로 bizppurio_dispatches 행을 찾는다 | +| TELRES | body | string | 아니오 | max 10 | 대체발송(문자) 결과 코드 | +| KAORES | body | string | 아니오 | max 10 | 알림톡 발송 결과 코드 | **요청 예시** @@ -60,11 +60,21 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +이 엔드포인트는 `data` 를 반환하지 않는다. 비즈뿌리오 URL PUSH 규약상 수신 사실만 알리면 되므로, +성공·실패와 무관하게 `success: true` + 수신 확인 메시지만 담은 200 봉투를 돌려준다. +리포트 해석 결과(성공/실패·사유)는 `bizppurio_dispatches` 행에 기록되며 응답에는 싣지 않는다. **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "success": true, + "message": "발송 결과 리포트를 수신했습니다." +} +``` **에러 응답** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index 29dcbd63..0557050d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -41,11 +41,11 @@ return [ 'channels' => [ 'source_label' => 'Bizppurio', 'sms' => [ - 'name' => 'SMS/LMS Text', + 'name' => 'Bizppurio SMS', 'description' => 'Send notifications as SMS/LMS text messages via Bizppurio.', ], 'alimtalk' => [ - 'name' => 'Kakao Alimtalk', + 'name' => 'Bizppurio Alimtalk', 'description' => 'Send notifications as Kakao Alimtalk messages via Bizppurio.', ], ], @@ -82,27 +82,100 @@ return [ '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.', - 'template_not_sendable' => 'This template is not in a sendable (approved) state. (code: :code)', + 'image_unreadable' => 'The uploaded image file could not be read.', ], // Send skipped (channel driver send() precondition not met — recorded as "Failed" in core notification log) 'send_skipped' => [ - 'alimtalk_binding_missing' => 'Skipped sending: no alimtalk template is bound. (notification type: :type)', - 'alimtalk_kakao_content_unavailable' => 'Skipped sending: failed to fetch the approved Kakao template content. (notification type: :type)', - 'sms_template_missing' => 'Skipped sending: no SMS template found. (notification type: :type)', + 'alimtalk_template_not_approved' => 'Skipped sending: no approved alimtalk template or alimtalk delivery is off. (notification type: :type)', + 'sms_template_missing' => 'Skipped sending: no SMS body is configured. (notification type: :type)', 'recipient_phone_missing' => 'Skipped sending: recipient phone number is missing. (notification type: :type)', 'message_body_empty' => 'Skipped sending: message body is empty. (notification type: :type)', ], - // Notification-to-alimtalk template binding (NotificationBindingController responses) - 'binding' => [ - 'saved' => 'Alimtalk binding saved.', - 'removed' => 'Alimtalk binding removed.', + // Notification template lifecycle (#597 — BizppurioTemplateController responses and state guards) + 'template' => [ + 'created' => 'Notification template saved.', + 'updated' => 'Notification template updated.', + 'requested' => 'Inspection requested. The approval result syncs automatically, or use [Refresh] to check now.', + 'request_cancelled' => 'Inspection request cancelled.', + 'approval_cancelled' => 'Approval cancelled. Alimtalk delivery for this notification has stopped.', + 'released' => 'Dormant state released.', + 'synced' => 'Kakao inspection status synchronized.', + 'deleted' => 'Notification template deleted.', + 'image_uploaded' => 'Image uploaded.', + 'content_locked' => 'The alimtalk content cannot be edited in the current state (:status). Cancel the inspection request first, or cancel the approval if approved.', + 'content_missing' => 'Please compose the alimtalk template content first.', + 'request_not_allowed' => 'Inspection cannot be requested in the current state (:status).', + 'cancel_request_not_allowed' => 'The request cannot be cancelled because the template is not under inspection. (current: :status)', + 'cancel_approval_not_allowed' => 'Approval cannot be cancelled because the template is not approved. (current: :status)', + 'release_not_allowed' => 'The template is not dormant. (current: :status)', + 'code_generation_failed' => 'Failed to allocate a template code. Please try again later.', ], - // Dispatch template content cache (AlimtalkTemplateController::clearCache response) - 'cache' => [ - 'cleared' => 'Alimtalk template content cache cleared. The latest content will apply from the next dispatch.', + // Notification template validation (FormRequest — only documented limits; kapi is the final gate) + 'validation' => [ + 'link_mo_required' => 'A web link (WL) button requires a mobile link.', + 'link_and_required' => 'An app link (AL) button requires an Android scheme.', + 'link_ios_required' => 'An app link (AL) button requires an iOS scheme.', + 'tel_number_required' => 'A phone (TN) button requires a phone number.', + 'plugin_id_required' => 'A plugin (P1–P3) button requires a plugin ID.', + 'highlight_title_too_long' => 'The item highlight title must be at most :max characters.', + 'highlight_description_too_long' => 'The item highlight description must be at most :max characters.', + 'image_ratio_invalid' => 'The image must have a 2:1 width-to-height ratio. (e.g. 1000×500px)', + + // Field labels used in validation messages (FormRequest::attributes) + 'attributes' => [ + 'notification_type' => 'notification type', + 'content' => 'AlimTalk content', + 'template_name' => 'template name', + 'message_type' => 'message type', + 'emphasize_type' => 'emphasis type', + 'template_content' => 'body', + 'preview_message' => 'preview message', + 'category_code' => 'category', + 'security_flag' => 'secure template flag', + 'extra' => 'supplementary information', + 'title' => 'emphasis title', + 'subtitle' => 'emphasis subtitle', + 'header' => 'header', + 'image_name' => 'image file name', + 'image_url' => 'image URL', + 'item' => 'item list', + 'item_list' => 'items', + 'item_title' => 'item title', + 'item_description' => 'item description', + 'summary' => 'summary', + 'summary_title' => 'summary title', + 'summary_description' => 'summary description', + 'highlight' => 'highlight', + 'highlight_title' => 'highlight title', + 'highlight_description' => 'highlight description', + 'highlight_image_url' => 'highlight image URL', + 'represent_link' => 'representative link', + 'buttons' => 'buttons', + 'button_name' => 'button label', + 'button_link_type' => 'button link type', + 'quick_replies' => 'quick replies', + 'quick_reply_name' => 'quick reply label', + 'quick_reply_link_type' => 'quick reply link type', + 'link_mo' => 'mobile link', + 'link_pc' => 'PC link', + 'link_and' => 'Android scheme', + 'link_ios' => 'iOS scheme', + 'tel_number' => 'phone number', + 'plugin_id' => 'plugin ID', + 'image' => 'image file', + 'sms_body' => 'SMS body', + 'alimtalk_enabled' => 'AlimTalk enabled', + 'fallback_sms_enabled' => 'fallback SMS enabled', + 'sms_only' => 'SMS only', + 'is_active' => 'active', + 'status' => 'status', + 'search' => 'search keyword', + 'page' => 'page', + 'per_page' => 'items per page', + ], ], // Connection check (TokenCheckController response) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index b103ad3a..5652a7a3 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -41,11 +41,11 @@ return [ 'channels' => [ 'source_label' => '비즈뿌리오', 'sms' => [ - 'name' => 'SMS/LMS 문자', + 'name' => '비즈뿌리오 문자', 'description' => '비즈뿌리오를 통해 문자(SMS/LMS)로 알림을 발송합니다.', ], 'alimtalk' => [ - 'name' => '카카오 알림톡', + 'name' => '비즈뿌리오 알림톡', 'description' => '비즈뿌리오를 통해 카카오 알림톡으로 알림을 발송합니다.', ], ], @@ -82,27 +82,100 @@ return [ 'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.', 'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.', 'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.', - 'template_not_sendable' => '발송 가능(승인) 상태가 아닌 템플릿입니다. (코드: :code)', + 'image_unreadable' => '업로드한 이미지 파일을 읽을 수 없습니다.', ], // 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨) 'send_skipped' => [ - 'alimtalk_binding_missing' => '알림톡 템플릿이 연결되지 않아 발송을 건너뛰었습니다. (알림 유형: :type)', - 'alimtalk_kakao_content_unavailable' => '카카오 승인 템플릿 내용을 조회하지 못해 발송을 건너뛰었습니다. (알림 유형: :type)', - 'sms_template_missing' => 'SMS 템플릿이 없어 발송을 건너뛰었습니다. (알림 유형: :type)', + 'alimtalk_template_not_approved' => '승인된 알림톡 템플릿이 없거나 알림톡 발송이 꺼져 있어 발송을 건너뛰었습니다. (알림 유형: :type)', + 'sms_template_missing' => 'SMS 본문이 설정되지 않아 발송을 건너뛰었습니다. (알림 유형: :type)', 'recipient_phone_missing' => '수신자 전화번호가 없어 발송을 건너뛰었습니다. (알림 유형: :type)', 'message_body_empty' => '발송 본문이 비어 있어 발송을 건너뛰었습니다. (알림 유형: :type)', ], - // 알림↔알림톡 템플릿 연동 (NotificationBindingController 응답) - 'binding' => [ - 'saved' => '알림톡 연동을 저장했습니다.', - 'removed' => '알림톡 연동을 해제했습니다.', + // 알림 템플릿 라이프사이클 (#597 — BizppurioTemplateController 응답·상태 가드) + 'template' => [ + 'created' => '알림 템플릿을 저장했습니다.', + 'updated' => '알림 템플릿을 수정했습니다.', + 'requested' => '검수를 신청했습니다. 승인 결과는 자동 동기화되며 [새로고침]으로 즉시 확인할 수 있습니다.', + 'request_cancelled' => '검수 신청을 취소했습니다.', + 'approval_cancelled' => '승인을 취소했습니다. 이 알림의 알림톡 발송이 중단되었습니다.', + 'released' => '휴면 상태를 해제했습니다.', + 'synced' => '카카오 검수 상태를 동기화했습니다.', + 'deleted' => '알림 템플릿을 삭제했습니다.', + 'image_uploaded' => '이미지를 업로드했습니다.', + 'content_locked' => '현재 상태(:status)에서는 알림톡 내용을 수정할 수 없습니다. 검수중이면 신청 취소를, 승인됨이면 승인 취소를 먼저 하세요.', + 'content_missing' => '알림톡 템플릿 내용을 먼저 작성하세요.', + 'request_not_allowed' => '현재 상태(:status)에서는 검수를 신청할 수 없습니다.', + 'cancel_request_not_allowed' => '검수중 상태가 아니어서 신청을 취소할 수 없습니다. (현재: :status)', + 'cancel_approval_not_allowed' => '승인 상태가 아니어서 승인을 취소할 수 없습니다. (현재: :status)', + 'release_not_allowed' => '휴면 상태가 아니어서 해제할 수 없습니다. (현재: :status)', + 'code_generation_failed' => '템플릿 코드를 채번하지 못했습니다. 잠시 후 다시 시도하세요.', ], - // 발송용 템플릿 내용 캐시 (AlimtalkTemplateController::clearCache 응답) - 'cache' => [ - 'cleared' => '알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.', + // 알림 템플릿 검증 (FormRequest — 문서 수치 명시 제약만, 그 외는 kapi 최종 게이트) + 'validation' => [ + 'link_mo_required' => '웹링크(WL) 버튼은 모바일 링크가 필요합니다.', + 'link_and_required' => '앱링크(AL) 버튼은 Android 스킴이 필요합니다.', + 'link_ios_required' => '앱링크(AL) 버튼은 iOS 스킴이 필요합니다.', + 'tel_number_required' => '전화(TN) 버튼은 전화번호가 필요합니다.', + 'plugin_id_required' => '플러그인(P1~P3) 버튼은 플러그인 ID 가 필요합니다.', + 'highlight_title_too_long' => '아이템 하이라이트 타이틀은 :max자 이하여야 합니다.', + 'highlight_description_too_long' => '아이템 하이라이트 설명은 :max자 이하여야 합니다.', + 'image_ratio_invalid' => '이미지는 가로:세로 2:1 비율이어야 합니다. (예: 1000×500px)', + + // 검증 오류 문구에 쓰이는 필드 라벨 (FormRequest::attributes) + 'attributes' => [ + 'notification_type' => '알림 유형', + 'content' => '알림톡 내용', + 'template_name' => '템플릿명', + 'message_type' => '메시지 유형', + 'emphasize_type' => '강조 유형', + 'template_content' => '본문', + 'preview_message' => '미리보기 문구', + 'category_code' => '카테고리', + 'security_flag' => '보안 템플릿 여부', + 'extra' => '부가정보', + 'title' => '강조 타이틀', + 'subtitle' => '강조 서브타이틀', + 'header' => '헤더', + 'image_name' => '이미지 파일명', + 'image_url' => '이미지 URL', + 'item' => '아이템 리스트', + 'item_list' => '아이템 목록', + 'item_title' => '아이템 제목', + 'item_description' => '아이템 설명', + 'summary' => '요약', + 'summary_title' => '요약 제목', + 'summary_description' => '요약 설명', + 'highlight' => '하이라이트', + 'highlight_title' => '하이라이트 타이틀', + 'highlight_description' => '하이라이트 설명', + 'highlight_image_url' => '하이라이트 이미지 URL', + 'represent_link' => '대표 링크', + 'buttons' => '버튼', + 'button_name' => '버튼명', + 'button_link_type' => '버튼 링크 유형', + 'quick_replies' => '바로연결', + 'quick_reply_name' => '바로연결명', + 'quick_reply_link_type' => '바로연결 링크 유형', + 'link_mo' => '모바일 링크', + 'link_pc' => 'PC 링크', + 'link_and' => 'Android 스킴', + 'link_ios' => 'iOS 스킴', + 'tel_number' => '전화번호', + 'plugin_id' => '플러그인 ID', + 'image' => '이미지 파일', + 'sms_body' => 'SMS 본문', + 'alimtalk_enabled' => '알림톡 사용', + 'fallback_sms_enabled' => '대체 SMS 사용', + 'sms_only' => 'SMS 단독 발송', + 'is_active' => '활성 여부', + 'status' => '상태', + 'search' => '검색어', + 'page' => '페이지', + 'per_page' => '페이지당 건수', + ], ], // 연결 확인 (TokenCheckController 응답) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/package.json b/plugins/_bundled/sirsoft-message_bizppurio/package.json index 25c55585..3a8d1cf9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/package.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/package.json @@ -8,7 +8,9 @@ "build": "vite build", "preview": "vite preview", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts", + "test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index b5048d2b..3722d29f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -151,6 +151,11 @@ class Plugin extends AbstractPlugin * 발송이 불가능한 죽은 설정이라 보존할 가치가 없고, 재설치 시 activate() 가 재시딩하며 * 자동으로 복원된다. * + * 반대로 우리 소유 테이블(bizppurio_templates·bizppurio_dispatches)은 여기서 손대지 않는다. + * 코어 PluginManager 가 "데이터도 함께 삭제" 를 선택했을 때만 rollbackMigrations() 로, + * 그것도 uninstall() 호출 **이전에** 제거하기 때문이다. 여기에 dropIfExists 를 더하면 + * 테이블 정리 책임이 두 곳으로 갈라지고, 데이터 보존을 선택한 운영자의 테이블까지 지운다. + * * @return bool 제거 성공 여부 */ public function uninstall(): bool @@ -333,16 +338,6 @@ class Plugin extends AbstractPlugin 'sensitive' => true, 'required' => false, ], - 'template_cache_minutes' => [ - 'type' => 'number', - 'default' => 60, - 'label' => ['ko' => '알림톡 내용 캐시 시간(분)', 'en' => 'Alimtalk content cache (minutes)'], - 'hint' => [ - 'ko' => '카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용합니다(기본 60분). 이 시간이 지나면 다음 발송 때 최신 내용을 다시 가져옵니다. 0으로 두면 매번 최신 내용을 가져옵니다 — 발송이 많으면 조회 제한에 걸릴 수 있어 권장하지 않습니다. 카카오에서 템플릿을 방금 수정했다면 아래 [캐시 초기화]로 즉시 반영할 수 있습니다.', - 'en' => 'Reuses Kakao alimtalk template content for this period (default 60 minutes). After it expires, the latest content is fetched on the next dispatch. Set to 0 to always fetch the latest — not recommended for high volume as it may hit rate limits. If you just edited a template in Kakao, use [Clear cache] below to apply it immediately.', - ], - 'required' => false, - ], ]; } @@ -363,7 +358,6 @@ class Plugin extends AbstractPlugin 'api_key' => '', 'sender_number' => '', 'sender_key' => '', - 'template_cache_minutes' => 60, ]; } @@ -457,6 +451,26 @@ class Plugin extends AbstractPlugin ]; } + /** + * 스케줄 작업 목록 반환 (#597 §3.4). + * + * 검수중(requested) 알림톡 템플릿의 카카오 검수 상태를 30분 주기로 동기화한다. + * 커맨드는 requested 행이 없으면 카카오 API 를 호출하지 않으며, 화면의 수동 + * [새로고침]이 동등 기능을 제공하므로 cron 미가동 환경에서도 승인 확인이 가능하다. + * + * @return array> 스케줄 정의 목록 + */ + public function getSchedules(): array + { + return [ + [ + 'command' => 'bizppurio:sync-template-status', + 'schedule' => 'everyThirtyMinutes', + 'description' => '비즈뿌리오 알림톡 템플릿 검수 상태 동기화', + ], + ]; + } + /** * 확장 미들웨어 선언 (self-gate) * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json index 9d4c2d1e..cf17dc15 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json @@ -1,139 +1,800 @@ { "extension_point": "notification_definition_row_footer", - "comment": "알림 설정 알림톡 탭의 각 알림 행 하단(코어 notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼을 채운다(계획서 §6-2, Phase 6 재설계). channel==='alimtalk'일 때만 노출. extensionPointProps.definition 으로 이 행의 알림(def)을 받고, 연결 정보는 bizppurioBindings(overlay notification_tab_core.json 이 등록·조회) 에서 def.type 으로 읽는다. [연결/변경] → 우리 연결 모달(modal_bizppurio_binding, overlay 가 등록)을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.", + "comment": "알림 설정 '비즈뿌리오' 통합 탭의 각 알림 행 하단(코어 notification_definition_row_footer 슬롯)에 알림톡 라이프사이클 줄 + SMS 설정 줄을 채운다(#597 §4.1). activeChannel==='alimtalk'(통합 탭 정체성)일 때만 노출. 요약 데이터는 bizppurioTemplates(각 탭 오버레이가 등록·조회, GET templates/map)에서 def.type 으로 읽고, 작성/수정은 modal_bizppurio_template(오버레이 등록), SMS 본문은 modal_bizppurio_sms, 반려 사유는 modal_bizppurio_rejection, 승인 취소 경고는 modal_bizppurio_cancel_approval 을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.", "components": [ { - "id": "bizppurio_row_binding", + "id": "bizppurio_row_lifecycle", "type": "basic", "name": "Div", "if": "{{extensionPointProps.activeChannel === 'alimtalk'}}", "props": { - "className": "flex-between gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-700" + "className": "mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 space-y-2" }, "children": [ { "type": "basic", "name": "Div", - "props": { - "className": "flex-center gap-2 min-w-0" - }, "children": [ { "type": "basic", - "name": "Icon", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-comment-dots", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_label", + "props": { + "className": "text-sm font-medium text-gray-700 dark:text-gray-200 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "{{$t('sirsoft-message_bizppurio.template.status.' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten'))}}", + "props": { + "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'stopped' || (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 flex-shrink-0" + }, + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}" + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.row.synced_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at ?? '').slice(5, 10)}}", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 flex-shrink-0" + }, + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}" + } + ], "props": { - "name": "fas fa-link", - "size": "sm", - "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + "className": "flex-center gap-2 min-w-0 flex-wrap" } }, { - "comment": "연결됨: 템플릿명(또는 코드)", "type": "basic", - "name": "Span", - "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}", - "props": { - "className": "text-sm text-gray-700 dark:text-gray-200 truncate" - }, - "text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}" - }, - { - "comment": "소실 경고 — 연결됨이지만 연결한 카카오 템플릿이 삭제·차단·미승인되어 발송 불가(is_unavailable). 승인 목록 대조가 가능했고(=is_unavailable 필드 존재) true 일 때만 표시. 카카오 조회 실패 시 서비스가 필드를 부여하지 않아 배지도 뜨지 않는다(오탐 방지).", - "type": "basic", - "name": "Span", - "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.is_unavailable === true}}", - "props": { - "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100 flex-shrink-0" - }, + "name": "Div", "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "fas fa-triangle-exclamation", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.unavailable" } - ] - }, - { - "comment": "⑧ SMS 대체발송 배지 — 연결됨일 때만, 켜짐/꺼짐을 색으로 구분해 둘 다 표시(다크에서 묽지 않게 solid 색)", - "type": "basic", - "name": "Span", - "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled}}", + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Toggle", + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}", + "props": { + "checked": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled ?? false}}", + "size": "sm" + }, + "actions": [ + { + "type": "change", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": "{{!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled ?? false)}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_enabled", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}" + } + ], + "props": { + "className": "flex-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_reject_view": { + "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", + "comments": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []}}" + } + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_rejection" + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_view_reason", + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'rejected' && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_upload": { "uploading": false, "error": null }, + "bz_tpl_modal": { + "id": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id ?? null}}", + "notification_type": "{{extensionPointProps.definition?.type}}", + "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", + "variables": "{{extensionPointProps.definition?.variables ?? []}}", + "status": "draft", + "inspection_detail": [], + "isSaving": false, + "error": null, + "sender_profile": "", + "content": { + "templateName": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", + "templateMessageType": "BA", + "templateEmphasizeType": "NONE", + "templateContent": "{{(((extensionPointProps.definition?.templates ?? []).find(tpl => tpl.channel === 'alimtalk')?.body?.[$locale] ?? '').split('{').join('#{'))}}", + "templateExtra": "", + "templateHeader": "", + "templateTitle": "", + "templateSubtitle": "", + "templateImageName": "", + "templateImageUrl": "", + "categoryCode": "", + "buttons": [], + "quickReplies": [], + "templateItem": { + "list": [], + "summary": { + "title": "", + "description": "" + } + }, + "templateItemHighlight": { + "title": "", + "description": "", + "imageUrl": "" + } + } + } + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioCategories" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioProfiles" + } + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_compose", + "if": "{{!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) || !(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}", + "params": { + "method": "GET" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_upload": { "uploading": false, "error": null }, + "bz_tpl_modal": { + "id": "{{response.data?.template?.id}}", + "notification_type": "{{response.data?.template?.notification_type}}", + "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? response.data?.template?.notification_type}}", + "variables": "{{extensionPointProps.definition?.variables ?? []}}", + "status": "{{response.data?.template?.status}}", + "inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}", + "isSaving": false, + "error": null, + "sender_profile": "", + "content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}" + } + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioCategories" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioProfiles" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit", + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'draft' || (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'rejected')}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border 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": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval": { + "id": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}", + "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", + "isSaving": false + } + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_cancel_approval" + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_approved", + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'approved'}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30", + "disabled": "{{_global.bz_row_busy ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_row_busy": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_row_busy": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.requested_toast", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_row_busy": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_row_busy ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_request", + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'draft'}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border 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": "sequence", + "params": { + "actions": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/cancel-request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.cancel_request_toast", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_cancel_request", + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'requested'}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/release", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.released_toast", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_release", + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'dormant'}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border 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": "sequence", + "params": { + "actions": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/sync", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.synced_toast", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_refresh", + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.template_code}}" + } + ], "props": { - "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-200 flex-shrink-0" - }, - "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "comment-sms", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.fallback_on" } - ] - }, - { - "type": "basic", - "name": "Span", - "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && !(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}", - "props": { - "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 flex-shrink-0" - }, - "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "comment-sms", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.fallback_off" } - ] - }, - { - "type": "basic", - "name": "Span", - "if": "{{!(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code)}}", - "props": { - "className": "text-sm text-gray-400 dark:text-gray-500" - }, - "text": "$t:sirsoft-message_bizppurio.binding.unbound" + "className": "flex-center gap-1.5 flex-shrink-0 flex-wrap" + } } - ] + ], + "props": { + "className": "flex-between gap-2" + } }, { "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "text-xs font-medium px-3 py-1.5 rounded-md border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30 whitespace-nowrap flex-shrink-0" - }, - "text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ? '$t:sirsoft-message_bizppurio.binding.btn_change' : '$t:sirsoft-message_bizppurio.binding.btn_connect'}}", - "actions": [ + "name": "Div", + "children": [ { - "type": "click", - "handler": "sequence", - "params": { - "actions": [ - { - "comment": "모달 상태 seed — 대상 알림 type·이름·변수 + 기존 연결값 프리필", - "handler": "setState", - "params": { - "target": "global", - "bizppurio_binding_modal": { - "notification_type": "{{extensionPointProps.definition?.type}}", - "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", - "variables": "{{extensionPointProps.definition?.variables ?? []}}", - "template_code": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ?? ''}}", - "template_name": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name ?? ''}}", - "fallback_sms": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ?? false}}", - "isSaving": false + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-comment-sms", + "size": "sm", + "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Label", + "comment": "알림톡 실패 시 SMS 대체발송 — 변경 즉시 저장(행이 없으면 draft 로 생성)", + "props": { + "className": "flex items-center gap-1.5 cursor-pointer select-none" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "autoBinding": false, + "checked": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}", + "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600" + }, + "actions": [ + { + "type": "change", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}", + "params": { + "method": "PUT", + "body": { + "fallback_sms_enabled": "{{$event.target.checked}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-300" } } + ] + }, + { + "type": "basic", + "name": "Label", + "comment": "알림톡 없이 SMS 만 발송 — 변경 즉시 저장", + "props": { + "className": "flex items-center gap-1.5 cursor-pointer select-none" }, - { - "handler": "openModal", - "target": "modal_bizppurio_binding" - }, - { - "comment": "승인 템플릿 드롭다운 옵션 조회(모달 열 때만 → 전 탭 에러 없음). 자격증명 미설정 시 422 는 fallback 으로 조용히 처리되며, openModal 뒤에 두어 조회 실패가 모달 표시를 막지 않게 한다.", - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioApprovedTemplates" + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "autoBinding": false, + "checked": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only)}}", + "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600" + }, + "actions": [ + { + "type": "change", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}", + "params": { + "method": "PUT", + "body": { + "sms_only": "{{$event.target.checked}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.row.sms_only", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-300" + } } - } - ] + ] + }, + { + "type": "basic", + "name": "Span", + "comment": "SMS 본문 미리보기. 로케일 선택·폴백·유무 판정은 서버(BizppurioTemplate::getLocalizedSmsBody/hasSmsBody)가 하고 화면은 결과만 그린다 — 여기서 맵을 직접 훑으면 발송 게이트와 규칙이 갈린다.", + "text": "{{$t('sirsoft-message_bizppurio.template.row.sms_body_prefix')}} {{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '').slice(0, 40) + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '').length > 40 ? '…' : '')}}", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 truncate min-w-0" + }, + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body === true}}" + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.row.sms_body_missing", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + }, + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body !== true}}" + } + ], + "props": { + "className": "flex-center gap-3 min-w-0 flex-wrap" } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border 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": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal": { + "notification_type": "{{extensionPointProps.definition?.type}}", + "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", + "variables": "{{extensionPointProps.definition?.variables ?? []}}", + "body": "{{Object.assign({}, bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body)}}", + "editLang": "{{$locale}}", + "isSaving": false + } + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_sms" + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_sms" } - ] + ], + "props": { + "className": "flex-between gap-2" + } } ] } ], "priority": 320 -} \ No newline at end of file +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json index a1c53c27..fa29ff1e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json @@ -1,26 +1,26 @@ { "target_layout": "admin_board_settings", - "comment": "게시판 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 게시판 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 게시판 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).", + "comment": "[게시판 면 — notification_tab_core.json 과 동일 패턴, target/컴포넌트 id 만 게시판 전용] 알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 알림 행 하단(notification_definition_row_footer 슬롯 — notification_row_footer.json)에 알림톡 라이프사이클·SMS 설정 줄 ③ 작성/신청·SMS 본문·승인 취소·반려 사유 모달 4종. 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회한다. 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염).", "data_sources": [ { - "id": "bizppurioBindings", - "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.", + "id": "bizppurioTemplates", + "comment": "행 라이프사이클 표시용 요약 맵(notification_type 키). auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지, 비즈뿌리오 탭 진입 시 init_actions 로 조회.", "type": "api", - "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates/map", "method": "GET", "auto_fetch": false, "auth_required": true, "fallback": { "data": { - "bindings": {} + "templates": {} } } }, { - "id": "bizppurioApprovedTemplates", - "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.", + "id": "bizppurioCategories", + "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 자격증명 미설정 422 는 suppress + fallback.", "type": "api", - "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories", "method": "GET", "auto_fetch": false, "auth_required": true, @@ -31,7 +31,27 @@ }, "fallback": { "data": { - "templates": [], + "categories": [], + "load_failed": true + } + } + }, + { + "id": "bizppurioProfiles", + "comment": "작성 모달 발신프로필 참고 셀렉트(kapi profile/use 위임). 모달 열 때만 조회. 실패는 suppress + fallback.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" + } + }, + "fallback": { + "data": { + "profiles": [], "load_failed": true } } @@ -41,13 +61,13 @@ { "target_id": "board_notif_channel_content", "position": "prepend_child", - "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.", + "comment": "비즈뿌리오 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드. 탭 정체성은 query.channel==='alimtalk'.", "components": [ { "id": "bizppurio_board_status_banner", "type": "basic", "name": "Div", - "if": "{{['sms','alimtalk'].includes(query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true)}}", + "if": "{{['sms','alimtalk'].includes(query.channel ?? '') && (((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false) || ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true))}}", "props": { "className": "mt-4 mb-3 space-y-2" }, @@ -64,9 +84,6 @@ { "type": "basic", "name": "Div", - "props": { - "className": "flex-center gap-2" - }, "children": [ { "type": "basic", @@ -80,12 +97,15 @@ { "type": "basic", "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.not_ready", "props": { "className": "text-sm text-red-800 dark:text-red-200" - }, - "text": "$t:sirsoft-message_bizppurio.banner.not_ready" + } } - ] + ], + "props": { + "className": "flex-center gap-2" + } }, { "type": "basic", @@ -94,7 +114,6 @@ "type": "button", "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" }, - "text": "$t:sirsoft-message_bizppurio.banner.setup_action", "actions": [ { "type": "click", @@ -103,7 +122,8 @@ "path": "/admin/plugins/sirsoft-message_bizppurio/settings" } } - ] + ], + "text": "$t:sirsoft-message_bizppurio.banner.setup_action" } ] }, @@ -128,10 +148,10 @@ { "type": "basic", "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.test_mode", "props": { "className": "text-sm text-amber-800 dark:text-amber-200" - }, - "text": "$t:sirsoft-message_bizppurio.banner.test_mode" + } } ] } @@ -139,7 +159,7 @@ }, { "id": "bizppurio_board_alimtalk_guide", - "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.", + "comment": "비즈뿌리오 탭 상시 안내 박스 — 알림톡 템플릿을 시스템에서 작성→검수 신청→승인 후 발송됨을 안내. alimtalk 탭 정체성일 때만.", "type": "basic", "name": "Div", "if": "{{(query.channel) === 'alimtalk'}}", @@ -159,10 +179,10 @@ { "type": "basic", "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.tab_guide", "props": { "className": "text-sm text-blue-800 dark:text-blue-200" - }, - "text": "$t:sirsoft-message_bizppurio.binding.list_guide" + } } ] } @@ -171,48 +191,213 @@ ], "modals": [ { - "id": "modal_bizppurio_binding", - "comment": "알림톡 연결 전용 모달(우리 소유). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.", + "id": "modal_bizppurio_template", + "comment": "알림톡 템플릿 작성/신청 모달(#597 §4.2, 3면+플러그인 관리 화면 공유). 값은 _global.bz_tpl_modal 에 담는다(모달 컨텍스트 분리). content 는 kapi 등록 페이로드 필드 그대로이며, 유형 전환 잔여 필드는 서버(FormRequest)가 정돈한다. 신규/기존 분기는 id 유무의 저장 버튼 if 2벌(POST/PUT)로 처리한다.", "type": "composite", "name": "Modal", "props": { - "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}", - "size": "md", + "title": "$t:sirsoft-message_bizppurio.template.form.modal_title|name={{_global.bz_tpl_modal?.notification_name ?? ''}}", + "size": "xl", "closeOnOverlayClick": false }, "children": [ { - "id": "bizppurio_board_binding_modal_body", + "id": "bizppurio_board_tpl_modal_body", "type": "basic", "name": "Div", - "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}", "props": { - "className": "space-y-4" + "className": "space-y-4 max-h-[65vh] overflow-y-auto pr-1" }, "children": [ { "type": "basic", "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.form.rejected_banner", + "props": { + "className": "text-xs font-medium text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2" + } + }, + { + "type": "basic", + "name": "P", + "iteration": { + "source": "{{_global.bz_tpl_modal?.inspection_detail ?? []}}", + "item_var": "cm" + }, + "props": { + "className": "text-xs text-red-700 dark:text-red-300 mt-1" + }, + "text": "{{(cm.createdAt ?? '').slice(0, 10)}} — {{cm.content ?? ''}}" + } + ], "props": { - "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" }, + "if": "{{_global.bz_tpl_modal?.status === 'rejected' && (_global.bz_tpl_modal?.inspection_detail ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Div", "children": [ { "type": "basic", "name": "Icon", "props": { - "name": "fas fa-circle-info", + "name": "fas fa-triangle-exclamation", "size": "sm", - "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + "className": "text-red-600 dark:text-red-400 flex-shrink-0" } }, { "type": "basic", "name": "Span", + "text": "{{_global.bz_tpl_modal?.error}}", "props": { - "className": "text-xs text-teal-700 dark:text-teal-300" + "className": "text-xs text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "if": "{{_global.bz_tpl_modal?.error}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile" + }, + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-full text-sm", + "value": "{{_global.bz_tpl_modal?.sender_profile ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.sender_profile_placeholder')}, ...((bizppurioProfiles?.data?.profiles ?? []).map(pr => ({value: pr.senderKey ?? '', label: (pr.name ?? pr.senderKey ?? '')})))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.sender_profile": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.category *" + }, + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-full text-sm", + "value": "{{_global.bz_tpl_modal?.content?.categoryCode ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.category_placeholder')}, ...((bizppurioCategories?.data?.categories ?? []).map(cat => ({value: cat.code ?? '', label: ((cat.groupName ? cat.groupName + ' › ' : '') + (cat.name ?? cat.code ?? ''))})))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.categoryCode": "{{$event.target.value}}" + } + } + ] + } + ] + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-3" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" }, - "text": "$t:sirsoft-message_bizppurio.binding.section_hint" + "text": "$t:sirsoft-message_bizppurio.template.form.name *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateName ?? ''}}", + "name": "bz_template_name", + "maxLength": 200 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateName": "{{$event.target.value}}" + } + } + ] } ] }, @@ -226,144 +411,1328 @@ "props": { "className": "text-secondary block text-xs font-medium mb-1" }, - "text": "$t:sirsoft-message_bizppurio.binding.connected_template" - }, - { - "type": "composite", - "name": "Select", - "props": { - "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", - "className": "w-full text-sm", - "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bizppurio_binding_modal.template_code": "{{$event.target.value}}", - "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", - "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" - } - } - ] - }, - { - "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.", - "type": "basic", - "name": "P", - "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}", - "props": { - "className": "text-xs text-red-600 dark:text-red-400 mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed" - }, - { - "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.", - "type": "basic", - "name": "P", - "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}", - "props": { - "className": "text-xs text-amber-600 dark:text-amber-400 mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { - "className": "flex-center gap-2" - }, - "children": [ - { - "type": "composite", - "name": "Toggle", - "props": { - "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}", - "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Span", - "props": { - "className": "text-secondary text-xs" - }, - "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms" - } - ] - }, - { - "id": "bizppurio_board_binding_fallback_hint", - "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.", - "type": "basic", - "name": "P", - "props": { - "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint" - }, - { - "id": "bizppurio_board_binding_variables", - "type": "basic", - "name": "Div", - "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}", - "props": { - "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" - }, - "children": [ - { - "type": "basic", - "name": "P", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + "text": "$t:sirsoft-message_bizppurio.template.form.message_type *" }, { "type": "basic", "name": "Div", + "comment": "메시지 유형 — BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형", "props": { - "className": "flex flex-wrap gap-1" + "className": "flex flex-wrap gap-1.5" }, "children": [ { "type": "basic", - "name": "Span", - "iteration": { - "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}", - "item_var": "v" - }, + "name": "Button", "props": { - "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300" + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'BA' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" }, - "text": "{{'#{' + (v.key ?? '') + '}'}}" + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateMessageType": "BA" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.BA" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'EX' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "EX" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.EX" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'AD' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "AD" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.AD" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'MI' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "MI" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.MI" } ] } ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_type *" + }, + { + "type": "basic", + "name": "Div", + "comment": "강조 유형 — NONE / TEXT 강조표기 / IMAGE 이미지 / ITEM_LIST 아이템리스트", + "props": { + "className": "flex flex-wrap gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "NONE" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.NONE" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "TEXT" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.TEXT" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "IMAGE" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.IMAGE" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'ITEM_LIST' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "ITEM_LIST" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.ITEM_LIST" + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_title *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateTitle ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateTitle": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_subtitle *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateSubtitle ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateSubtitle": "{{$event.target.value}}" + } + } + ] + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-3" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.image *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "file", + "accept": ".jpg,.jpeg,.png", + "className": "block w-full text-xs text-gray-600 dark:text-gray-300" + }, + "actions": [ + { + "type": "change", + "handler": "sirsoft-message_bizppurio.uploadTemplateImage", + "params": { + "stateTarget": "global", + "statePathUrl": "bz_tpl_modal.content.templateImageUrl", + "statePathName": "bz_tpl_modal.content.templateImageName", + "statePathStatus": "bz_tpl_upload" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.image_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.form.image_uploading", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400" + }, + "if": "{{_global.bz_tpl_upload?.uploading}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_upload?.error ?? ''}}", + "props": { + "className": "text-xs text-red-600 dark:text-red-400" + }, + "if": "{{_global.bz_tpl_upload?.error}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_modal?.content?.templateImageName}} — {{_global.bz_tpl_modal?.content?.templateImageUrl}}", + "props": { + "className": "text-xs text-green-700 dark:text-green-400 break-all" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateImageUrl}}" + } + ], + "props": { + "className": "space-y-1" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_highlight" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_title", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {title: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_description", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {description: $event.target.value})}}" + } + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-1.5" + } + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_image_url", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.imageUrl ?? ''}}", + "maxLength": 500 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {imageUrl: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_list" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.templateItem?.list ?? []).length >= 10}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: [...(_global.bz_tpl_modal?.content?.templateItem?.list ?? []), {title: '', description: ''}]})}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.item_add" + } + ], + "props": { + "className": "flex-between mt-2" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.item_list_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.templateItem?.list ?? []}}", + "item_var": "itm", + "index_var": "itmIdx" + }, + "props": { + "className": "flex items-center gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 6, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", + "value": "{{itm.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {title: $event.target.value}) : r)})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 23, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_description", + "value": "{{itm.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {description: $event.target.value}) : r)})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_item", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).filter((r, i) => i !== itmIdx)})}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium mt-2" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_summary" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 6, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", + "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {title: $event.target.value})})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 14, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.summary_description", + "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {description: $event.target.value})})}}" + } + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-1.5" + } + } + ], + "props": { + "className": "space-y-1.5" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.content *" + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.content?.templateContent ?? '').length}}/1000", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_template_content", + "rows": 6, + "maxLength": 1000, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 font-mono", + "value": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateContent": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{_global.bz_tpl_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "type": "button", + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}", + "actions": [ + { + "type": "click", + "handler": "sirsoft-message_bizppurio.insertVariable", + "params": { + "variable": "{{v.key}}", + "target": "bz_template_content", + "stateTarget": "global", + "statePath": "bz_tpl_modal.content.templateContent" + } + } + ] + } + ], + "props": { + "className": "flex flex-wrap gap-1" + } + } + ], + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "if": "{{(_global.bz_tpl_modal?.variables ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.extra *" + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "rows": 2, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content?.templateExtra ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateExtra": "{{$event.target.value}}" + } + } + ] + } + ], + "if": "{{_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.header" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateHeader ?? ''}}", + "maxLength": 16 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateHeader": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.header_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.buttons" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{[...(_global.bz_tpl_modal?.content?.buttons ?? []), {name: '', linkType: 'WL'}]}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.button_add" + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.buttons ?? []}}", + "item_var": "buttonsItem", + "index_var": "buttonsIdx" + }, + "props": { + "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-xs", + "value": "{{buttonsItem.linkType ?? 'WL'}}", + "options": "{{['WL','AL','DS','BK','MD','AC','BC','BT','P1','P2','P3','TN','MP'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", + "value": "{{buttonsItem.name ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_button", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).filter((b, i) => i !== buttonsIdx)}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ], + "props": { + "className": "flex items-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", + "value": "{{buttonsItem.linkMo ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", + "value": "{{buttonsItem.linkPc ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", + "value": "{{buttonsItem.linkAnd ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", + "value": "{{buttonsItem.linkIos ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", + "value": "{{buttonsItem.telNumber ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'TN'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", + "value": "{{buttonsItem.pluginId ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'P1' || buttonsItem.linkType === 'P2' || buttonsItem.linkType === 'P3'}}" + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" + } + } + ] + } + ], + "props": { + "className": "space-y-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.quick_replies" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{[...(_global.bz_tpl_modal?.content?.quickReplies ?? []), {name: '', linkType: 'WL'}]}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.quick_reply_add" + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.quickReplies ?? []}}", + "item_var": "quickRepliesItem", + "index_var": "quickRepliesIdx" + }, + "props": { + "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-xs", + "value": "{{quickRepliesItem.linkType ?? 'WL'}}", + "options": "{{['WL','AL','BK','MD','BC','BT'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", + "value": "{{quickRepliesItem.name ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_quick_reply", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).filter((b, i) => i !== quickRepliesIdx)}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ], + "props": { + "className": "flex items-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", + "value": "{{quickRepliesItem.linkMo ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", + "value": "{{quickRepliesItem.linkPc ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", + "value": "{{quickRepliesItem.linkAnd ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", + "value": "{{quickRepliesItem.linkIos ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", + "value": "{{quickRepliesItem.telNumber ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'TN'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", + "value": "{{quickRepliesItem.pluginId ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'P1' || quickRepliesItem.linkType === 'P2' || quickRepliesItem.linkType === 'P3'}}" + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" + } + } + ] + } + ], + "props": { + "className": "space-y-1.5" + } } ] }, { "type": "basic", "name": "Div", - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - }, "children": [ { "type": "basic", @@ -371,26 +1740,25 @@ "props": { "type": "button", "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + "disabled": "{{_global.bz_tpl_modal?.isSaving ?? false}}" }, - "text": "$t:common.cancel", "actions": [ { "type": "click", "handler": "closeModal", - "target": "modal_bizppurio_binding" + "target": "modal_bizppurio_template" } - ] + ], + "text": "$t:common.cancel" }, { "type": "basic", "name": "Button", "props": { "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" }, - "text": "$t:common.save", "actions": [ { "type": "click", @@ -401,45 +1769,45 @@ "handler": "setState", "params": { "target": "global", - "bizppurio_binding_modal.isSaving": true + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null } }, { "handler": "apiCall", "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", "params": { "method": "POST", "body": { - "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}", - "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", - "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}", - "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}" + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}", + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" } }, "onSuccess": [ { "handler": "refetchDataSource", "params": { - "dataSourceId": "bizppurioBindings" + "dataSourceId": "bizppurioTemplates" } }, { "handler": "toast", "params": { - "message": "$t:sirsoft-message_bizppurio.binding.saved", + "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", "type": "success" } }, { "handler": "closeModal", - "target": "modal_bizppurio_binding" + "target": "modal_bizppurio_template" }, { "handler": "setState", "params": { "target": "global", - "bizppurio_binding_modal.isSaving": false + "bz_tpl_modal.isSaving": false } } ], @@ -448,13 +1816,527 @@ "handler": "setState", "params": { "target": "global", - "bizppurio_binding_modal.isSaving": false + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", + "if": "{{!(_global.bz_tpl_modal?.id)}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" } }, { "handler": "toast", "params": { - "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", + "if": "{{_global.bz_tpl_modal?.id}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", + "params": { + "method": "POST", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}", + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", + "if": "{{!(_global.bz_tpl_modal?.id)}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", + "if": "{{_global.bz_tpl_modal?.id}}" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_sms", + "comment": "대체 SMS/SMS 단독 본문 편집 모달(#597 §4.1). 값은 _global.bz_sms_modal, 저장은 delivery upsert(행 없으면 draft 생성). #{var} 치환 규칙은 알림톡 본문과 동일.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.sms.modal_title|name={{_global.bz_sms_modal?.notification_name ?? ''}}", + "size": "md", + "closeOnOverlayClick": false + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.sms.hint", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + } + }, + { + "id": "bz_sms_lang_tabs", + "comment": "SMS 본문 언어 탭(§14.3). sms_body 는 로케일 맵이며 수신자 로케일로 발송된다 — 코어 알림 템플릿 편집 모달과 동일한 editLang 패턴.", + "type": "basic", + "name": "Div", + "props": { + "className": "flex border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{$locales}}", + "item_var": "loc" + }, + "props": { + "type": "button", + "className": "{{(_global.bz_sms_modal?.editLang ?? $locale) === loc ? 'border-b-2 border-teal-500 dark:border-teal-400 text-teal-600 dark:text-teal-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none" + }, + "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}", + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.editLang": "{{loc}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_sms_body", + "rows": 4, + "maxLength": 2000, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale] ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.body": "{{Object.assign({}, _global.bz_sms_modal?.body, {[_global.bz_sms_modal?.editLang ?? $locale]: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{_global.bz_sms_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "type": "button", + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}", + "actions": [ + { + "type": "click", + "handler": "sirsoft-message_bizppurio.insertVariable", + "params": { + "variable": "{{v.key}}", + "target": "bz_sms_body", + "stateTarget": "global", + "statePath": "{{'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)}}" + } + } + ] + } + ], + "props": { + "className": "flex flex-wrap gap-1" + } + } + ], + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "if": "{{(_global.bz_sms_modal?.variables ?? []).length > 0}}" + } + ], + "props": { + "className": "space-y-3" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_sms" + } + ], + "text": "$t:common.cancel" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}", + "params": { + "method": "PUT", + "body": { + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.sms.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_sms" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", "type": "error" } } @@ -463,21 +2345,241 @@ ] } } + ], + "text": "$t:sirsoft-message_bizppurio.template.sms.btn_save" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_cancel_approval", + "comment": "승인 취소 경고 확인 모달(#597 §3.2). 확정 시 kapi cancel_approval → draft 복귀 — status 가 approved 를 벗어나는 즉시 발송 게이트가 차단된다(대체 SMS 는 sms 채널 규칙으로 계속). 취소 후 상세를 받아 작성 모달을 이어서 연다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.cancel_approval.modal_title", + "size": "sm", + "closeOnOverlayClick": false + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-triangle-exclamation", + "className": "text-amber-500 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_cancel_approval?.notification_name ?? ''}}", + "props": { + "className": "text-sm text-gray-700 dark:text-gray-200" + } + } + ], + "props": { + "className": "flex items-start gap-2" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_cancel_approval" + } + ], + "text": "$t:common.cancel" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 disabled:opacity-50", + "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast", + "type": "info" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_cancel_approval" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_rejection", + "comment": "반려 사유 열람 모달(#597 §4.1). kapi detail 의 comments 스냅샷(inspection_detail) 원문을 그대로 보여준다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.rejection.modal_title|name={{_global.bz_reject_view?.notification_name ?? ''}}", + "size": "md" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_reject_view?.comments ?? []}}", + "item_var": "cm" + }, + "props": { + "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "P", + "text": "{{(cm.createdAt ?? '').slice(0, 16).split('T').join(' ')}}", + "props": { + "className": "text-xs text-red-500 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "P", + "text": "{{cm.content ?? ''}}", + "props": { + "className": "text-sm text-red-800 dark:text-red-200 whitespace-pre-wrap" + } + } ] } - ] + ], + "props": { + "className": "space-y-2 max-h-80 overflow-y-auto" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_rejection" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.rejection.btn_close" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } } ] } ], "init_actions": [ { - "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", "handler": "refetchDataSource", "params": { - "dataSourceId": "bizppurioBindings" + "dataSourceId": "bizppurioTemplates" } } ], "priority": 320 -} \ No newline at end of file +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json index 56e5356c..6cc90787 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json @@ -1,26 +1,26 @@ { "target_layout": "admin_settings", - "comment": "알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(계획서 §6-2, Phase 6 재설계). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼(channel==='alimtalk'일 때만) ③ [연결] 클릭 시 우리 연결 모달(modals 병합)에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API. 편집 모달(본문 편집)과 완전히 분리되어 헷갈리지 않는다.", + "comment": "알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 알림 행 하단(notification_definition_row_footer 슬롯 — notification_row_footer.json)에 알림톡 라이프사이클·SMS 설정 줄 ③ 작성/신청·SMS 본문·승인 취소·반려 사유 모달 4종. 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회한다. 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염).", "data_sources": [ { - "id": "bizppurioBindings", - "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.", + "id": "bizppurioTemplates", + "comment": "행 라이프사이클 표시용 요약 맵(notification_type 키). auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지, 비즈뿌리오 탭 진입 시 init_actions 로 조회.", "type": "api", - "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates/map", "method": "GET", "auto_fetch": false, "auth_required": true, "fallback": { "data": { - "bindings": {} + "templates": {} } } }, { - "id": "bizppurioApprovedTemplates", - "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단(결정 C). 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용, 토스트 재도입 금지 — 모달 열 때마다 에러 토스트가 뜨던 회귀). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다 — 정상 응답에는 이 필드가 없으므로, 드롭다운이 비었을 때 load_failed 유무로 '불러오지 못함(자격증명 확인)' vs '승인 템플릿 없음'을 분기한다.", + "id": "bizppurioCategories", + "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 자격증명 미설정 422 는 suppress + fallback.", "type": "api", - "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories", "method": "GET", "auto_fetch": false, "auth_required": true, @@ -31,7 +31,27 @@ }, "fallback": { "data": { - "templates": [], + "categories": [], + "load_failed": true + } + } + }, + { + "id": "bizppurioProfiles", + "comment": "작성 모달 발신프로필 참고 셀렉트(kapi profile/use 위임). 모달 열 때만 조회. 실패는 suppress + fallback.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" + } + }, + "fallback": { + "data": { + "profiles": [], "load_failed": true } } @@ -41,13 +61,13 @@ { "target_id": "notif_channel_content", "position": "prepend_child", - "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.", + "comment": "비즈뿌리오 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드. 탭 정체성은 query.channel==='alimtalk'.", "components": [ { "id": "bizppurio_status_banner", "type": "basic", "name": "Div", - "if": "{{['sms','alimtalk'].includes(query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true)}}", + "if": "{{['sms','alimtalk'].includes(query.channel ?? '') && (((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false) || ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true))}}", "props": { "className": "mt-4 mb-3 space-y-2" }, @@ -64,9 +84,6 @@ { "type": "basic", "name": "Div", - "props": { - "className": "flex-center gap-2" - }, "children": [ { "type": "basic", @@ -80,12 +97,15 @@ { "type": "basic", "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.not_ready", "props": { "className": "text-sm text-red-800 dark:text-red-200" - }, - "text": "$t:sirsoft-message_bizppurio.banner.not_ready" + } } - ] + ], + "props": { + "className": "flex-center gap-2" + } }, { "type": "basic", @@ -94,7 +114,6 @@ "type": "button", "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" }, - "text": "$t:sirsoft-message_bizppurio.banner.setup_action", "actions": [ { "type": "click", @@ -103,7 +122,8 @@ "path": "/admin/plugins/sirsoft-message_bizppurio/settings" } } - ] + ], + "text": "$t:sirsoft-message_bizppurio.banner.setup_action" } ] }, @@ -128,10 +148,10 @@ { "type": "basic", "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.test_mode", "props": { "className": "text-sm text-amber-800 dark:text-amber-200" - }, - "text": "$t:sirsoft-message_bizppurio.banner.test_mode" + } } ] } @@ -139,7 +159,7 @@ }, { "id": "bizppurio_alimtalk_guide", - "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.", + "comment": "비즈뿌리오 탭 상시 안내 박스 — 알림톡 템플릿을 시스템에서 작성→검수 신청→승인 후 발송됨을 안내. alimtalk 탭 정체성일 때만.", "type": "basic", "name": "Div", "if": "{{(query.channel) === 'alimtalk'}}", @@ -159,10 +179,10 @@ { "type": "basic", "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.tab_guide", "props": { "className": "text-sm text-blue-800 dark:text-blue-200" - }, - "text": "$t:sirsoft-message_bizppurio.binding.list_guide" + } } ] } @@ -171,48 +191,213 @@ ], "modals": [ { - "id": "modal_bizppurio_binding", - "comment": "알림톡 연결 전용 모달(우리 소유, 신규). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.", + "id": "modal_bizppurio_template", + "comment": "알림톡 템플릿 작성/신청 모달(#597 §4.2, 3면+플러그인 관리 화면 공유). 값은 _global.bz_tpl_modal 에 담는다(모달 컨텍스트 분리). content 는 kapi 등록 페이로드 필드 그대로이며, 유형 전환 잔여 필드는 서버(FormRequest)가 정돈한다. 신규/기존 분기는 id 유무의 저장 버튼 if 2벌(POST/PUT)로 처리한다.", "type": "composite", "name": "Modal", "props": { - "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}", - "size": "md", + "title": "$t:sirsoft-message_bizppurio.template.form.modal_title|name={{_global.bz_tpl_modal?.notification_name ?? ''}}", + "size": "xl", "closeOnOverlayClick": false }, "children": [ { - "id": "bizppurio_binding_modal_body", + "id": "bizppurio_tpl_modal_body", "type": "basic", "name": "Div", - "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}", "props": { - "className": "space-y-4" + "className": "space-y-4 max-h-[65vh] overflow-y-auto pr-1" }, "children": [ { "type": "basic", "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.form.rejected_banner", + "props": { + "className": "text-xs font-medium text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2" + } + }, + { + "type": "basic", + "name": "P", + "iteration": { + "source": "{{_global.bz_tpl_modal?.inspection_detail ?? []}}", + "item_var": "cm" + }, + "props": { + "className": "text-xs text-red-700 dark:text-red-300 mt-1" + }, + "text": "{{(cm.createdAt ?? '').slice(0, 10)}} — {{cm.content ?? ''}}" + } + ], "props": { - "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" }, + "if": "{{_global.bz_tpl_modal?.status === 'rejected' && (_global.bz_tpl_modal?.inspection_detail ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Div", "children": [ { "type": "basic", "name": "Icon", "props": { - "name": "fas fa-circle-info", + "name": "fas fa-triangle-exclamation", "size": "sm", - "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + "className": "text-red-600 dark:text-red-400 flex-shrink-0" } }, { "type": "basic", "name": "Span", + "text": "{{_global.bz_tpl_modal?.error}}", "props": { - "className": "text-xs text-teal-700 dark:text-teal-300" + "className": "text-xs text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "if": "{{_global.bz_tpl_modal?.error}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile" + }, + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-full text-sm", + "value": "{{_global.bz_tpl_modal?.sender_profile ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.sender_profile_placeholder')}, ...((bizppurioProfiles?.data?.profiles ?? []).map(pr => ({value: pr.senderKey ?? '', label: (pr.name ?? pr.senderKey ?? '')})))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.sender_profile": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.category *" + }, + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-full text-sm", + "value": "{{_global.bz_tpl_modal?.content?.categoryCode ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.category_placeholder')}, ...((bizppurioCategories?.data?.categories ?? []).map(cat => ({value: cat.code ?? '', label: ((cat.groupName ? cat.groupName + ' › ' : '') + (cat.name ?? cat.code ?? ''))})))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.categoryCode": "{{$event.target.value}}" + } + } + ] + } + ] + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-3" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" }, - "text": "$t:sirsoft-message_bizppurio.binding.section_hint" + "text": "$t:sirsoft-message_bizppurio.template.form.name *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateName ?? ''}}", + "name": "bz_template_name", + "maxLength": 200 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateName": "{{$event.target.value}}" + } + } + ] } ] }, @@ -226,144 +411,1328 @@ "props": { "className": "text-secondary block text-xs font-medium mb-1" }, - "text": "$t:sirsoft-message_bizppurio.binding.connected_template" - }, - { - "type": "composite", - "name": "Select", - "props": { - "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", - "className": "w-full text-sm", - "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bizppurio_binding_modal.template_code": "{{$event.target.value}}", - "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", - "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" - } - } - ] - }, - { - "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.", - "type": "basic", - "name": "P", - "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}", - "props": { - "className": "text-xs text-red-600 dark:text-red-400 mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed" - }, - { - "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.", - "type": "basic", - "name": "P", - "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}", - "props": { - "className": "text-xs text-amber-600 dark:text-amber-400 mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { - "className": "flex-center gap-2" - }, - "children": [ - { - "type": "composite", - "name": "Toggle", - "props": { - "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}", - "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Span", - "props": { - "className": "text-secondary text-xs" - }, - "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms" - } - ] - }, - { - "id": "bizppurio_binding_fallback_hint", - "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.", - "type": "basic", - "name": "P", - "props": { - "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint" - }, - { - "id": "bizppurio_binding_variables", - "type": "basic", - "name": "Div", - "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}", - "props": { - "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" - }, - "children": [ - { - "type": "basic", - "name": "P", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + "text": "$t:sirsoft-message_bizppurio.template.form.message_type *" }, { "type": "basic", "name": "Div", + "comment": "메시지 유형 — BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형", "props": { - "className": "flex flex-wrap gap-1" + "className": "flex flex-wrap gap-1.5" }, "children": [ { "type": "basic", - "name": "Span", - "iteration": { - "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}", - "item_var": "v" - }, + "name": "Button", "props": { - "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300" + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'BA' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" }, - "text": "{{'#{' + (v.key ?? '') + '}'}}" + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateMessageType": "BA" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.BA" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'EX' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "EX" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.EX" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'AD' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "AD" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.AD" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'MI' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "MI" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.MI" } ] } ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_type *" + }, + { + "type": "basic", + "name": "Div", + "comment": "강조 유형 — NONE / TEXT 강조표기 / IMAGE 이미지 / ITEM_LIST 아이템리스트", + "props": { + "className": "flex flex-wrap gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "NONE" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.NONE" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "TEXT" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.TEXT" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "IMAGE" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.IMAGE" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'ITEM_LIST' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "ITEM_LIST" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.ITEM_LIST" + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_title *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateTitle ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateTitle": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_subtitle *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateSubtitle ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateSubtitle": "{{$event.target.value}}" + } + } + ] + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-3" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.image *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "file", + "accept": ".jpg,.jpeg,.png", + "className": "block w-full text-xs text-gray-600 dark:text-gray-300" + }, + "actions": [ + { + "type": "change", + "handler": "sirsoft-message_bizppurio.uploadTemplateImage", + "params": { + "stateTarget": "global", + "statePathUrl": "bz_tpl_modal.content.templateImageUrl", + "statePathName": "bz_tpl_modal.content.templateImageName", + "statePathStatus": "bz_tpl_upload" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.image_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.form.image_uploading", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400" + }, + "if": "{{_global.bz_tpl_upload?.uploading}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_upload?.error ?? ''}}", + "props": { + "className": "text-xs text-red-600 dark:text-red-400" + }, + "if": "{{_global.bz_tpl_upload?.error}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_modal?.content?.templateImageName}} — {{_global.bz_tpl_modal?.content?.templateImageUrl}}", + "props": { + "className": "text-xs text-green-700 dark:text-green-400 break-all" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateImageUrl}}" + } + ], + "props": { + "className": "space-y-1" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_highlight" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_title", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {title: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_description", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {description: $event.target.value})}}" + } + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-1.5" + } + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_image_url", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.imageUrl ?? ''}}", + "maxLength": 500 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {imageUrl: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_list" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.templateItem?.list ?? []).length >= 10}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: [...(_global.bz_tpl_modal?.content?.templateItem?.list ?? []), {title: '', description: ''}]})}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.item_add" + } + ], + "props": { + "className": "flex-between mt-2" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.item_list_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.templateItem?.list ?? []}}", + "item_var": "itm", + "index_var": "itmIdx" + }, + "props": { + "className": "flex items-center gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 6, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", + "value": "{{itm.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {title: $event.target.value}) : r)})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 23, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_description", + "value": "{{itm.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {description: $event.target.value}) : r)})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_item", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).filter((r, i) => i !== itmIdx)})}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium mt-2" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_summary" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 6, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", + "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {title: $event.target.value})})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 14, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.summary_description", + "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {description: $event.target.value})})}}" + } + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-1.5" + } + } + ], + "props": { + "className": "space-y-1.5" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.content *" + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.content?.templateContent ?? '').length}}/1000", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_template_content", + "rows": 6, + "maxLength": 1000, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 font-mono", + "value": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateContent": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{_global.bz_tpl_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "type": "button", + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}", + "actions": [ + { + "type": "click", + "handler": "sirsoft-message_bizppurio.insertVariable", + "params": { + "variable": "{{v.key}}", + "target": "bz_template_content", + "stateTarget": "global", + "statePath": "bz_tpl_modal.content.templateContent" + } + } + ] + } + ], + "props": { + "className": "flex flex-wrap gap-1" + } + } + ], + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "if": "{{(_global.bz_tpl_modal?.variables ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.extra *" + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "rows": 2, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content?.templateExtra ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateExtra": "{{$event.target.value}}" + } + } + ] + } + ], + "if": "{{_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.header" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateHeader ?? ''}}", + "maxLength": 16 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateHeader": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.header_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.buttons" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{[...(_global.bz_tpl_modal?.content?.buttons ?? []), {name: '', linkType: 'WL'}]}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.button_add" + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.buttons ?? []}}", + "item_var": "buttonsItem", + "index_var": "buttonsIdx" + }, + "props": { + "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-xs", + "value": "{{buttonsItem.linkType ?? 'WL'}}", + "options": "{{['WL','AL','DS','BK','MD','AC','BC','BT','P1','P2','P3','TN','MP'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", + "value": "{{buttonsItem.name ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_button", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).filter((b, i) => i !== buttonsIdx)}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ], + "props": { + "className": "flex items-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", + "value": "{{buttonsItem.linkMo ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", + "value": "{{buttonsItem.linkPc ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", + "value": "{{buttonsItem.linkAnd ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", + "value": "{{buttonsItem.linkIos ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", + "value": "{{buttonsItem.telNumber ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'TN'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", + "value": "{{buttonsItem.pluginId ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'P1' || buttonsItem.linkType === 'P2' || buttonsItem.linkType === 'P3'}}" + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" + } + } + ] + } + ], + "props": { + "className": "space-y-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.quick_replies" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{[...(_global.bz_tpl_modal?.content?.quickReplies ?? []), {name: '', linkType: 'WL'}]}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.quick_reply_add" + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.quickReplies ?? []}}", + "item_var": "quickRepliesItem", + "index_var": "quickRepliesIdx" + }, + "props": { + "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-xs", + "value": "{{quickRepliesItem.linkType ?? 'WL'}}", + "options": "{{['WL','AL','BK','MD','BC','BT'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", + "value": "{{quickRepliesItem.name ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_quick_reply", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).filter((b, i) => i !== quickRepliesIdx)}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ], + "props": { + "className": "flex items-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", + "value": "{{quickRepliesItem.linkMo ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", + "value": "{{quickRepliesItem.linkPc ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", + "value": "{{quickRepliesItem.linkAnd ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", + "value": "{{quickRepliesItem.linkIos ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", + "value": "{{quickRepliesItem.telNumber ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'TN'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", + "value": "{{quickRepliesItem.pluginId ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'P1' || quickRepliesItem.linkType === 'P2' || quickRepliesItem.linkType === 'P3'}}" + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" + } + } + ] + } + ], + "props": { + "className": "space-y-1.5" + } } ] }, { "type": "basic", "name": "Div", - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - }, "children": [ { "type": "basic", @@ -371,26 +1740,25 @@ "props": { "type": "button", "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + "disabled": "{{_global.bz_tpl_modal?.isSaving ?? false}}" }, - "text": "$t:common.cancel", "actions": [ { "type": "click", "handler": "closeModal", - "target": "modal_bizppurio_binding" + "target": "modal_bizppurio_template" } - ] + ], + "text": "$t:common.cancel" }, { "type": "basic", "name": "Button", "props": { "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" }, - "text": "$t:common.save", "actions": [ { "type": "click", @@ -401,45 +1769,45 @@ "handler": "setState", "params": { "target": "global", - "bizppurio_binding_modal.isSaving": true + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null } }, { "handler": "apiCall", "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", "params": { "method": "POST", "body": { - "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}", - "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", - "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}", - "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}" + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}", + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" } }, "onSuccess": [ { "handler": "refetchDataSource", "params": { - "dataSourceId": "bizppurioBindings" + "dataSourceId": "bizppurioTemplates" } }, { "handler": "toast", "params": { - "message": "$t:sirsoft-message_bizppurio.binding.saved", + "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", "type": "success" } }, { "handler": "closeModal", - "target": "modal_bizppurio_binding" + "target": "modal_bizppurio_template" }, { "handler": "setState", "params": { "target": "global", - "bizppurio_binding_modal.isSaving": false + "bz_tpl_modal.isSaving": false } } ], @@ -448,13 +1816,527 @@ "handler": "setState", "params": { "target": "global", - "bizppurio_binding_modal.isSaving": false + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", + "if": "{{!(_global.bz_tpl_modal?.id)}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" } }, { "handler": "toast", "params": { - "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", + "if": "{{_global.bz_tpl_modal?.id}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", + "params": { + "method": "POST", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}", + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", + "if": "{{!(_global.bz_tpl_modal?.id)}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", + "if": "{{_global.bz_tpl_modal?.id}}" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_sms", + "comment": "대체 SMS/SMS 단독 본문 편집 모달(#597 §4.1). 값은 _global.bz_sms_modal, 저장은 delivery upsert(행 없으면 draft 생성). #{var} 치환 규칙은 알림톡 본문과 동일.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.sms.modal_title|name={{_global.bz_sms_modal?.notification_name ?? ''}}", + "size": "md", + "closeOnOverlayClick": false + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.sms.hint", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + } + }, + { + "id": "bz_sms_lang_tabs", + "comment": "SMS 본문 언어 탭(§14.3). sms_body 는 로케일 맵이며 수신자 로케일로 발송된다 — 코어 알림 템플릿 편집 모달과 동일한 editLang 패턴.", + "type": "basic", + "name": "Div", + "props": { + "className": "flex border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{$locales}}", + "item_var": "loc" + }, + "props": { + "type": "button", + "className": "{{(_global.bz_sms_modal?.editLang ?? $locale) === loc ? 'border-b-2 border-teal-500 dark:border-teal-400 text-teal-600 dark:text-teal-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none" + }, + "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}", + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.editLang": "{{loc}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_sms_body", + "rows": 4, + "maxLength": 2000, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale] ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.body": "{{Object.assign({}, _global.bz_sms_modal?.body, {[_global.bz_sms_modal?.editLang ?? $locale]: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{_global.bz_sms_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "type": "button", + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}", + "actions": [ + { + "type": "click", + "handler": "sirsoft-message_bizppurio.insertVariable", + "params": { + "variable": "{{v.key}}", + "target": "bz_sms_body", + "stateTarget": "global", + "statePath": "{{'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)}}" + } + } + ] + } + ], + "props": { + "className": "flex flex-wrap gap-1" + } + } + ], + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "if": "{{(_global.bz_sms_modal?.variables ?? []).length > 0}}" + } + ], + "props": { + "className": "space-y-3" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_sms" + } + ], + "text": "$t:common.cancel" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}", + "params": { + "method": "PUT", + "body": { + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.sms.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_sms" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", "type": "error" } } @@ -463,21 +2345,241 @@ ] } } + ], + "text": "$t:sirsoft-message_bizppurio.template.sms.btn_save" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_cancel_approval", + "comment": "승인 취소 경고 확인 모달(#597 §3.2). 확정 시 kapi cancel_approval → draft 복귀 — status 가 approved 를 벗어나는 즉시 발송 게이트가 차단된다(대체 SMS 는 sms 채널 규칙으로 계속). 취소 후 상세를 받아 작성 모달을 이어서 연다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.cancel_approval.modal_title", + "size": "sm", + "closeOnOverlayClick": false + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-triangle-exclamation", + "className": "text-amber-500 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_cancel_approval?.notification_name ?? ''}}", + "props": { + "className": "text-sm text-gray-700 dark:text-gray-200" + } + } + ], + "props": { + "className": "flex items-start gap-2" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_cancel_approval" + } + ], + "text": "$t:common.cancel" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 disabled:opacity-50", + "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast", + "type": "info" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_cancel_approval" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_rejection", + "comment": "반려 사유 열람 모달(#597 §4.1). kapi detail 의 comments 스냅샷(inspection_detail) 원문을 그대로 보여준다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.rejection.modal_title|name={{_global.bz_reject_view?.notification_name ?? ''}}", + "size": "md" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_reject_view?.comments ?? []}}", + "item_var": "cm" + }, + "props": { + "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "P", + "text": "{{(cm.createdAt ?? '').slice(0, 16).split('T').join(' ')}}", + "props": { + "className": "text-xs text-red-500 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "P", + "text": "{{cm.content ?? ''}}", + "props": { + "className": "text-sm text-red-800 dark:text-red-200 whitespace-pre-wrap" + } + } ] } - ] + ], + "props": { + "className": "space-y-2 max-h-80 overflow-y-auto" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_rejection" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.rejection.btn_close" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } } ] } ], "init_actions": [ { - "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", "handler": "refetchDataSource", "params": { - "dataSourceId": "bizppurioBindings" + "dataSourceId": "bizppurioTemplates" } } ], "priority": 320 -} \ No newline at end of file +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json index de4b21c9..cd5a7ce2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json @@ -1,26 +1,26 @@ { "target_layout": "admin_ecommerce_settings", - "comment": "이커머스 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 이커머스 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 이커머스 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).", + "comment": "[이커머스 면 — notification_tab_core.json 과 동일 패턴, target/컴포넌트 id 만 이커머스 전용] 알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 알림 행 하단(notification_definition_row_footer 슬롯 — notification_row_footer.json)에 알림톡 라이프사이클·SMS 설정 줄 ③ 작성/신청·SMS 본문·승인 취소·반려 사유 모달 4종. 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회한다. 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염).", "data_sources": [ { - "id": "bizppurioBindings", - "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.", + "id": "bizppurioTemplates", + "comment": "행 라이프사이클 표시용 요약 맵(notification_type 키). auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지, 비즈뿌리오 탭 진입 시 init_actions 로 조회.", "type": "api", - "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates/map", "method": "GET", "auto_fetch": false, "auth_required": true, "fallback": { "data": { - "bindings": {} + "templates": {} } } }, { - "id": "bizppurioApprovedTemplates", - "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.", + "id": "bizppurioCategories", + "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 자격증명 미설정 422 는 suppress + fallback.", "type": "api", - "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories", "method": "GET", "auto_fetch": false, "auth_required": true, @@ -31,7 +31,27 @@ }, "fallback": { "data": { - "templates": [], + "categories": [], + "load_failed": true + } + } + }, + { + "id": "bizppurioProfiles", + "comment": "작성 모달 발신프로필 참고 셀렉트(kapi profile/use 위임). 모달 열 때만 조회. 실패는 suppress + fallback.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" + } + }, + "fallback": { + "data": { + "profiles": [], "load_failed": true } } @@ -41,13 +61,13 @@ { "target_id": "ecommerce_notif_channel_content", "position": "prepend_child", - "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.", + "comment": "비즈뿌리오 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드. 탭 정체성은 query.channel==='alimtalk'.", "components": [ { "id": "bizppurio_ecommerce_status_banner", "type": "basic", "name": "Div", - "if": "{{['sms','alimtalk'].includes(query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true)}}", + "if": "{{['sms','alimtalk'].includes(query.channel ?? '') && (((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false) || ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true))}}", "props": { "className": "mt-4 mb-3 space-y-2" }, @@ -64,9 +84,6 @@ { "type": "basic", "name": "Div", - "props": { - "className": "flex-center gap-2" - }, "children": [ { "type": "basic", @@ -80,12 +97,15 @@ { "type": "basic", "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.not_ready", "props": { "className": "text-sm text-red-800 dark:text-red-200" - }, - "text": "$t:sirsoft-message_bizppurio.banner.not_ready" + } } - ] + ], + "props": { + "className": "flex-center gap-2" + } }, { "type": "basic", @@ -94,7 +114,6 @@ "type": "button", "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" }, - "text": "$t:sirsoft-message_bizppurio.banner.setup_action", "actions": [ { "type": "click", @@ -103,7 +122,8 @@ "path": "/admin/plugins/sirsoft-message_bizppurio/settings" } } - ] + ], + "text": "$t:sirsoft-message_bizppurio.banner.setup_action" } ] }, @@ -128,10 +148,10 @@ { "type": "basic", "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.test_mode", "props": { "className": "text-sm text-amber-800 dark:text-amber-200" - }, - "text": "$t:sirsoft-message_bizppurio.banner.test_mode" + } } ] } @@ -139,7 +159,7 @@ }, { "id": "bizppurio_ecommerce_alimtalk_guide", - "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.", + "comment": "비즈뿌리오 탭 상시 안내 박스 — 알림톡 템플릿을 시스템에서 작성→검수 신청→승인 후 발송됨을 안내. alimtalk 탭 정체성일 때만.", "type": "basic", "name": "Div", "if": "{{(query.channel) === 'alimtalk'}}", @@ -159,10 +179,10 @@ { "type": "basic", "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.tab_guide", "props": { "className": "text-sm text-blue-800 dark:text-blue-200" - }, - "text": "$t:sirsoft-message_bizppurio.binding.list_guide" + } } ] } @@ -171,48 +191,213 @@ ], "modals": [ { - "id": "modal_bizppurio_binding", - "comment": "알림톡 연결 전용 모달(우리 소유). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.", + "id": "modal_bizppurio_template", + "comment": "알림톡 템플릿 작성/신청 모달(#597 §4.2, 3면+플러그인 관리 화면 공유). 값은 _global.bz_tpl_modal 에 담는다(모달 컨텍스트 분리). content 는 kapi 등록 페이로드 필드 그대로이며, 유형 전환 잔여 필드는 서버(FormRequest)가 정돈한다. 신규/기존 분기는 id 유무의 저장 버튼 if 2벌(POST/PUT)로 처리한다.", "type": "composite", "name": "Modal", "props": { - "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}", - "size": "md", + "title": "$t:sirsoft-message_bizppurio.template.form.modal_title|name={{_global.bz_tpl_modal?.notification_name ?? ''}}", + "size": "xl", "closeOnOverlayClick": false }, "children": [ { - "id": "bizppurio_ecommerce_binding_modal_body", + "id": "bizppurio_ecommerce_tpl_modal_body", "type": "basic", "name": "Div", - "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}", "props": { - "className": "space-y-4" + "className": "space-y-4 max-h-[65vh] overflow-y-auto pr-1" }, "children": [ { "type": "basic", "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.form.rejected_banner", + "props": { + "className": "text-xs font-medium text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2" + } + }, + { + "type": "basic", + "name": "P", + "iteration": { + "source": "{{_global.bz_tpl_modal?.inspection_detail ?? []}}", + "item_var": "cm" + }, + "props": { + "className": "text-xs text-red-700 dark:text-red-300 mt-1" + }, + "text": "{{(cm.createdAt ?? '').slice(0, 10)}} — {{cm.content ?? ''}}" + } + ], "props": { - "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" }, + "if": "{{_global.bz_tpl_modal?.status === 'rejected' && (_global.bz_tpl_modal?.inspection_detail ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Div", "children": [ { "type": "basic", "name": "Icon", "props": { - "name": "fas fa-circle-info", + "name": "fas fa-triangle-exclamation", "size": "sm", - "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + "className": "text-red-600 dark:text-red-400 flex-shrink-0" } }, { "type": "basic", "name": "Span", + "text": "{{_global.bz_tpl_modal?.error}}", "props": { - "className": "text-xs text-teal-700 dark:text-teal-300" + "className": "text-xs text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "if": "{{_global.bz_tpl_modal?.error}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile" + }, + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-full text-sm", + "value": "{{_global.bz_tpl_modal?.sender_profile ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.sender_profile_placeholder')}, ...((bizppurioProfiles?.data?.profiles ?? []).map(pr => ({value: pr.senderKey ?? '', label: (pr.name ?? pr.senderKey ?? '')})))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.sender_profile": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.category *" + }, + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-full text-sm", + "value": "{{_global.bz_tpl_modal?.content?.categoryCode ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.category_placeholder')}, ...((bizppurioCategories?.data?.categories ?? []).map(cat => ({value: cat.code ?? '', label: ((cat.groupName ? cat.groupName + ' › ' : '') + (cat.name ?? cat.code ?? ''))})))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.categoryCode": "{{$event.target.value}}" + } + } + ] + } + ] + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-3" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" }, - "text": "$t:sirsoft-message_bizppurio.binding.section_hint" + "text": "$t:sirsoft-message_bizppurio.template.form.name *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateName ?? ''}}", + "name": "bz_template_name", + "maxLength": 200 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateName": "{{$event.target.value}}" + } + } + ] } ] }, @@ -226,144 +411,1328 @@ "props": { "className": "text-secondary block text-xs font-medium mb-1" }, - "text": "$t:sirsoft-message_bizppurio.binding.connected_template" - }, - { - "type": "composite", - "name": "Select", - "props": { - "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", - "className": "w-full text-sm", - "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bizppurio_binding_modal.template_code": "{{$event.target.value}}", - "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", - "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" - } - } - ] - }, - { - "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.", - "type": "basic", - "name": "P", - "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}", - "props": { - "className": "text-xs text-red-600 dark:text-red-400 mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed" - }, - { - "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.", - "type": "basic", - "name": "P", - "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}", - "props": { - "className": "text-xs text-amber-600 dark:text-amber-400 mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { - "className": "flex-center gap-2" - }, - "children": [ - { - "type": "composite", - "name": "Toggle", - "props": { - "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}", - "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}" - }, - "actions": [ - { - "type": "change", - "handler": "setState", - "params": { - "target": "global", - "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" - } - } - ] - }, - { - "type": "basic", - "name": "Span", - "props": { - "className": "text-secondary text-xs" - }, - "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms" - } - ] - }, - { - "id": "bizppurio_ecommerce_binding_fallback_hint", - "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.", - "type": "basic", - "name": "P", - "props": { - "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint" - }, - { - "id": "bizppurio_ecommerce_binding_variables", - "type": "basic", - "name": "Div", - "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}", - "props": { - "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" - }, - "children": [ - { - "type": "basic", - "name": "P", - "props": { - "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" - }, - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + "text": "$t:sirsoft-message_bizppurio.template.form.message_type *" }, { "type": "basic", "name": "Div", + "comment": "메시지 유형 — BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형", "props": { - "className": "flex flex-wrap gap-1" + "className": "flex flex-wrap gap-1.5" }, "children": [ { "type": "basic", - "name": "Span", - "iteration": { - "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}", - "item_var": "v" - }, + "name": "Button", "props": { - "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300" + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'BA' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}" }, - "text": "{{'#{' + (v.key ?? '') + '}'}}" + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateMessageType": "BA" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.BA" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'EX' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "EX" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.EX" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'AD' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "AD" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.AD" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'MI' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "MI" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.MI" } ] } ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_type *" + }, + { + "type": "basic", + "name": "Div", + "comment": "강조 유형 — NONE / TEXT 강조표기 / IMAGE 이미지 / ITEM_LIST 아이템리스트", + "props": { + "className": "flex flex-wrap gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "NONE" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.NONE" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "TEXT" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.TEXT" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "IMAGE" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.IMAGE" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'ITEM_LIST' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "ITEM_LIST" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.ITEM_LIST" + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_title *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateTitle ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateTitle": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_subtitle *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateSubtitle ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateSubtitle": "{{$event.target.value}}" + } + } + ] + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-3" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.image *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "file", + "accept": ".jpg,.jpeg,.png", + "className": "block w-full text-xs text-gray-600 dark:text-gray-300" + }, + "actions": [ + { + "type": "change", + "handler": "sirsoft-message_bizppurio.uploadTemplateImage", + "params": { + "stateTarget": "global", + "statePathUrl": "bz_tpl_modal.content.templateImageUrl", + "statePathName": "bz_tpl_modal.content.templateImageName", + "statePathStatus": "bz_tpl_upload" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.image_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.form.image_uploading", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400" + }, + "if": "{{_global.bz_tpl_upload?.uploading}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_upload?.error ?? ''}}", + "props": { + "className": "text-xs text-red-600 dark:text-red-400" + }, + "if": "{{_global.bz_tpl_upload?.error}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_modal?.content?.templateImageName}} — {{_global.bz_tpl_modal?.content?.templateImageUrl}}", + "props": { + "className": "text-xs text-green-700 dark:text-green-400 break-all" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateImageUrl}}" + } + ], + "props": { + "className": "space-y-1" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_highlight" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_title", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {title: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_description", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {description: $event.target.value})}}" + } + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-1.5" + } + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_image_url", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.imageUrl ?? ''}}", + "maxLength": 500 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {imageUrl: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_list" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.templateItem?.list ?? []).length >= 10}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: [...(_global.bz_tpl_modal?.content?.templateItem?.list ?? []), {title: '', description: ''}]})}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.item_add" + } + ], + "props": { + "className": "flex-between mt-2" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.item_list_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.templateItem?.list ?? []}}", + "item_var": "itm", + "index_var": "itmIdx" + }, + "props": { + "className": "flex items-center gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 6, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", + "value": "{{itm.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {title: $event.target.value}) : r)})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 23, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_description", + "value": "{{itm.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {description: $event.target.value}) : r)})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_item", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).filter((r, i) => i !== itmIdx)})}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium mt-2" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_summary" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 6, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", + "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {title: $event.target.value})})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 14, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.summary_description", + "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {description: $event.target.value})})}}" + } + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-1.5" + } + } + ], + "props": { + "className": "space-y-1.5" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.content *" + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.content?.templateContent ?? '').length}}/1000", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_template_content", + "rows": 6, + "maxLength": 1000, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 font-mono", + "value": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateContent": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{_global.bz_tpl_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "type": "button", + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}", + "actions": [ + { + "type": "click", + "handler": "sirsoft-message_bizppurio.insertVariable", + "params": { + "variable": "{{v.key}}", + "target": "bz_template_content", + "stateTarget": "global", + "statePath": "bz_tpl_modal.content.templateContent" + } + } + ] + } + ], + "props": { + "className": "flex flex-wrap gap-1" + } + } + ], + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "if": "{{(_global.bz_tpl_modal?.variables ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.extra *" + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "rows": 2, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content?.templateExtra ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateExtra": "{{$event.target.value}}" + } + } + ] + } + ], + "if": "{{_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.header" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateHeader ?? ''}}", + "maxLength": 16 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateHeader": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.header_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.buttons" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{[...(_global.bz_tpl_modal?.content?.buttons ?? []), {name: '', linkType: 'WL'}]}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.button_add" + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.buttons ?? []}}", + "item_var": "buttonsItem", + "index_var": "buttonsIdx" + }, + "props": { + "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-xs", + "value": "{{buttonsItem.linkType ?? 'WL'}}", + "options": "{{['WL','AL','DS','BK','MD','AC','BC','BT','P1','P2','P3','TN','MP'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", + "value": "{{buttonsItem.name ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_button", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).filter((b, i) => i !== buttonsIdx)}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ], + "props": { + "className": "flex items-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", + "value": "{{buttonsItem.linkMo ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", + "value": "{{buttonsItem.linkPc ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", + "value": "{{buttonsItem.linkAnd ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", + "value": "{{buttonsItem.linkIos ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", + "value": "{{buttonsItem.telNumber ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'TN'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", + "value": "{{buttonsItem.pluginId ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'P1' || buttonsItem.linkType === 'P2' || buttonsItem.linkType === 'P3'}}" + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" + } + } + ] + } + ], + "props": { + "className": "space-y-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.quick_replies" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{[...(_global.bz_tpl_modal?.content?.quickReplies ?? []), {name: '', linkType: 'WL'}]}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.quick_reply_add" + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.quickReplies ?? []}}", + "item_var": "quickRepliesItem", + "index_var": "quickRepliesIdx" + }, + "props": { + "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-xs", + "value": "{{quickRepliesItem.linkType ?? 'WL'}}", + "options": "{{['WL','AL','BK','MD','BC','BT'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", + "value": "{{quickRepliesItem.name ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_quick_reply", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).filter((b, i) => i !== quickRepliesIdx)}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ], + "props": { + "className": "flex items-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", + "value": "{{quickRepliesItem.linkMo ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", + "value": "{{quickRepliesItem.linkPc ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", + "value": "{{quickRepliesItem.linkAnd ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", + "value": "{{quickRepliesItem.linkIos ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", + "value": "{{quickRepliesItem.telNumber ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'TN'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", + "value": "{{quickRepliesItem.pluginId ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'P1' || quickRepliesItem.linkType === 'P2' || quickRepliesItem.linkType === 'P3'}}" + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" + } + } + ] + } + ], + "props": { + "className": "space-y-1.5" + } } ] }, { "type": "basic", "name": "Div", - "props": { - "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" - }, "children": [ { "type": "basic", @@ -371,26 +1740,25 @@ "props": { "type": "button", "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", - "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + "disabled": "{{_global.bz_tpl_modal?.isSaving ?? false}}" }, - "text": "$t:common.cancel", "actions": [ { "type": "click", "handler": "closeModal", - "target": "modal_bizppurio_binding" + "target": "modal_bizppurio_template" } - ] + ], + "text": "$t:common.cancel" }, { "type": "basic", "name": "Button", "props": { "type": "button", - "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", - "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" }, - "text": "$t:common.save", "actions": [ { "type": "click", @@ -401,45 +1769,45 @@ "handler": "setState", "params": { "target": "global", - "bizppurio_binding_modal.isSaving": true + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null } }, { "handler": "apiCall", "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", "params": { "method": "POST", "body": { - "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}", - "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", - "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}", - "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}" + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}", + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" } }, "onSuccess": [ { "handler": "refetchDataSource", "params": { - "dataSourceId": "bizppurioBindings" + "dataSourceId": "bizppurioTemplates" } }, { "handler": "toast", "params": { - "message": "$t:sirsoft-message_bizppurio.binding.saved", + "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", "type": "success" } }, { "handler": "closeModal", - "target": "modal_bizppurio_binding" + "target": "modal_bizppurio_template" }, { "handler": "setState", "params": { "target": "global", - "bizppurio_binding_modal.isSaving": false + "bz_tpl_modal.isSaving": false } } ], @@ -448,13 +1816,527 @@ "handler": "setState", "params": { "target": "global", - "bizppurio_binding_modal.isSaving": false + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", + "if": "{{!(_global.bz_tpl_modal?.id)}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" } }, { "handler": "toast", "params": { - "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", + "if": "{{_global.bz_tpl_modal?.id}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", + "params": { + "method": "POST", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}", + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", + "if": "{{!(_global.bz_tpl_modal?.id)}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", + "if": "{{_global.bz_tpl_modal?.id}}" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_sms", + "comment": "대체 SMS/SMS 단독 본문 편집 모달(#597 §4.1). 값은 _global.bz_sms_modal, 저장은 delivery upsert(행 없으면 draft 생성). #{var} 치환 규칙은 알림톡 본문과 동일.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.sms.modal_title|name={{_global.bz_sms_modal?.notification_name ?? ''}}", + "size": "md", + "closeOnOverlayClick": false + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.sms.hint", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + } + }, + { + "id": "bz_sms_lang_tabs", + "comment": "SMS 본문 언어 탭(§14.3). sms_body 는 로케일 맵이며 수신자 로케일로 발송된다 — 코어 알림 템플릿 편집 모달과 동일한 editLang 패턴.", + "type": "basic", + "name": "Div", + "props": { + "className": "flex border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{$locales}}", + "item_var": "loc" + }, + "props": { + "type": "button", + "className": "{{(_global.bz_sms_modal?.editLang ?? $locale) === loc ? 'border-b-2 border-teal-500 dark:border-teal-400 text-teal-600 dark:text-teal-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none" + }, + "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}", + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.editLang": "{{loc}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_sms_body", + "rows": 4, + "maxLength": 2000, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale] ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.body": "{{Object.assign({}, _global.bz_sms_modal?.body, {[_global.bz_sms_modal?.editLang ?? $locale]: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{_global.bz_sms_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "type": "button", + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}", + "actions": [ + { + "type": "click", + "handler": "sirsoft-message_bizppurio.insertVariable", + "params": { + "variable": "{{v.key}}", + "target": "bz_sms_body", + "stateTarget": "global", + "statePath": "{{'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)}}" + } + } + ] + } + ], + "props": { + "className": "flex flex-wrap gap-1" + } + } + ], + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "if": "{{(_global.bz_sms_modal?.variables ?? []).length > 0}}" + } + ], + "props": { + "className": "space-y-3" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_sms" + } + ], + "text": "$t:common.cancel" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}", + "params": { + "method": "PUT", + "body": { + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.sms.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_sms" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", "type": "error" } } @@ -463,21 +2345,241 @@ ] } } + ], + "text": "$t:sirsoft-message_bizppurio.template.sms.btn_save" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_cancel_approval", + "comment": "승인 취소 경고 확인 모달(#597 §3.2). 확정 시 kapi cancel_approval → draft 복귀 — status 가 approved 를 벗어나는 즉시 발송 게이트가 차단된다(대체 SMS 는 sms 채널 규칙으로 계속). 취소 후 상세를 받아 작성 모달을 이어서 연다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.cancel_approval.modal_title", + "size": "sm", + "closeOnOverlayClick": false + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-triangle-exclamation", + "className": "text-amber-500 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_cancel_approval?.notification_name ?? ''}}", + "props": { + "className": "text-sm text-gray-700 dark:text-gray-200" + } + } + ], + "props": { + "className": "flex items-start gap-2" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_cancel_approval" + } + ], + "text": "$t:common.cancel" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 disabled:opacity-50", + "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast", + "type": "info" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_cancel_approval" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_rejection", + "comment": "반려 사유 열람 모달(#597 §4.1). kapi detail 의 comments 스냅샷(inspection_detail) 원문을 그대로 보여준다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.rejection.modal_title|name={{_global.bz_reject_view?.notification_name ?? ''}}", + "size": "md" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_reject_view?.comments ?? []}}", + "item_var": "cm" + }, + "props": { + "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "P", + "text": "{{(cm.createdAt ?? '').slice(0, 16).split('T').join(' ')}}", + "props": { + "className": "text-xs text-red-500 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "P", + "text": "{{cm.content ?? ''}}", + "props": { + "className": "text-sm text-red-800 dark:text-red-200 whitespace-pre-wrap" + } + } ] } - ] + ], + "props": { + "className": "space-y-2 max-h-80 overflow-y-auto" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_rejection" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.rejection.btn_close" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } } ] } ], "init_actions": [ { - "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", "handler": "refetchDataSource", "params": { - "dataSourceId": "bizppurioBindings" + "dataSourceId": "bizppurioTemplates" } } ], "priority": 320 -} \ No newline at end of file +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/handlers/uploadTemplateImage.test.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/handlers/uploadTemplateImage.test.ts new file mode 100644 index 00000000..49f776d1 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/handlers/uploadTemplateImage.test.ts @@ -0,0 +1,161 @@ +// e2e:allow 업로드 in-flight 경합·모달 재시딩은 브라우저에서 재현하려면 네트워크 지연을 +// 인위적으로 만들어야 한다. 브라우저가 담당하는 축(업로드 성공 → URL 기입 → 저장)은 +// tests/Playwright/specs/admin/template-lifecycle.spec.ts 가 맡고, 여기서는 경합 자체를 고정한다. +/** + * uploadTemplateImage 핸들러 — in-flight 경합 가드 (#597 라운드 5 R2·R3) + * + * 라운드 5 이전에는 다음 두 경로가 열려 있었다: + * - 업로드가 진행 중인데 파일을 다시 고르면 두 요청이 겹치고, 먼저 끝난 쪽이 + * `uploading:false` 를 써서 저장 버튼 잠금이 풀린다. 나중에 끝나는 업로드는 + * 저장 이후에 templateImageUrl 을 덮는다. + * - 업로드 중 모달을 닫고 다른 알림의 모달을 열면(취소는 의도적으로 잠기지 않는다), + * 먼저 시작한 업로드의 결과가 새 모달의 폼에 기입된다. 실패 경로에서는 새 모달이 + * 방금 시딩한 이미지 값이 지워진다. + * + * 둘 다 예외도 경고도 남기지 않으므로, 여기서 고정하지 않으면 되돌려도 red 가 나지 않는다. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { uploadTemplateImageHandler } from '../../handlers/uploadTemplateImage'; + +/** 전역 상태를 흉내내는 최소 스토어 (dot 경로 setState + getGlobal) */ +function makeStore(initial: Record = {}) { + const state: Record = { bz_tpl_modal: { content: {} }, bz_tpl_upload: {}, ...initial }; + + const setGlobal = vi.fn((updates: Record) => { + for (const [path, value] of Object.entries(updates)) { + const keys = path.split('.'); + let cur: any = state; + for (const k of keys.slice(0, -1)) { + if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {}; + cur = cur[k]; + } + cur[keys[keys.length - 1]] = value; + } + }); + + return { state, setGlobal, getGlobal: () => state }; +} + +/** change 이벤트 컨텍스트(파일 1개 선택) */ +function fileContext(name = 'a.png') { + const input = { files: [new File(['x'], name, { type: 'image/png' })], value: name } as unknown as HTMLInputElement; + + return { context: { event: { target: input } as unknown as Event }, input }; +} + +const ACTION = { + handler: 'sirsoft-message_bizppurio.uploadTemplateImage', + params: { + stateTarget: 'global', + statePathUrl: 'bz_tpl_modal.content.templateImageUrl', + statePathName: 'bz_tpl_modal.content.templateImageName', + statePathStatus: 'bz_tpl_upload', + }, +} as any; + +let store: ReturnType; + +beforeEach(() => { + store = makeStore(); + (window as any).G7Core = { + state: { setGlobal: store.setGlobal, getGlobal: store.getGlobal }, + t: (k: string) => k, + }; + localStorage.setItem('auth_token', 'tkn'); +}); + +afterEach(() => { + vi.restoreAllMocks(); + delete (window as any).G7Core; +}); + +describe('uploadTemplateImage — in-flight 경합 가드', () => { + /** + * @effects upload_rejects_concurrent_selection_while_in_flight + */ + it('업로드가 진행 중이면 두 번째 파일 선택을 무시한다(요청 1회)', async () => { + let release: (v: any) => void = () => {}; + const fetchMock = vi.fn(() => new Promise((r) => { release = r; })); + vi.stubGlobal('fetch', fetchMock); + + const first = uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any); + // 첫 요청이 아직 열려 있는 상태에서 두 번째 선택 + await uploadTemplateImageHandler(ACTION, fileContext('two.png').context as any); + + expect(fetchMock, '겹친 업로드가 나가면 먼저 끝난 쪽이 저장 잠금을 풀어 버린다').toHaveBeenCalledTimes(1); + + release({ ok: true, json: async () => ({ success: true, data: { url: 'https://k/1.png' } }) }); + await first; + + expect(store.state.bz_tpl_modal.content.templateImageUrl).toBe('https://k/1.png'); + expect(store.state.bz_tpl_upload.uploading).toBe(false); + }); + + /** + * @effects upload_result_discarded_when_modal_reseeded + */ + it('응답 도착 전 모달이 다시 시딩되면 성공 결과를 폼에 쓰지 않는다', async () => { + let release: (v: any) => void = () => {}; + vi.stubGlobal('fetch', vi.fn(() => new Promise((r) => { release = r; }))); + + const pending = uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any); + expect(store.state.bz_tpl_upload.uploading).toBe(true); + + // 다른 알림의 모달을 여는 지점이 하는 일: 상태 리시드 + store.setGlobal({ 'bz_tpl_upload': { uploading: false, error: null } }); + store.setGlobal({ 'bz_tpl_modal.content.templateImageUrl': 'https://k/keep.png' }); + + release({ ok: true, json: async () => ({ success: true, data: { url: 'https://k/stale.png' } }) }); + await pending; + + expect(store.state.bz_tpl_modal.content.templateImageUrl, + '이전 모달의 업로드 결과가 지금 열린 모달의 폼을 덮으면 안 된다').toBe('https://k/keep.png'); + }); + + /** + * @effects upload_result_discarded_when_modal_reseeded + */ + it('응답 도착 전 모달이 다시 시딩되면 실패 결과도 새 모달의 값을 지우지 않는다', async () => { + let release: (v: any) => void = () => {}; + vi.stubGlobal('fetch', vi.fn(() => new Promise((r) => { release = r; }))); + + const pending = uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any); + + store.setGlobal({ 'bz_tpl_upload': { uploading: false, error: null } }); + store.setGlobal({ 'bz_tpl_modal.content.templateImageUrl': 'https://k/keep.png' }); + + release({ ok: false, json: async () => ({ success: false, message: '실패' }) }); + await pending; + + expect(store.state.bz_tpl_modal.content.templateImageUrl).toBe('https://k/keep.png'); + expect(store.state.bz_tpl_upload.error, '새 모달에 남의 실패 배너가 뜨면 안 된다').toBeNull(); + }); + + it('업로드가 끝나면 다음 선택을 다시 받는다(가드가 영구 잠기지 않는다)', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ success: true, data: { url: 'https://k/1.png' } }), + })); + vi.stubGlobal('fetch', fetchMock); + + await uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any); + await uploadTemplateImageHandler(ACTION, fileContext('two.png').context as any); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('성공 응답인데 url 이 비면 실패로 처리하고 이미지 값을 비운다', async () => { + store.setGlobal({ 'bz_tpl_modal.content.templateImageUrl': 'https://k/old.png' }); + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ({ success: true, data: { url: ' ' } }), + }))); + + await uploadTemplateImageHandler(ACTION, fileContext().context as any); + + expect(store.state.bz_tpl_modal.content.templateImageUrl).toBe(''); + expect(store.state.bz_tpl_upload.error).toBeTruthy(); + expect(store.state.bz_tpl_upload.uploading).toBe(false); + }); +}); 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 deleted file mode 100644 index a76d8f43..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx +++ /dev/null @@ -1,309 +0,0 @@ -/** - * 비즈뿌리오 메시징 플러그인 알림톡 템플릿 조회 탭 구조 검증 (조회 전용) - * - * plugin_settings.json 안에 탭으로 배치된 알림톡 템플릿 화면을 검증한다. - * 조회 전용 전환: 등록·수정·삭제·검수·상태변경을 제거하고 목록·상태·내용 조회 + 알림 연결만 - * 남겼다. 등록·관리는 비즈뿌리오 콘솔로 위임한다. - * - 탭 네비게이션(환경설정 ↔ 알림톡 템플릿) + 배타 전환(query.tab) - * - 목록 서브뷰(상태필터·검색·상태배지·[내용] 버튼) — [새 템플릿]·폼·상태변경 없음 - * - 상세 모달([닫기]만, 관리 액션 없음) / readiness 안내 / 준비 안내 / i18n 정합 - * - * 독립 페이지·메뉴 없음: 설정 페이지 탭으로만 진입. - */ - -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('알림톡 탭 버튼이 navigate replace 로 query.tab=templates 를 갱신하고 목록을 refetch 한다', () => { - const raw = JSON.stringify(findById(root, 'tab_templates')); - // 탭 전환은 navigate replace(+mergeQuery)로 if 재평가 → 화면 전환 (replaceUrl 은 if 미재평가라 부적합) - expect(raw).toContain('"handler":"navigate"'); - expect(raw).toContain('"replace":true'); - expect(raw).toContain('"tab":"templates"'); - 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).toBeUndefined(); - }); -}); - -describe('alimtalk templates — 조회 전용 (폼·관리 제거)', () => { - it('폼 서브뷰(templates_form_view)가 제거되었다', () => { - expect(findById(root, 'templates_form_view')).toBeNull(); - }); - - it('상태변경 확인/실행 모달(alimtalk_template_action_modal)이 제거되었다', () => { - const modals = (root as { modals?: AnyNode[] }).modals ?? []; - expect(modals.find((m) => m.id === 'alimtalk_template_action_modal')).toBeUndefined(); - }); - - it('폼 전환·관리 상태(templateView/templateForm/pendingAction/available_actions)가 레이아웃에 없다', () => { - const raw = JSON.stringify(root); - expect(raw).not.toContain('templateView'); - expect(raw).not.toContain('templateForm'); - expect(raw).not.toContain('pendingAction'); - expect(raw).not.toContain('available_actions'); - // 이미지 업로드 핸들러도 제거 - expect(raw).not.toContain('uploadTemplateImage'); - }); -}); - -describe('alimtalk templates — 목록 서브뷰 (조회 전용)', () => { - it('상태 필터 Select 와 검색 입력이 있다', () => { - const toolbar = findById(root, 'templates_toolbar'); - const raw = JSON.stringify(toolbar); - expect(raw).toContain('templateStatus'); - expect(raw).toContain('templateKeyword'); - }); - - it('툴바에 [새 템플릿] 등록 버튼이 없고 새로고침만 있다 (캐시 초기화는 환경설정 탭으로 이관)', () => { - const raw = JSON.stringify(findById(root, 'templates_toolbar')); - expect(raw).toContain('templates.list.refresh'); - // 캐시 초기화·캐시 시간은 환경설정 탭으로 옮겼으므로 템플릿 툴바에는 없다. - expect(raw).not.toContain('cache/clear'); - // 등록 진입(폼 전환) 없음 - expect(raw).not.toContain('templates.list.new'); - expect(raw).not.toContain('"form"'); - }); - - 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_table_card')); - // 상세 조회 → 상세 모달 - expect(raw).toContain('templates.actions.detail'); - expect(raw).toContain('"handler":"apiCall"'); - expect(raw).toContain('alimtalk_template_detail_modal'); - // 관리 액션 메뉴(ActionMenu)·switch 분기 없음 - expect(raw).not.toContain('ActionMenu'); - expect(raw).not.toContain('"handler":"switch"'); - expect(raw).not.toContain("id:'edit'"); - expect(raw).not.toContain("id:'delete'"); - }); - - it('목록에 번호·등록요청일·처리일 컬럼이 있다', () => { - const raw = JSON.stringify(findById(root, 'templates_table_card')); - expect(raw).toContain('columns.no'); - expect(raw).toContain('columns.requested_at'); - expect(raw).toContain('columns.processed_at'); - // 순번은 pagination 기준 계산 + index_var - expect(raw).toContain('"index_var":"tplIndex"'); - expect(raw).toContain('pagination?.current_page'); - // 날짜는 kapi 원본 필드 - expect(raw).toContain('tpl.createdAt'); - expect(raw).toContain('tpl.modifiedAt'); - }); - - it('상태 배지가 RDY 일 때만 세부(사용전)를 덧붙인다', () => { - const raw = JSON.stringify(findById(root, 'templates_table_card')); - expect(raw).toContain("tpl.service_status === 'RDY'"); - expect(raw).toContain('status_sub.rdy'); - }); - - it('페이지네이션이 page 상태를 갱신하고 목록을 refetch 한다', () => { - const listView = JSON.stringify(findById(root, 'templates_list_view')); - expect(listView).toContain('"name":"Pagination"'); - expect(listView).toContain('onPageChange'); - expect(listView).toContain('templatePage'); - expect(listView).toContain('pagination?.total_page'); - }); -}); - -describe('alimtalk templates — 콘솔 안내 (조회 전용)', () => { - it('목록 상단 안내가 배지 의미(제목+배지명+설명) + 콘솔 위임 안내를 포함한다', () => { - const raw = JSON.stringify(findById(root, 'templates_list_notice')); - // 배지 의미 — 제목 + 배지명/설명 분리(의미색 라벨) - expect(raw).toContain('status_guide.title'); - expect(raw).toContain('status_guide.sendable_label'); - expect(raw).toContain('status_guide.inspecting_label'); - expect(raw).toContain('status_guide.pending_label'); - // 배지명은 의미색(초록/amber/빨강) solid 로 표의 배지와 매칭 - expect(raw).toContain('text-green-700'); - expect(raw).toContain('text-amber-700'); - expect(raw).toContain('text-red-700'); - // 콘솔 위임 안내 + 콘솔 링크 - expect(raw).toContain('list_notice.console_desc'); - expect(raw).toContain('list_notice.console_link'); - expect(raw).toContain('bizppurio.com'); - }); - - it('환경설정 탭에 사용 전 준비 안내(카카오·SMS 통합 1박스)가 있고 info_panel 은 제거되었다', () => { - const notice = findById(root, 'preparation_notice'); - expect(notice).toBeTruthy(); - const raw = JSON.stringify(notice); - // 통합 박스: 문자(발신번호) + 카카오(채널·템플릿·API키 3단계) + 콘솔 링크 (박스 제목 없이 채널 그룹만) - expect(raw).toContain('preparation.sms_label'); - expect(raw).toContain('preparation.sms_sender'); - expect(raw).toContain('preparation.kakao_label'); - expect(raw).toContain('preparation.kakao_channel'); - expect(raw).toContain('preparation.kakao_template'); - expect(raw).toContain('preparation.kakao_apikey'); - expect(raw).toContain('preparation.console_link'); - // 제목·본문 sm 로 상향(가독성) - expect(raw).toContain('text-sm'); - // 중복이던 연동정보 안내(info_panel)는 제거(운영전환 경고·리포트 안내는 폼/리포트 섹션이 담당) - expect(findById(root, 'info_panel')).toBeNull(); - }); -}); - -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('상세 모달이 상태 배지·내용을 표시하고 [닫기]만 노출한다 (관리 액션 없음)', () => { - const modals = (root as { modals?: AnyNode[] }).modals ?? []; - const detail = JSON.stringify(modals.find((m) => m.id === 'alimtalk_template_detail_modal')); - // 상태 배지 + 내용(카테고리·유형·버튼) 표시 - expect(detail).toContain('status_badge'); - expect(detail).toContain('templates.detail.category'); - expect(detail).toContain('templates.detail.buttons'); - expect(detail).toContain('columns.requested_at'); - // 닫기만 — 관리 액션(available_actions)·상태변경 모달 연결 없음 - expect(detail).toContain('templates.detail.close'); - expect(detail).not.toContain('available_actions'); - expect(detail).not.toContain('alimtalk_template_action_modal'); - }); - - it('readiness 안내가 미준비(ready=false) 시 조건부로 표시되고 항목별 상태를 노출한다', () => { - const readiness = findById(root, 'templates_readiness'); - // 최상위 노출 조건은 ready 플래그 기반 - const cond = (readiness as { if?: string }).if ?? ''; - expect(cond).toContain('templates_readiness?.data'); - expect(cond).toContain('ready'); - // 자식 노드에서 항목별 미설정(api_key_set/sender_key_set) 조건부 표시 - const raw = JSON.stringify(readiness); - expect(raw).toContain('api_key_set'); - expect(raw).toContain('sender_key_set'); - }); -}); - -describe('alimtalk templates — 회귀', () => { - it('컴포넌트 최상위 actions 는 모두 이벤트 type 을 가진다 (charAt 회귀 방지)', () => { - // 회귀: 컴포넌트의 actions 배열 최상위 항목에 type(click 등) 이 없으면 엔진이 - // getReactEventName 에서 eventType.charAt(0) 을 호출하다 "Cannot read properties of - // undefined (reading 'charAt')" 로 그 컴포넌트 렌더가 통째로 실패한다(PO 브라우저 검수로 발견). - const bad: string[] = []; - const walk = (node: AnyNode, path: string): void => { - if (Array.isArray(node)) { - node.forEach((n, i) => walk(n as AnyNode, `${path}[${i}]`)); - return; - } - if (node && typeof node === 'object') { - const obj = node as Record; - // 컴포넌트의 actions 만 검사 (sequence/switch 내부 하위 actions 는 handler 를 가짐 → 제외) - if (Array.isArray(obj.actions) && obj.handler === undefined) { - (obj.actions as Array>).forEach((a, i) => { - if (a && typeof a === 'object' && a.type === undefined) { - bad.push(`${path}.actions[${i}] handler=${String(a.handler)}`); - } - }); - } - for (const k of Object.keys(obj)) walk(obj[k] as AnyNode, `${path}.${k}`); - } - }; - walk(root, 'root'); - expect(bad, `type 누락 최상위 액션:\n${bad.join('\n')}`).toEqual([]); - }); -}); - -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(); - } - }); -}); - -describe('alimtalk templates — 환경설정 탭: 발송 내용 캐시 (독립 카드)', () => { - it('발송 내용 캐시가 독립 카드로 있고 캐시 시간(분) 입력을 담는다 (form 저장 대상)', () => { - const card = findById(root, 'cache_section'); - expect(card).toBeTruthy(); - const raw = JSON.stringify(card); - // 카드 제목 + 분 단위 숫자 입력 + form 바인딩(name) - expect(raw).toContain('settings.cache.section_title'); - expect(raw).toContain('template_cache_minutes'); - expect(raw).toContain('"number"'); - expect(raw).toContain('settings.fields.template_cache_minutes.label'); - expect(raw).toContain('settings.fields.template_cache_minutes.hint'); - }); - - it('같은 카드에 캐시 초기화 버튼 + 안내문이 함께 있다 (한 묶음)', () => { - const raw = JSON.stringify(findById(root, 'cache_section')); - // 즉시 캐시 비우기 — API 호출 + 성공/실패 토스트 + 상시 안내문 - expect(raw).toContain('alimtalk-templates/cache/clear'); - expect(raw).toContain('apiCall'); - expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache'); - expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_hint'); - expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_success'); - expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_failed'); - }); -}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts index 74d07367..f4dc9b40 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts @@ -118,3 +118,22 @@ export function collectI18nKeys(json: unknown): string[] { const matches = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; return Array.from(new Set(matches)); } + +/** + * `{{...}}` 단일 바인딩 조건식을 주어진 자유변수 스코프로 실제 평가합니다. + * + * 문자열 포함/동일성 단언(`toContain`/`toBe`)은 조건을 잘못 고쳐도 기대 문자열을 함께 + * 고치면 통과하므로 회귀를 잡지 못한다. 식을 그대로 실행해 결과 boolean 을 본다. + * + * @param expr `{{ ... }}` 형태의 조건식(감싸지 않은 식도 허용) + * @param scope 식이 참조하는 자유변수 (예: `{ _global: {...} }`) + * @returns 평가 결과의 boolean 캐스팅 + */ +export function evalBinding(expr: string, scope: Record): boolean { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + const names = Object.keys(scope); + // eslint-disable-next-line no-new-func + const fn = new Function(...names, `return (${body});`); + + return Boolean(fn(...names.map((n) => scope[n]))); +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx deleted file mode 100644 index f68e83cf..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx +++ /dev/null @@ -1,238 +0,0 @@ -// e2e:allow 검수 모드 배너(readiness 무관 노출)·배너 간격은 Chrome MCP로 알림톡 탭 실제 화면 -// 확인·수정까지 완료(2026-07-24). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸. -/** - * 알림 설정 알림톡 탭 연동 UI 구조 검증 (Phase 6 재설계, §6-2) - * - * 두 확장 파일로 분리 구현: - * - notification_tab_core.json (Overlay): 상태 배너(injections) + 안내 박스 + 연결 모달(modals) - * + data_sources. target_layout=admin_settings. - * - notification_row_footer.json (ExtensionPoint): 코어 목록 각 행 하단 슬롯 - * (notification_definition_row_footer)에 연결 상태 줄 + [연결/변경] 버튼. - * - * 코어 편집 모달·저장 버튼은 건드리지 않는다(무오염). 연결은 편집 모달과 분리된 우리 전용 - * 모달에서 하며, 변경 즉시가 아니라 [저장] 버튼으로 명확히 저장한다. 카카오 API 422 는 - * errorHandling.suppress 로 조용히 처리(안내는 배너·문구가 담당). - * - * 검증: 파일 분리(overlay vs extension_point), 배너/안내/모달 구조, 행 슬롯 UI, 저장 배선, - * 무오염(코어 저장 body 미개입), i18n 정합. - */ - -import { describe, it, expect } from 'vitest'; -import overlay from '../../../extensions/notification_tab_core.json'; -import footer from '../../../extensions/notification_row_footer.json'; -import ko from '../../../lang/ko.json'; -import en from '../../../lang/en.json'; -import { findById, type AnyNode } from './helpers'; - -const overlayRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode; -const bannerRoot = { - children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []).flatMap((i) => i.components ?? []), -} as AnyNode; -const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode; - -/** overlay 텍스트에서 $t:key 및 $t('key') 형태의 플러그인 i18n 키를 모두 수집한다. */ -const collectPluginKeys = (json: unknown): string[] => { - const text = JSON.stringify(json); - const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; - const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? []; - return Array.from(new Set([ - ...prefixed.map((m) => m.replace('$t:', '')), - ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')), - ])); -}; - -describe('binding UI — 파일 분리(Overlay vs ExtensionPoint)', () => { - it('overlay 는 target_layout=admin_settings 이고 extension_point 키가 없다', () => { - expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings'); - expect((overlay as Record).extension_point).toBeUndefined(); - }); - - it('footer 는 extension_point=notification_definition_row_footer 이고 target_layout 이 없다', () => { - expect((footer as { extension_point?: string }).extension_point).toBe('notification_definition_row_footer'); - expect((footer as Record).target_layout).toBeUndefined(); - }); - - it('overlay 는 연결 맵·승인 템플릿 데이터소스를 등록한다', () => { - const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id); - expect(ids).toContain('bizppurioBindings'); - expect(ids).toContain('bizppurioApprovedTemplates'); - }); - - it('승인 템플릿 데이터소스는 auto_fetch:false 이고 422 를 suppress 한다(전 탭 에러 방지)', () => { - const ds = ((overlay as { data_sources?: Array> }).data_sources ?? []) - .find((d) => d.id === 'bizppurioApprovedTemplates'); - expect(ds?.auto_fetch).toBe(false); - expect(JSON.stringify(ds?.errorHandling)).toContain('suppress'); - }); -}); - -describe('binding UI — 상태 배너 + 안내 박스', () => { - it('배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => { - const banner = findById(bannerRoot, 'bizppurio_status_banner'); - expect(banner).toBeTruthy(); - const cond = (banner as { if?: string }).if ?? ''; - expect(cond).toContain("'sms'"); - expect(cond).toContain("'alimtalk'"); - expect(cond).toContain('readiness?.ready === false'); - expect(cond).toContain('is_test_mode === true'); - }); - - it('빨강(설정 미완료)·노랑(검수 모드) 배너가 동시에 뜰 때 간격이 있다(회귀: 두 배너가 붙어 보이던 문제)', () => { - const banner = findById(bannerRoot, 'bizppurio_status_banner'); - const className = (banner as { props?: { className?: string } }).props?.className ?? ''; - expect(className).toMatch(/space-y-\d/); - }); - - it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { - const raw = JSON.stringify(findById(bannerRoot, 'bizppurio_banner_not_ready')); - expect(raw).toContain('banner.not_ready'); - expect(raw).toContain('banner.setup_action'); - expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); - }); - - it('검수 모드 배너는 readiness 충족 여부와 무관하게 is_test_mode 만으로 노출된다', () => { - // readiness 실패(예: 알림톡 API 키 미설정) + 검수 모드가 동시에 참인 상황에서도 - // 검수 안내가 가려지면 안 된다(회귀: 과거 readiness?.ready !== false 조건이 배너를 숨겼음). - const banner = findById(bannerRoot, 'bizppurio_banner_test_mode'); - expect(banner).toBeTruthy(); - const cond = (banner as { if?: string }).if ?? ''; - expect(cond).not.toContain('readiness'); - expect(cond).toContain('is_test_mode === true'); - }); - - it('알림톡 탭 상시 안내 박스가 있다(무엇을 하는 화면인지)', () => { - const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide'); - expect(guide).toBeTruthy(); - expect((guide as { if?: string }).if).toContain("=== 'alimtalk'"); - expect(JSON.stringify(guide)).toContain('binding.list_guide'); - }); -}); - -describe('binding UI — 행 연결(extension_point)', () => { - it('행 연결 UI 는 channel === alimtalk 일 때만 노출된다', () => { - const row = findById(footerRoot, 'bizppurio_row_binding'); - expect(row).toBeTruthy(); - expect((row as { if?: string }).if).toContain("extensionPointProps.activeChannel === 'alimtalk'"); - }); - - it('연결 상태를 bizppurioBindings 에서 def.type 으로 읽어 표시한다(연결됨/미연결)', () => { - const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding')); - expect(raw).toContain('bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]'); - expect(raw).toContain('binding.unbound'); - expect(raw).toContain('binding.btn_connect'); - expect(raw).toContain('binding.btn_change'); - }); - - it('[연결] 클릭 시 모달 상태를 seed 하고 우리 연결 모달을 연다', () => { - const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding')); - expect(raw).toContain('bizppurio_binding_modal'); - expect(raw).toContain('"openModal"'); - expect(raw).toContain('modal_bizppurio_binding'); - }); - - it('연결된 카카오 템플릿이 소실(is_unavailable)이면 빨간 경고 배지를 표시한다(결함 2)', () => { - const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding')); - // 연결됨(template_code 있음) + is_unavailable === true 일 때만 경고 - expect(raw).toContain('is_unavailable === true'); - expect(raw).toContain('binding.unavailable'); - // 소실 경고는 red 배지로 표시(연결됨 초록과 구분) - expect(raw).toContain('bg-red-100'); - }); - - it('모달 열기(openModal)가 승인 템플릿 조회(refetch)보다 먼저 실행된다(조회 실패가 모달 표시를 막지 않도록)', () => { - const row = findById(footerRoot, 'bizppurio_row_binding'); - const raw = JSON.stringify(row); - const openIdx = raw.indexOf('"openModal"'); - const refetchIdx = raw.indexOf('bizppurioApprovedTemplates'); - expect(openIdx).toBeGreaterThan(-1); - expect(refetchIdx).toBeGreaterThan(-1); - expect(openIdx).toBeLessThan(refetchIdx); - }); -}); - -describe('binding UI — 연결 모달(우리 소유, 코어 편집 모달과 분리)', () => { - const modal = findById(overlayRoot, 'modal_bizppurio_binding'); - - it('연결 전용 모달이 modals 로 등록된다', () => { - expect(modal).toBeTruthy(); - expect((modal as { name?: string }).name).toBe('Modal'); - }); - - it('안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 토글 + 변수 안내를 담는다', () => { - const raw = JSON.stringify(modal); - expect(raw).toContain('binding.section_hint'); - expect(raw).toContain('binding.connected_template'); - expect(raw).toContain('binding.fallback_sms'); - expect(raw).toContain('binding.variables_hint'); - }); - - it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이다', () => { - const raw = JSON.stringify(findById(overlayRoot, 'bizppurio_binding_modal_body')); - expect(raw).toContain('"disabled"'); - expect(raw).toContain("=== ''"); - }); - - it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => { - const raw = JSON.stringify(modal); - expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'); - expect(raw).toContain('"method":"POST"'); - expect(raw).toContain('binding.saved'); - expect(raw).toContain('binding.save_error'); - expect(raw).toContain('"closeModal"'); - expect(raw).toContain('bizppurioBindings'); - }); -}); - -describe('binding UI — 드롭다운 조회 실패 vs 0건 구분(결함 3)', () => { - const modal = findById(overlayRoot, 'modal_bizppurio_binding'); - - it('승인 템플릿 데이터소스 fallback 에 load_failed:true 마커가 있다', () => { - const ds = ((overlay as { data_sources?: Array> }).data_sources ?? []) - .find((d) => d.id === 'bizppurioApprovedTemplates'); - const fallback = (ds?.fallback as { data?: Record })?.data ?? {}; - // 조회 실패 시 이 마커가 상태에 실려 '0건'과 구분된다. 정상 응답에는 이 필드가 없다. - expect(fallback.load_failed).toBe(true); - expect(Array.isArray(fallback.templates)).toBe(true); - expect((fallback.templates as unknown[]).length).toBe(0); - }); - - it('드롭다운이 비었을 때 조회 실패(load_failed)면 설정 확인 문구를 노출한다', () => { - const raw = JSON.stringify(modal); - // 조회 실패 분기: length===0 && load_failed===true → templates_load_failed - expect(raw).toContain('binding.templates_load_failed'); - expect(raw).toContain('load_failed === true'); - }); - - it('드롭다운이 비었을 때 조회 정상(0건)이면 승인 템플릿 없음 문구를 노출한다', () => { - const raw = JSON.stringify(modal); - // 0건 분기: length===0 && !load_failed → no_approved_templates - expect(raw).toContain('binding.no_approved_templates'); - expect(raw).toContain('!(bizppurioApprovedTemplates?.data?.load_failed)'); - }); - - it('두 문구는 상호배타 조건이라 동시에 뜨지 않는다(조회실패=빨강 / 0건=amber)', () => { - const raw = JSON.stringify(modal); - // 조회 실패 문구는 red, 0건 문구는 amber 로 시각 구분 - expect(raw).toContain('text-red-600'); - expect(raw).toContain('text-amber-600'); - }); -}); - -describe('binding UI — 코어 무오염 + i18n', () => { - it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => { - const all = JSON.stringify(overlay) + JSON.stringify(footer); - expect(all).not.toContain('/api/admin/notification-templates/'); - expect(all).not.toContain('notification_template_form_modal'); - }); - - it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => { - const keys = [...collectPluginKeys(overlay), ...collectPluginKeys(footer)]; - expect(keys.length).toBeGreaterThan(0); - const resolve = (root: unknown, path: string): unknown => - path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); - for (const key of Array.from(new Set(keys))) { - expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy(); - expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy(); - } - }); -}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx index 7e89914c..38c35348 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx @@ -1,35 +1,36 @@ -// e2e:allow 게시판·이커머스 배너/버튼/모달 노출은 Chrome MCP 로 실브라우저 확인·수정까지 완료 -// (2026-07-28). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸(코어와 동일 사유). +// e2e:allow 3면 패리티는 정규화 문자열 대조 Vitest 로 잠근다(#597 재작성) — 라이프사이클 전이 +// 브라우저 흐름은 코어 면 대표로 tests/Playwright/specs/admin/template-lifecycle.spec.ts 가 담당. /** - * 게시판·이커머스 알림 설정 알림톡 탭 연동 UI 구조 검증 (이슈 #28 후속) + * 게시판·이커머스 알림 설정 '비즈뿌리오' 통합 탭 오버레이 — 3면 패리티 검증 (#597) * - * 배경: notification_tab_core.json(target_layout=admin_settings) + notification_row_footer.json - * (extension_point, 전역 매칭)은 코어 알림 설정 화면에만 연동 버튼·배너를 노출했다. - * extension_point 는 이름만 같으면 어느 레이아웃에서도 매칭되지만, target_id 기반 overlay - * injection 은 레이아웃별로 독립이라 게시판·이커머스는 배너/안내박스/연결모달이 뜨지 않았다. + * 시나리오 매니페스트 exclusions 근거: "3면은 동일 오버레이 패턴(파일 diff 는 id·target 뿐) + * — 라이프사이클 전이는 코어 면으로 대표하고 면 axis 는 렌더 패리티(Vitest)로 잠근다". * - * notification_tab_board.json / notification_tab_ecommerce.json 을 신설해 - * target_layout=admin_board_settings / admin_ecommerce_settings 로 각각 등록했다. - * notification_row_footer.json 은 그대로(전역 매칭)이므로 재사용된다. - */ + * 검증 방식: comment 필드 제거 후 JSON 문자열에서 면 고유 토큰(target_layout·target_id· + * 컴포넌트 id 접두)만 코어 형으로 치환하면 코어 오버레이와 완전 동일해야 한다. + * 반대로 데이터소스 id·모달 id·전역 상태 경로(bz_tpl_modal 등)는 치환 없이도 3면 동일해야 + * 한다 — notification_row_footer.json(전역 매칭)이 이 이름들을 공유 참조하기 때문이다. + * + * 세 오버레이가 전문 동일하다는 것이 board/ecommerce 면의 화면 효과를 떠받치는 근거다 — + * core 면에서 단언한 것들(탭 통합·행 하단 UI·업로드 잠금·SMS 언어 탭)이 두 면에도 있다는 + * 보장은 이 패리티뿐이고, 그래서 이 파일이 사라지면 두 면은 미측정으로 남는다. + * + * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_badge_and_lifecycle_actions, + * upload_in_progress_locks_save_buttons_not_cancel, sms_modal_edits_body_per_locale_tab +*/ import { describe, it, expect } from 'vitest'; +import coreOverlay from '../../../extensions/notification_tab_core.json'; import boardOverlay from '../../../extensions/notification_tab_board.json'; import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json'; -import ko from '../../../lang/ko.json'; -import en from '../../../lang/en.json'; -import { findById, type AnyNode } from './helpers'; +import { type AnyNode } from './helpers'; type OverlayFixture = { label: string; overlay: typeof boardOverlay; targetLayout: string; targetId: string; - bannerId: string; - notReadyId: string; - testModeId: string; - guideId: string; - modalBodyId: string; + idPrefix: string; }; const FIXTURES: OverlayFixture[] = [ @@ -38,44 +39,44 @@ const FIXTURES: OverlayFixture[] = [ overlay: boardOverlay, targetLayout: 'admin_board_settings', targetId: 'board_notif_channel_content', - bannerId: 'bizppurio_board_status_banner', - notReadyId: 'bizppurio_board_banner_not_ready', - testModeId: 'bizppurio_board_banner_test_mode', - guideId: 'bizppurio_board_alimtalk_guide', - modalBodyId: 'bizppurio_board_binding_modal_body', + idPrefix: 'bizppurio_board_', }, { label: '이커머스', overlay: ecommerceOverlay, targetLayout: 'admin_ecommerce_settings', targetId: 'ecommerce_notif_channel_content', - bannerId: 'bizppurio_ecommerce_status_banner', - notReadyId: 'bizppurio_ecommerce_banner_not_ready', - testModeId: 'bizppurio_ecommerce_banner_test_mode', - guideId: 'bizppurio_ecommerce_alimtalk_guide', - modalBodyId: 'bizppurio_ecommerce_binding_modal_body', + idPrefix: 'bizppurio_ecommerce_', }, ]; -const collectPluginKeys = (json: unknown): string[] => { - const text = JSON.stringify(json); - const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; - const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? []; - return Array.from(new Set([ - ...prefixed.map((m) => m.replace('$t:', '')), - ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')), - ])); +/** comment 필드를 재귀 제거한다(설명문은 면마다 달라도 되는 유일한 자유 축). */ +const stripComments = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(stripComments); + if (node && typeof node === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(node as Record)) { + if (k === 'comment') continue; + out[k] = stripComments(v); + } + return out; + } + return node; }; -describe.each(FIXTURES)('$label 알림톡 연동 overlay', ({ - overlay, targetLayout, targetId, bannerId, notReadyId, testModeId, guideId, modalBodyId, -}) => { - const injectionRoot = { - children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []) - .flatMap((i) => i.components ?? []), - } as AnyNode; - const modalRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode; +const normalizedCore = JSON.stringify(stripComments(coreOverlay)); +/** 3면 공유 이름(치환 없이 동일해야 하는 축) */ +const SHARED_DATA_SOURCE_IDS = ['bizppurioTemplates', 'bizppurioCategories', 'bizppurioProfiles']; +const SHARED_MODAL_IDS = [ + 'modal_bizppurio_template', + 'modal_bizppurio_sms', + 'modal_bizppurio_cancel_approval', + 'modal_bizppurio_rejection', +]; +const SHARED_GLOBAL_STATE_PATHS = ['bz_tpl_modal', 'bz_sms_modal', 'bz_cancel_approval', 'bz_reject_view', 'bz_tpl_upload']; + +describe.each(FIXTURES)('$label 오버레이 — 3면 패리티', ({ label, overlay, targetLayout, targetId, idPrefix }) => { it(`target_layout=${targetLayout} 이고 extension_point 키가 없다(overlay 전용)`, () => { expect((overlay as { target_layout?: string }).target_layout).toBe(targetLayout); expect((overlay as Record).extension_point).toBeUndefined(); @@ -88,80 +89,45 @@ describe.each(FIXTURES)('$label 알림톡 연동 overlay', ({ expect(injections[0].position).toBe('prepend_child'); }); - it('연결 맵·승인 템플릿 데이터소스를 등록한다(코어와 동일 endpoint)', () => { + it('면 고유 토큰(target_layout/target_id/id 접두)만 치환하면 코어 오버레이와 완전 동일하다(comment 제외)', () => { + const normalized = JSON.stringify(stripComments(overlay)) + .split(targetLayout).join('admin_settings') + .split(targetId).join('notif_channel_content') + .split(idPrefix).join('bizppurio_'); + expect(normalized, `${label} 오버레이가 코어와 구조 불일치`).toBe(normalizedCore); + }); + + it('데이터소스 id 3종이 코어와 완전 동일하다(순서 포함 — 행 footer 공유 참조)', () => { const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id); - expect(ids).toContain('bizppurioBindings'); - expect(ids).toContain('bizppurioApprovedTemplates'); + expect(ids).toEqual(SHARED_DATA_SOURCE_IDS); }); - it('상태 배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => { - const banner = findById(injectionRoot, bannerId); - expect(banner).toBeTruthy(); - const cond = (banner as { if?: string }).if ?? ''; - expect(cond).toContain("'sms'"); - expect(cond).toContain("'alimtalk'"); - expect(cond).toContain('readiness?.ready === false'); - expect(cond).toContain('is_test_mode === true'); + it('모달 id 4종이 코어와 완전 동일하다(행 footer 의 openModal target 공유)', () => { + const ids = ((overlay as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id); + expect(ids).toEqual(SHARED_MODAL_IDS); }); - it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { - const raw = JSON.stringify(findById(injectionRoot, notReadyId)); - expect(raw).toContain('banner.not_ready'); - expect(raw).toContain('banner.setup_action'); - expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); - }); - - it('검수 모드 배너는 readiness 와 무관하게 is_test_mode 만으로 노출된다', () => { - const banner = findById(injectionRoot, testModeId); - expect(banner).toBeTruthy(); - const cond = (banner as { if?: string }).if ?? ''; - expect(cond).not.toContain('readiness'); - expect(cond).toContain('is_test_mode === true'); - }); - - it('알림톡 탭 상시 안내 박스가 있다', () => { - const guide = findById(injectionRoot, guideId); - expect(guide).toBeTruthy(); - expect((guide as { if?: string }).if).toContain("=== 'alimtalk'"); - expect(JSON.stringify(guide)).toContain('binding.list_guide'); - }); - - it('연결 전용 모달(modal_bizppurio_binding)이 modals 로 등록된다', () => { - const modal = findById(modalRoot, 'modal_bizppurio_binding'); - expect(modal).toBeTruthy(); - expect((modal as { name?: string }).name).toBe('Modal'); - }); - - it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => { - const modal = findById(modalRoot, 'modal_bizppurio_binding'); - const raw = JSON.stringify(modal); - expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'); - expect(raw).toContain('"method":"POST"'); - expect(raw).toContain('binding.saved'); - expect(raw).toContain('"closeModal"'); - expect(raw).toContain('bizppurioBindings'); - }); - - it('연결 템플릿이 없으면 SMS 대체 토글이 비활성이다(코어와 동일 규칙)', () => { - const raw = JSON.stringify(findById(modalRoot, modalBodyId)); - expect(raw).toContain('"disabled"'); - expect(raw).toContain("=== ''"); - }); - - it('overlay 는 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => { + it('전역 상태 경로(bz_*)가 접두 치환 없이 그대로 사용된다(면 전용 상태 분기 금지)', () => { const raw = JSON.stringify(overlay); - expect(raw).not.toContain('/api/admin/notification-templates/'); - expect(raw).not.toContain('notification_template_form_modal'); - }); - - it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => { - const keys = collectPluginKeys(overlay); - expect(keys.length).toBeGreaterThan(0); - const resolve = (root: unknown, path: string): unknown => - path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); - for (const key of Array.from(new Set(keys))) { - expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy(); - expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy(); + for (const path of SHARED_GLOBAL_STATE_PATHS) { + expect(raw, `${label} 에 ${path} 없음`).toContain(`_global.${path}`); + // 면 접두가 붙은 변형(bz_board_* 등)이 존재하면 footer 공유가 깨진다 + expect(raw).not.toContain(path.replace('bz_', `bz_${idPrefix.replace('bizppurio_', '')}`)); } }); }); + +describe('3면 패리티 — 공유 축 교차 검증', () => { + it('코어 오버레이도 동일한 공유 데이터소스·모달 id 집합을 갖는다(패리티 기준점 고정)', () => { + const dsIds = ((coreOverlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id); + const modalIds = ((coreOverlay as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id); + expect(dsIds).toEqual(SHARED_DATA_SOURCE_IDS); + expect(modalIds).toEqual(SHARED_MODAL_IDS); + }); + + it('세 오버레이의 init_actions(탭 진입 요약 맵 조회)가 comment 제외 완전 동일하다', () => { + const pick = (o: unknown) => JSON.stringify(stripComments((o as { init_actions?: unknown }).init_actions ?? [])); + expect(pick(boardOverlay)).toBe(pick(coreOverlay)); + expect(pick(ecommerceOverlay)).toBe(pick(coreOverlay)); + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx new file mode 100644 index 00000000..952a6e2b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx @@ -0,0 +1,691 @@ +// e2e:allow 구조 단언 + 조건식 실평가 Vitest(#597) — 브라우저 흐름은 +// tests/Playwright/specs/admin/template-lifecycle.spec.ts 가 담당(발송 인프라 의존 축은 별도 계획). +// +// 행 하단 버튼의 노출 조건은 문자열 동일성이 아니라 `new Function` 실평가로 판정한다 +// (§14.2 T7) — 리터럴 비교는 조건을 잘못 고쳐도 기대값을 함께 고치면 green 이라 회귀를 +// 잡지 못한다. +/** + * 알림 설정 '비즈뿌리오' 통합 탭 — 알림톡 템플릿 라이프사이클 UI 구조 검증 (#597) + * + * @effects row_footer_shows_status_badge_and_lifecycle_actions, compose_modal_switches_conditional_fields_by_type, sms_modal_saves_body_via_delivery_upsert, sms_modal_edits_body_per_locale_tab + * + * 두 확장 파일로 분리 구현: + * - notification_tab_core.json (Overlay): 상태 배너(injections) + 안내 박스 + 모달 4종 + * (작성/신청·SMS 본문·승인 취소·반려 사유) + data_sources 3종. target_layout=admin_settings. + * - notification_row_footer.json (ExtensionPoint): 코어 목록 각 행 하단 슬롯 + * (notification_definition_row_footer)에 알림톡 라이프사이클 줄 + SMS 설정 줄. + * + * 라이프사이클: 미작성(unwritten=행 없음/내용 없음) → 작성(draft) → 검수 신청(requested) + * → 승인(approved) / 반려(rejected) / 휴면(dormant). 발송 판정은 DB 가 유일한 근거. + */ + +import { describe, it, expect } from 'vitest'; +import coreOverlay from '../../../extensions/notification_tab_core.json'; +import boardOverlay from '../../../extensions/notification_tab_board.json'; +import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json'; +import footer from '../../../extensions/notification_row_footer.json'; +import ko from '../../../lang/ko.json'; +import en from '../../../lang/en.json'; +import { findById, findAllByName, type AnyNode, evalBinding } from './helpers'; + +const overlay = coreOverlay; +const overlayModalRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode; +const bannerRoot = { + children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []).flatMap((i) => i.components ?? []), +} as AnyNode; +const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode; + +/** 요약 맵의 def.type 행 접근 경로(행 하단 UI 전반이 공유하는 데이터 근거) */ +const MAP_PATH = "bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]"; + +/** footer 에서 text 키로 버튼 노드를 찾는다. */ +const findButtonByTextKey = (root: AnyNode, key: string): AnyNode | undefined => + findAllByName(root, 'Button').find((b) => b.text === `$t:sirsoft-message_bizppurio.${key}`); + +/** overlay 텍스트에서 $t:key 및 $t('key'...) 형태의 플러그인 i18n 정적 키를 모두 수집한다. */ +const collectPluginKeys = (json: unknown): string[] => { + const text = JSON.stringify(json); + const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; + // $t('key') / $t('key', {...}) 정적 호출 — 동적 접두('... .' + expr)는 끝의 '.' 로 걸러 + // 접두 가족 검증(아래 별도 테스트)으로 넘긴다. + const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'/g) ?? []; + return Array.from(new Set([ + ...prefixed.map((m) => m.replace('$t:', '')), + ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'$/, '')), + ])).filter((k) => !k.endsWith('.')); +}; + +const resolve = (root: unknown, path: string): unknown => + path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); + +describe('lifecycle UI — 파일 분리(Overlay vs ExtensionPoint)', () => { + it('overlay 는 target_layout=admin_settings 이고 extension_point 키가 없다', () => { + expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings'); + expect((overlay as Record).extension_point).toBeUndefined(); + }); + + it('footer 는 extension_point=notification_definition_row_footer 이고 target_layout 이 없다', () => { + expect((footer as { extension_point?: string }).extension_point).toBe('notification_definition_row_footer'); + expect((footer as Record).target_layout).toBeUndefined(); + }); +}); + +describe('lifecycle UI — 데이터소스 3종', () => { + const sources = ((overlay as { data_sources?: Array> }).data_sources ?? []); + + it('bizppurioTemplates / bizppurioCategories / bizppurioProfiles 3종을 등록한다', () => { + expect(sources.map((d) => d.id)).toEqual([ + 'bizppurioTemplates', + 'bizppurioCategories', + 'bizppurioProfiles', + ]); + }); + + it('3종 모두 auto_fetch:false — 설정 페이지 다른 탭에서 자동 호출되지 않는다', () => { + for (const ds of sources) { + expect(ds.auto_fetch, `${ds.id} auto_fetch`).toBe(false); + } + }); + + it('요약 맵은 GET templates/map 이고 탭 진입 init_actions 로만 조회된다', () => { + const map = sources.find((d) => d.id === 'bizppurioTemplates'); + expect(map?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/map'); + expect(map?.method).toBe('GET'); + const inits = (overlay as { init_actions?: AnyNode[] }).init_actions ?? []; + const refetch = inits.find((a) => a.handler === 'refetchDataSource'); + expect((refetch?.params as { dataSourceId?: string })?.dataSourceId).toBe('bizppurioTemplates'); + }); + + it('카테고리·발신프로필은 실패를 suppress 하고 load_failed fallback 을 둔다(자격증명 미설정 422 방어)', () => { + for (const id of ['bizppurioCategories', 'bizppurioProfiles']) { + const ds = sources.find((d) => d.id === id); + expect(JSON.stringify(ds?.errorHandling), `${id} suppress`).toContain('suppress'); + const fallback = (ds?.fallback as { data?: Record })?.data ?? {}; + expect(fallback.load_failed, `${id} load_failed`).toBe(true); + } + }); +}); + +describe('lifecycle UI — 상태 배너 + 안내 박스', () => { + it('배너는 sms·alimtalk 탭 정체성에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => { + const banner = findById(bannerRoot, 'bizppurio_status_banner'); + expect(banner).toBeTruthy(); + const cond = (banner as { if?: string }).if ?? ''; + expect(cond).toContain("'sms'"); + expect(cond).toContain("'alimtalk'"); + expect(cond).toContain('readiness?.ready === false'); + expect(cond).toContain('is_test_mode === true'); + }); + + it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { + const raw = JSON.stringify(findById(bannerRoot, 'bizppurio_banner_not_ready')); + expect(raw).toContain('banner.not_ready'); + expect(raw).toContain('banner.setup_action'); + expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); + }); + + it('검수 모드 배너는 readiness 와 무관하게 is_test_mode 만으로 노출된다(회귀 유지)', () => { + const banner = findById(bannerRoot, 'bizppurio_banner_test_mode'); + expect(banner).toBeTruthy(); + const cond = (banner as { if?: string }).if ?? ''; + expect(cond).not.toContain('readiness'); + expect(cond).toContain('is_test_mode === true'); + }); + + it('알림톡 탭 상시 안내 박스가 라이프사이클 안내(tab_guide)를 노출한다', () => { + const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide'); + expect(guide).toBeTruthy(); + expect((guide as { if?: string }).if).toContain("=== 'alimtalk'"); + expect(JSON.stringify(guide)).toContain('template.tab_guide'); + }); +}); + +describe('lifecycle UI — 행 하단(extension_point) 라이프사이클 줄', () => { + const row = findById(footerRoot, 'bizppurio_row_lifecycle'); + + it('행 UI 는 activeChannel === alimtalk 일 때만 노출된다', () => { + expect(row).toBeTruthy(); + expect((row as { if?: string }).if).toBe("{{extensionPointProps.activeChannel === 'alimtalk'}}"); + }); + + it('상태 배지는 has_content 로 미작성(unwritten)/저장 상태를 분기해 template.status.* 로 표기한다', () => { + const badge = findAllByName(row as AnyNode, 'Span') + .find((s) => (s.text ?? '').includes("template.status.' +")); + expect(badge).toBeTruthy(); + const text = badge?.text ?? ''; + expect(text).toContain(`${MAP_PATH}?.has_content`); + expect(text).toContain("'unwritten'"); + expect(text).toContain(`${MAP_PATH}?.status`); + }); + + it('[작성] 버튼: 행이 없거나 내용이 없을 때(if) → bz_tpl_modal seed + 작성 모달 open', () => { + const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_compose'); + expect(btn).toBeTruthy(); + expect(btn?.if).toBe(`{{!(${MAP_PATH}) || !(${MAP_PATH}?.has_content === true)}}`); + const raw = JSON.stringify(btn); + expect(raw).toContain('"bz_tpl_modal"'); + expect(raw).toContain('modal_bizppurio_template'); + }); + + it('[수정] 버튼: draft·rejected 에서만(if) — 상세 GET 후 모달 open', () => { + const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_edit'); + expect(btn).toBeTruthy(); + expect(btn?.if).toBe( + `{{(${MAP_PATH}?.has_content === true) && ((${MAP_PATH}?.status) === 'draft' || (${MAP_PATH}?.status) === 'rejected')}}`, + ); + const raw = JSON.stringify(btn); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/'); + expect(raw).toContain('"method":"GET"'); + expect(raw).toContain('modal_bizppurio_template'); + }); + + it('[수정(승인 취소)] 버튼: approved 에서만(if) — 승인 취소 경고 모달을 연다', () => { + const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_edit_approved'); + expect(btn).toBeTruthy(); + expect(btn?.if).toBe(`{{(${MAP_PATH}?.status) === 'approved'}}`); + expect(JSON.stringify(btn)).toContain('modal_bizppurio_cancel_approval'); + }); + + it('[검수 신청] 버튼: draft + 내용 보유에서만(if) — POST .../request 후 요약 맵 갱신', () => { + const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_request'); + expect(btn).toBeTruthy(); + expect(btn?.if).toBe(`{{(${MAP_PATH}?.has_content === true) && (${MAP_PATH}?.status) === 'draft'}}`); + const raw = JSON.stringify(btn); + expect(raw).toContain('/request'); + expect(raw).toContain('"method":"POST"'); + expect(raw).toContain('bizppurioTemplates'); + }); + + it('[신청 취소] 버튼: requested 에서만(if) — POST .../cancel-request', () => { + const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_cancel_request'); + expect(btn).toBeTruthy(); + expect(btn?.if).toBe(`{{(${MAP_PATH}?.status) === 'requested'}}`); + expect(JSON.stringify(btn)).toContain('/cancel-request'); + }); + + it('[휴면 해제] 버튼: dormant 에서만(if) — POST .../release', () => { + const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_release'); + expect(btn).toBeTruthy(); + expect(btn?.if).toBe(`{{(${MAP_PATH}?.status) === 'dormant'}}`); + expect(JSON.stringify(btn)).toContain('/release'); + }); + + it('[새로고침] 버튼: template_code 보유 행에서만(if) — POST .../sync 수동 동기화', () => { + const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_refresh'); + expect(btn).toBeTruthy(); + expect(btn?.if).toBe(`{{(${MAP_PATH}) && ${MAP_PATH}?.template_code}}`); + expect(JSON.stringify(btn)).toContain('/sync'); + }); + + it('[사유 보기] 버튼: rejected + 사유 보유에서만(if) — bz_reject_view seed 후 반려 모달 open', () => { + const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_view_reason'); + expect(btn).toBeTruthy(); + expect(btn?.if).toBe( + `{{(${MAP_PATH}?.status) === 'rejected' && (${MAP_PATH}?.inspection_detail ?? []).length > 0}}`, + ); + const raw = JSON.stringify(btn); + expect(raw).toContain('"bz_reject_view"'); + expect(raw).toContain('modal_bizppurio_rejection'); + }); + + it('알림톡 발송 토글은 내용 보유 행에서만 노출되고 delivery upsert 로 즉시 저장한다', () => { + const toggle = findAllByName(row as AnyNode, 'Toggle')[0]; + expect(toggle).toBeTruthy(); + expect(toggle?.if).toContain('has_content === true'); + const raw = JSON.stringify(toggle); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}'); + expect(raw).toContain('"method":"PUT"'); + expect(raw).toContain('alimtalk_enabled'); + }); +}); + +describe('lifecycle UI — SMS 설정 줄(체크박스 + 본문 미리보기)', () => { + const row = findById(footerRoot, 'bizppurio_row_lifecycle'); + const checkboxes = findAllByName(row as AnyNode, 'Checkbox'); + + it.each([ + ['fallback_sms_enabled', '대체 SMS'], + ['sms_only', 'SMS 단독'], + ])('%s 체크박스: autoBinding:false + checked !! 강제 + $event.target.checked 를 delivery PUT body 로 전송', (field) => { + const cb = checkboxes.find((c) => String((c.props as { checked?: string })?.checked ?? '').includes(field)); + expect(cb).toBeTruthy(); + expect((cb?.props as { autoBinding?: boolean })?.autoBinding).toBe(false); + expect((cb?.props as { checked?: string })?.checked).toBe(`{{!!(${MAP_PATH}?.${field})}}`); + const change = (cb?.actions ?? []).find((a) => a.type === 'change') as AnyNode; + expect(change?.handler).toBe('apiCall'); + expect(change?.target).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}'); + const params = change?.params as { method?: string; body?: Record }; + expect(params?.method).toBe('PUT'); + expect(params?.body?.[field]).toBe('{{$event.target.checked}}'); + }); + + it('SMS 본문 미리보기(40자 절단)와 미설정 문구가 상호 조건으로 존재한다', () => { + const raw = JSON.stringify(row); + expect(raw).toContain('sms_body_prefix'); + expect(raw).toContain('.slice(0, 40)'); + expect(raw).toContain('template.row.sms_body_missing'); + }); + + it('[SMS 본문 편집] 버튼이 bz_sms_modal seed 후 SMS 모달을 연다', () => { + const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_edit_sms'); + expect(btn).toBeTruthy(); + const raw = JSON.stringify(btn); + expect(raw).toContain('"bz_sms_modal"'); + expect(raw).toContain('modal_bizppurio_sms'); + }); +}); + +describe('lifecycle UI — 작성/신청 모달(modal_bizppurio_template)', () => { + const modal = findById(overlayModalRoot, 'modal_bizppurio_template'); + + it('작성 모달이 3면(코어/게시판/이커머스) 오버레이 모두에 등록된다(행 footer 가 공유 참조)', () => { + for (const [label, o] of [['core', coreOverlay], ['board', boardOverlay], ['ecommerce', ecommerceOverlay]] as const) { + const ids = ((o as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id); + expect(ids, `${label} 오버레이 modals`).toContain('modal_bizppurio_template'); + } + }); + + it('저장 버튼은 id 유무로 POST(신규)/PUT(기존) 2벌 if 분기다', () => { + const saves = findAllByName(modal as AnyNode, 'Button') + .filter((b) => b.text === '$t:sirsoft-message_bizppurio.template.form.btn_save'); + expect(saves).toHaveLength(2); + const create = saves.find((b) => b.if === '{{!(_global.bz_tpl_modal?.id)}}'); + const update = saves.find((b) => b.if === '{{_global.bz_tpl_modal?.id}}'); + expect(create).toBeTruthy(); + expect(update).toBeTruthy(); + const createRaw = JSON.stringify(create); + expect(createRaw).toContain('"/api/plugins/sirsoft-message_bizppurio/admin/templates"'); + expect(createRaw).toContain('"method":"POST"'); + expect(createRaw).toContain('notification_type'); + const updateRaw = JSON.stringify(update); + expect(updateRaw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}'); + expect(updateRaw).toContain('"method":"PUT"'); + }); + + it('[저장 후 검수 신청] 버튼은 저장 onSuccess 로 request 엔드포인트를 체인한다(신규/기존 2벌)', () => { + const saveRequests = findAllByName(modal as AnyNode, 'Button') + .filter((b) => b.text === '$t:sirsoft-message_bizppurio.template.form.btn_save_request'); + expect(saveRequests).toHaveLength(2); + const create = saveRequests.find((b) => b.if === '{{!(_global.bz_tpl_modal?.id)}}'); + const update = saveRequests.find((b) => b.if === '{{_global.bz_tpl_modal?.id}}'); + // 신규: POST 저장 응답의 id 로 request + const createRaw = JSON.stringify(create); + expect(createRaw).toContain('"method":"POST"'); + expect(createRaw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request'); + // 기존: PUT 저장 후 자기 id 로 request + const updateRaw = JSON.stringify(update); + expect(updateRaw).toContain('"method":"PUT"'); + expect(updateRaw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request'); + }); + + it('강조 유형 조건부 섹션: TEXT(타이틀/서브타이틀)·IMAGE(업로드)·ITEM_LIST(아이템) 이 if 로 전환된다', () => { + const raw = JSON.stringify(modal); + expect(raw).toContain("_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'"); + expect(raw).toContain("_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'"); + expect(raw).toContain("_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'"); + // TEXT 섹션 필드 + expect(raw).toContain('templateTitle'); + expect(raw).toContain('templateSubtitle'); + }); + + it('부가정보(templateExtra) 는 EX·MI 메시지 유형에서만 노출된다', () => { + const raw = JSON.stringify(modal); + expect(raw).toContain("_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'"); + }); + + it('버튼 그룹은 최대 5개, 바로연결은 최대 10개에서 추가 버튼이 disabled 된다', () => { + const raw = JSON.stringify(modal); + expect(raw).toContain('"{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}"'); + expect(raw).toContain('"{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}"'); + }); + + it('이미지 업로드는 커스텀 핸들러(uploadTemplateImage)로 URL 을 상태에 기입한다', () => { + const raw = JSON.stringify(modal); + expect(raw).toContain('sirsoft-message_bizppurio.uploadTemplateImage'); + expect(raw).toContain('bz_tpl_modal.content.templateImageUrl'); + }); +}); + +describe('lifecycle UI — 승인 취소·반려·SMS 모달', () => { + it('승인 취소 모달: 경고 문구(cancel_approval.warning) + POST .../cancel-approval 배선', () => { + const modal = findById(overlayModalRoot, 'modal_bizppurio_cancel_approval'); + expect(modal).toBeTruthy(); + const raw = JSON.stringify(modal); + expect(raw).toContain('template.cancel_approval.warning'); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval'); + expect(raw).toContain('"method":"POST"'); + }); + + it('반려 사유 모달: inspection_detail 스냅샷(comments)을 반복 렌더한다', () => { + const modal = findById(overlayModalRoot, 'modal_bizppurio_rejection'); + expect(modal).toBeTruthy(); + const raw = JSON.stringify(modal); + expect(raw).toContain('_global.bz_reject_view?.comments'); + expect(raw).toContain('template.rejection.modal_title'); + }); + + it('SMS 본문 모달: delivery upsert(PUT) 로 sms_body 를 저장한다', () => { + const modal = findById(overlayModalRoot, 'modal_bizppurio_sms'); + expect(modal).toBeTruthy(); + const raw = JSON.stringify(modal); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}'); + expect(raw).toContain('"method":"PUT"'); + expect(raw).toContain('sms_body'); + }); +}); + +describe('lifecycle UI — 코어 무오염 + i18n 정합', () => { + it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => { + const all = JSON.stringify(overlay) + JSON.stringify(footer); + expect(all).not.toContain('/api/admin/notification-templates/'); + expect(all).not.toContain('notification_template_form_modal'); + }); + + it('참조하는 모든 플러그인 i18n 정적 키가 ko·en 에 존재한다', () => { + const keys = [...collectPluginKeys(overlay), ...collectPluginKeys(footer)]; + expect(keys.length).toBeGreaterThan(0); + for (const key of Array.from(new Set(keys))) { + expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy(); + expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy(); + } + }); + + it("동적 접두 'template.status.' 가족: ko·en 에 상태 8종 키가 모두 존재한다", () => { + const statuses = ['unwritten', 'draft', 'requested', 'approved', 'rejected', 'stopped', 'blocked', 'dormant']; + for (const dict of [ko, en] as Array>) { + expect(Object.keys(dict.template.status).sort()).toEqual([...statuses].sort()); + } + }); + + it("동적 접두 'templates.link_type.' 가족: 모달이 사용하는 링크 유형 코드가 ko·en 에 모두 존재한다", () => { + // 버튼 셀렉트 13종 + 바로연결 셀렉트 6종(부분집합) + const used = ['WL', 'AL', 'DS', 'BK', 'MD', 'AC', 'BC', 'BT', 'P1', 'P2', 'P3', 'TN', 'MP']; + for (const dict of [ko, en] as Array>) { + for (const code of used) { + expect(dict.templates.link_type[code], `link_type.${code}`).toBeTruthy(); + } + } + }); +}); + +describe('lifecycle UI — 바인딩 브레이스 위생 (회귀: 프리필 끝 `}}` 잔재)', () => { + it('문자열 값 어디에도 4연속 브레이스(}}}}·{{{{)가 없다', () => { + // 회귀 배경(#597 브라우저 실측): 작성 모달 본문 프리필 표현식이 `}}}}` 로 끝나 + // 엔진이 바인딩을 조기 종료하고 잔여 `}}` 가 본문 값에 리터럴로 붙었다. + // 구조적 중첩 브레이스(minified JSON)와 구분하기 위해 문자열 리프만 검사한다. + const collectStrings = (node: unknown, acc: string[] = []): string[] => { + if (typeof node === 'string') acc.push(node); + else if (Array.isArray(node)) node.forEach((c) => collectStrings(c, acc)); + else if (node && typeof node === 'object') Object.values(node).forEach((v) => collectStrings(v, acc)); + return acc; + }; + for (const [name, json] of [ + ['core', coreOverlay], + ['board', boardOverlay], + ['ecommerce', ecommerceOverlay], + ['footer', footer], + ] as const) { + const offenders = collectStrings(json).filter((v) => v.includes('}}}}') || v.includes('{{{{')); + expect(offenders, `${name} 브레이스 잔재: ${offenders.join(' | ')}`).toEqual([]); + } + }); + + it('표현식 $t 를 2-인자(파라미터 객체)로 호출하지 않는다 — 엔진은 단일 인자만 지원', () => { + // 회귀 배경(#597 §6.3 실측): `$t('key', {date: …})` 의 두 번째 인자는 엔진이 + // 무시해 "{date} 신청" 플레이스홀더 원문이 화면에 노출됐다. 파라미터 치환은 + // 텍스트 문법 `$t:key|param={{expr}}` 만 지원한다 (선례: admin_activity_log_list). + const collectStrings = (node: unknown, acc: string[] = []): string[] => { + if (typeof node === 'string') acc.push(node); + else if (Array.isArray(node)) node.forEach((c) => collectStrings(c, acc)); + else if (node && typeof node === 'object') Object.values(node).forEach((v) => collectStrings(v, acc)); + return acc; + }; + for (const [name, json] of [ + ['core', coreOverlay], + ['board', boardOverlay], + ['ecommerce', ecommerceOverlay], + ['footer', footer], + ] as const) { + const offenders = collectStrings(json).filter((v) => /\$t\([^)]*,\s*\{/.test(v)); + expect(offenders, `${name} 2-인자 $t 호출(파라미터 미치환 원문 노출): ${offenders.join(' | ')}`).toEqual([]); + } + }); +}); + +describe('lifecycle UI — 행 하단 버튼 노출 조건 실평가 (#597 §14.2 T7)', () => { + const row = findById(footerRoot, 'bizppurio_row_lifecycle') as AnyNode; + + /** + * `{{...}}` 단일 바인딩 형태의 조건식을 실제로 평가한다. + * + * 문자열 동일성 단언(`expect(btn.if).toBe('{{...}}')`)은 표현식이 바뀌면 테스트도 같이 + * 바뀌므로 회귀를 잡지 못한다 — 조건을 잘못 고쳐도 기대값을 함께 고치면 green 이다. + * 여기서는 식을 그대로 실행해 상태별 노출 결과를 판정한다. + */ + const evaluateIf = (expr: string, templates: Record, type: string): boolean => { + const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + // eslint-disable-next-line no-new-func + const fn = new Function('bizppurioTemplates', 'extensionPointProps', `return (${body});`); + return Boolean(fn({ data: { templates } }, { definition: { type }, activeChannel: 'alimtalk' })); + }; + + /** 상태별 요약 맵 행 (bizppurioTemplates.data.templates[type]) */ + const ROWS: Record | undefined> = { + '행 없음': undefined, + '행만 있고 내용 없음': { has_content: false, status: 'draft' }, + draft: { has_content: true, status: 'draft', template_code: null }, + requested: { has_content: true, status: 'requested', template_code: 'g7_a_1' }, + approved: { has_content: true, status: 'approved', template_code: 'g7_a_1' }, + rejected: { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [{ content: '반려 사유' }] }, + '반려(사유 없음)': { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [] }, + dormant: { has_content: true, status: 'dormant', template_code: 'g7_a_1' }, + stopped: { has_content: true, status: 'stopped', template_code: 'g7_a_1' }, + blocked: { has_content: true, status: 'blocked', template_code: 'g7_a_1' }, + }; + + /** 버튼 i18n 키 → 그 버튼이 보여야 하는 상태 이름 집합 */ + const EXPECTED_VISIBLE: Record = { + 'template.row.btn_compose': ['행 없음', '행만 있고 내용 없음'], + 'template.row.btn_edit': ['draft', 'rejected', '반려(사유 없음)'], + 'template.row.btn_edit_approved': ['approved'], + 'template.row.btn_request': ['draft'], + 'template.row.btn_cancel_request': ['requested'], + 'template.row.btn_release': ['dormant'], + 'template.row.btn_refresh': ['requested', 'approved', 'rejected', '반려(사유 없음)', 'dormant', 'stopped', 'blocked'], + 'template.row.btn_view_reason': ['rejected'], + }; + + it.each(Object.keys(EXPECTED_VISIBLE))('%s 의 노출 조건이 상태별로 정확히 평가된다', (key) => { + const btn = findButtonByTextKey(row, key); + expect(btn, `${key} 버튼이 레이아웃에 없다`).toBeTruthy(); + const expr = (btn as { if?: string }).if; + expect(expr, `${key} 에 if 조건이 없다`).toBeTruthy(); + + const expected = EXPECTED_VISIBLE[key]; + for (const [state, rowValue] of Object.entries(ROWS)) { + const templates = rowValue === undefined ? {} : { welcome: rowValue }; + const actual = evaluateIf(expr as string, templates, 'welcome'); + expect(actual, `${key} @ ${state}: ${expected.includes(state) ? '노출' : '숨김'} 이어야 한다`) + .toBe(expected.includes(state)); + } + }); + + it('상태 10종 × 버튼 8종을 모두 평가한다 (커버리지 하한 고정)', () => { + expect(Object.keys(ROWS)).toHaveLength(10); + expect(Object.keys(EXPECTED_VISIBLE)).toHaveLength(8); + }); +}); + +/** + * @effects upload_in_progress_locks_save_buttons_not_cancel + */ +describe('lifecycle UI — 이미지 업로드 상태 배선 (#597 §14.4·§14.5 V2)', () => { + const modalRaw = JSON.stringify(overlayModalRoot); + + it('업로드 진행 중에는 저장 계열 버튼이 잠기고, 취소는 잠기지 않는다', () => { + const buttons = findAllByName(overlayModalRoot, 'Button'); + const saveButtons = buttons.filter((b) => /template\.form\.btn_save(_request)?$/.test((b.text ?? '').replace('$t:sirsoft-message_bizppurio.', ''))); + expect(saveButtons.length, '작성 모달의 저장 계열 버튼(신규/수정 × 저장/저장후신청) 4개').toBe(4); + + // 문자열 포함 확인이 아니라 두 상태를 실제로 태운다 — `... && false` 로 바꿔도 + // toContain 은 통과하지만 아래 평가는 red 가 된다. + for (const b of saveButtons) { + const disabled = String((b.props as { disabled?: string } | undefined)?.disabled ?? ''); + expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }), + '업로드 in-flight 중 저장이 열려 있으면 빈 이미지 URL 로 저장된다').toBe(true); + expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: false } } }), + '업로드가 끝났는데도 저장이 잠겨 있으면 저장할 방법이 없다').toBe(false); + } + + const cancel = buttons.find((b) => b.text === '$t:common.cancel'); + const cancelDisabled = String((cancel?.props as { disabled?: string } | undefined)?.disabled ?? ''); + expect(evalBinding(cancelDisabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }), + '취소까지 잠그면 업로드가 매달렸을 때 모달을 빠져나갈 수 없다').toBe(false); + }); + + it('업로드 실패 배너는 상태에서만 읽고 fallback 을 가진다', () => { + expect(modalRaw).toContain("_global.bz_tpl_upload?.error ?? ''"); + }); +}); + +describe('lifecycle UI — SMS 본문 다국어 (#597 §14.3)', () => { + const modalRaw = JSON.stringify(overlayModalRoot); + + it('SMS 모달은 로케일 탭을 제공하고 본문을 로케일별로 읽는다', () => { + const tabs = findById(overlayModalRoot, 'bz_sms_lang_tabs'); + expect(tabs, 'SMS 본문 언어 탭이 없으면 ko 한 벌만 입력된다').toBeTruthy(); + expect(JSON.stringify(tabs)).toContain('{{$locales}}'); + expect(modalRaw).toContain('bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale]'); + }); + + it('SMS 본문 저장은 로케일 맵을 통째로 전송한다 (문자열 아님)', () => { + expect(modalRaw).toContain('"sms_body":"{{Object.assign({}, _global.bz_sms_modal?.body)}}"'); + }); + + it('변수 삽입 대상 경로가 현재 편집 로케일을 가리킨다', () => { + expect(modalRaw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)"); + }); +}); + +describe('compose modal — 유형 전환·반복 입력 실평가 (#597 §14.2 T7 / §6.2)', () => { + /** + * `{{...}}` 조건식을 모달 폼 상태에 대해 실제로 평가한다 (바인딩 프로브). + * + * 화면을 렌더하지 않고도 "이 상태에서 이 블록이 보이는가 / 이 버튼이 잠기는가" 를 + * 판정할 수 있다 — 조건식이 소비하는 자유변수는 `_global` 하나뿐이기 때문이다. + * substring 존재 확인은 조건을 잘못 고쳐도 통과하므로 회귀를 잡지 못한다. + */ + const evalWithModal = (expr: string, modal: Record): boolean => { + const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + // eslint-disable-next-line no-new-func + const fn = new Function('_global', `return (${body});`); + return Boolean(fn({ bz_tpl_modal: modal, bz_tpl_upload: {} })); + }; + + /** 강조유형별 조건부 블록의 if 식을 레이아웃에서 수집한다. */ + const conditionalBlocks = (): Record => { + const found: Record = {}; + const walk = (node: unknown): void => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (!node || typeof node !== 'object') return; + const rec = node as Record; + const cond = typeof rec.if === 'string' ? rec.if : ''; + const m = cond.match(/templateEmphasizeType === '(TEXT|IMAGE|ITEM_LIST)'/); + if (m && !found[m[1]]) found[m[1]] = cond; + Object.values(rec).forEach(walk); + }; + walk(overlayModalRoot); + return found; + }; + + it.each(['TEXT', 'IMAGE', 'ITEM_LIST'])('강조유형 %s 블록은 그 유형에서만 노출된다', (type) => { + const blocks = conditionalBlocks(); + const expr = blocks[type]; + expect(expr, `${type} 조건부 블록이 모달에 없다`).toBeTruthy(); + + for (const current of ['NONE', 'TEXT', 'IMAGE', 'ITEM_LIST']) { + const visible = evalWithModal(expr, { content: { templateEmphasizeType: current } }); + expect(visible, `강조유형 ${current} 일 때 ${type} 블록은 ${current === type ? '노출' : '숨김'}`) + .toBe(current === type); + } + }); + + it('강조유형 미선택(undefined) 상태에서는 조건부 블록이 모두 숨는다', () => { + const blocks = conditionalBlocks(); + for (const [type, expr] of Object.entries(blocks)) { + expect(evalWithModal(expr, { content: {} }), `${type} 블록`).toBe(false); + } + }); + + /** 반복 입력의 "추가" 버튼 상한 조건 — [상태 경로, 상한] */ + const REPEAT_CAPS: Array<[string, number]> = [ + ['templateItem?.list', 10], + ['buttons', 5], + ['quickReplies', 10], + ]; + + it.each(REPEAT_CAPS)('반복 입력 %s 는 상한 %i 에 도달하면 추가가 잠긴다', (pathFragment, cap) => { + const buttons = findAllByName(overlayModalRoot, 'Button'); + const addBtn = buttons.find((b) => { + const d = String((b.props as { disabled?: string } | undefined)?.disabled ?? ''); + return d.includes(pathFragment) && d.includes(`>= ${cap}`); + }); + expect(addBtn, `${pathFragment} 상한 ${cap} 을 거는 추가 버튼이 없다`).toBeTruthy(); + + const expr = String((addBtn?.props as { disabled?: string }).disabled); + const key = pathFragment.includes('templateItem') ? 'templateItem' : pathFragment; + const makeContent = (n: number): Record => { + const arr = Array.from({ length: n }, () => ({})); + return key === 'templateItem' ? { templateItem: { list: arr } } : { [key]: arr }; + }; + + // 경계 3점: 상한-1(열림) / 상한(잠김) / 상한+1(잠김) + expect(evalWithModal(expr, { content: makeContent(cap - 1) }), `${cap - 1}개: 열림`).toBe(false); + expect(evalWithModal(expr, { content: makeContent(cap) }), `${cap}개: 잠김`).toBe(true); + expect(evalWithModal(expr, { content: makeContent(cap + 1) }), `${cap + 1}개: 잠김`).toBe(true); + // 비어 있을 때(키 자체 부재)도 열려 있어야 한다 — ?? [] fallback 회귀 방지 + expect(evalWithModal(expr, { content: {} }), '항목 0개: 열림').toBe(false); + }); + + it('버튼 linkType 별 조건부 링크 필드가 선택한 유형에서만 노출된다', () => { + /** + * 기대값을 조건식에서 역산하지 않는다 — `linkType === 'X'` 를 조건식 자신에서 뽑아 + * 그 X 로 참이 되는지 보는 형태는 어떤 등가식이든 무조건 통과하는 준-항등식이고, + * "어느 입력칸이 어느 linkType 의 필드인가" 를 전혀 고정하지 못한다. + * 대응표는 부록 A-3(WL→linkMo/linkPc, AL→linkAnd+linkIos, TN→telNumber, + * P1~P3→pluginId)에서 가져와 **필드 바인딩 기준으로** 적는다. + */ + const EXPECTED_FOR_FIELD: Record = { + linkMo: ['WL'], + linkPc: ['WL'], + linkAnd: ['AL'], + linkIos: ['AL'], + telNumber: ['TN'], + pluginId: ['P1', 'P2', 'P3'], + }; + const ALL_LINK_TYPES = ['WL', 'AL', 'DS', 'BK', 'MD', 'AC', 'BC', 'BT', 'P1', 'P2', 'P3', 'TN', 'MP']; + + const inputs = findAllByName(overlayModalRoot, 'Input') + .filter((n) => /buttonsItem\.linkType === '/.test(String((n as { if?: string }).if ?? ''))); + + // 조건식이 아니라 value 바인딩으로 필드 정체를 식별한다. + const seen = new Set(); + for (const node of inputs) { + const value = String((node.props as { value?: string } | undefined)?.value ?? ''); + const field = (value.match(/buttonsItem\.([A-Za-z]+)/) ?? [])[1]; + expect(field, `value 바인딩에서 필드명을 못 읽었다: ${value}`).toBeTruthy(); + const expected = EXPECTED_FOR_FIELD[field as string]; + expect(expected, `부록 A-3 에 없는 조건부 필드: ${field}`).toBeTruthy(); + seen.add(field as string); + + const cond = String((node as { if?: string }).if); + for (const lt of ALL_LINK_TYPES) { + expect(evalBinding(cond, { buttonsItem: { linkType: lt } }), `${field} @ ${lt}`) + .toBe(expected!.includes(lt)); + } + } + + // 부록 A-3 의 조건부 필드가 하나라도 화면에서 사라지면 red (하한 단언이 아니라 전수 대조) + expect([...seen].sort()).toEqual(Object.keys(EXPECTED_FOR_FIELD).sort()); + }); +}); 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 d07a2545..998b17a5 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 @@ -162,7 +162,7 @@ describe('plugin_settings.json — 연결 확인 (§529)', () => { expect(field).not.toBe(findById(root, 'field_password')); }); - it('연결 확인 버튼이 캐시 초기화 버튼과 동일한 grid-cols-12(4/8) + 우측 정렬 패턴을 따른다', () => { + it('연결 확인 버튼이 설정 행과 동일한 grid-cols-12(4/8) + 우측 정렬 패턴을 따른다', () => { const field = findById(root, 'field_connection_check'); const raw = JSON.stringify(field); expect(raw).toContain('grid-cols-1'); @@ -346,8 +346,9 @@ describe('plugin_settings.json — 저장 버튼', () => { const allowed = [ 'apiCall', 'setState', 'toast', 'navigate', 'sequence', 'switch', 'refetchDataSource', 'scrollIntoView', 'copyToClipboard', - 'openModal', 'closeModal', 'replaceUrl', + 'openModal', 'closeModal', 'replaceUrl', 'suppress', 'sirsoft-message_bizppurio.uploadTemplateImage', + 'sirsoft-message_bizppurio.insertVariable', ]; for (const h of handlers) { expect(allowed).toContain(h); @@ -409,28 +410,26 @@ describe('plugin_settings.json — 탭 전환', () => { }); }); -describe('plugin_settings.json — 목록 조회 실패 표시', () => { - it('alimtalk_templates 데이터소스가 실패 사유를 _local.templateListError 에 담는다', () => { +describe('plugin_settings.json — templates 탭 readiness 게이트 (#597 재편)', () => { + // 구 조회 전용 목록(alimtalk_templates + templateListError 배너)은 #597 에서 + // DB 목록 관리 화면(bizppurio_templates_list)으로 교체됐다. 관리 화면 상세 배선은 + // plugin_settings_manage.test.tsx 가 담당하고, 여기서는 탭 골격만 잠근다. + it('templates_readiness 데이터소스가 templates 탭에서만 조회된다', () => { const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; - const ds = sources.find((s) => s.id === 'alimtalk_templates') as + const ds = sources.find((s) => s.id === 'templates_readiness') as | Record | undefined; expect(ds).toBeTruthy(); - const onError = JSON.stringify(ds?.onError ?? {}); - // 카카오가 준 사유(kakao_message)를 우선 노출, 없으면 error.message 폴백 - expect(onError).toContain('templateListError'); - expect(onError).toContain('kakao_message'); + expect(ds?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates-readiness'); + expect(ds?.if).toContain("query.tab ?? 'connection') === 'templates'"); }); - it('목록 오류 배너가 오류 존재 + 준비완료(ready) 조건으로 존재한다', () => { - const banner = findById(layout, 'templates_list_error') as - | Record - | undefined; - expect(banner).toBeTruthy(); - // 키 미설정(ready=false)일 때는 readiness 안내 배너가 담당 → 빨간 오류 배너는 숨겨 - // 두 배너가 동시에 뜨지 않게 한다. 즉 '키는 넣었는데 다른 이유로 실패'한 경우만 노출. - expect(banner?.if).toBe('{{_local.templateListError && templates_readiness?.data?.ready}}'); - // 사유 본문(카카오 실제 사유)을 그대로 렌더한다 - expect(JSON.stringify(banner)).toContain('{{_local.templateListError}}'); + it('readiness 안내(!ready)와 관리 뷰(ready)가 상호배타로 존재한다', () => { + const notice = findById(layout, 'templates_readiness') as Record | null; + const manageView = findById(layout, 'templates_manage_view') as Record | null; + expect(notice).toBeTruthy(); + expect(manageView).toBeTruthy(); + expect(notice?.if).toBe('{{templates_readiness?.data && !(templates_readiness?.data?.ready)}}'); + expect(manageView?.if).toBe('{{templates_readiness?.data?.ready}}'); }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx new file mode 100644 index 00000000..11b79b4e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx @@ -0,0 +1,373 @@ +// e2e:allow 관리 화면 mergeQuery 왕복·SMS 모달 저장 왕복 브라우저 흐름은 +// tests/Playwright/specs/admin/template-lifecycle.spec.ts(bizppurio_manage_round_trip_e2e)가 담당. +/** + * 플러그인 설정 — 알림 템플릿 관리 탭(DB 목록) 구조 검증 (#597 §4.3) + * + * @effects manage_screen_lists_db_rows_with_merge_query_round_trip + * + * templates 탭은 DB 목록 관리 화면이다: + * - 데이터소스 bizppurio_templates_list(GET /admin/templates) — 필터·검색·페이지는 + * URL 쿼리 SSoT(bz_status/bz_search/bz_page, mergeQuery 왕복 규약) + * - templates_manage_view — readiness 충족 시에만, 미충족이면 readiness 안내가 담당(배타) + * - 행 액션은 알림 설정 탭 행 하단과 동일 엔드포인트(request/cancel-request/cancel-approval/ + * release/sync/delivery) + 관리 전용 삭제(modal_bizppurio_delete, DELETE) + */ + +import { describe, it, expect } from 'vitest'; +import layout from '../../../layouts/admin/plugin_settings.json'; +import footer from '../../../extensions/notification_row_footer.json'; +import ko from '../../../lang/ko.json'; +import en from '../../../lang/en.json'; +import { findById, findAllByName, evalBinding, type AnyNode } from './helpers'; + +const root = layout as unknown as AnyNode; +const manageView = findById(root, 'templates_manage_view') as AnyNode; +const modalRoot = { children: (layout as { modals?: AnyNode[] }).modals ?? [] } as AnyNode; + +describe('manage — 목록 데이터소스(URL 쿼리 SSoT)', () => { + const ds = ((root as { data_sources?: Array> }).data_sources ?? []) + .find((d) => d.id === 'bizppurio_templates_list'); + + it('bizppurio_templates_list 가 GET /admin/templates 를 조회한다(auto_fetch:false)', () => { + expect(ds).toBeTruthy(); + expect(ds?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates'); + expect(ds?.method).toBe('GET'); + expect(ds?.auto_fetch).toBe(false); + }); + + it('params 가 query.bz_status/bz_search/bz_page 바인딩이다(목록 상태의 SSoT 는 URL)', () => { + const params = ds?.params as Record; + expect(params.status).toBe("{{query.bz_status ?? ''}}"); + expect(params.search).toBe("{{query.bz_search ?? ''}}"); + expect(params.page).toBe('{{query.bz_page ?? 1}}'); + expect(params.per_page).toBe(20); + }); + + it('templates 탭 진입 시 init_actions 가 목록을 조회한다(새로고침 복원)', () => { + const inits = (root as { init_actions?: AnyNode[] }).init_actions ?? []; + const refetch = inits.find( + (a) => a.handler === 'refetchDataSource' + && (a.params as { dataSourceId?: string })?.dataSourceId === 'bizppurio_templates_list', + ); + expect(refetch).toBeTruthy(); + expect((refetch as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'"); + }); + + it('tab_templates 탭 버튼이 navigate(mergeQuery) 후 목록을 refetch 한다', () => { + const raw = JSON.stringify(findById(root, 'tab_templates')); + expect(raw).toContain('"handler":"navigate"'); + expect(raw).toContain('"tab":"templates"'); + expect(raw).toContain('"mergeQuery":true'); + expect(raw).toContain('bizppurio_templates_list'); + }); +}); + +describe('manage — readiness 게이트(배타 전환)', () => { + it('관리 뷰는 readiness 충족 시에만 노출된다', () => { + expect(manageView).toBeTruthy(); + expect(manageView.if).toBe('{{templates_readiness?.data?.ready}}'); + }); + + it('readiness 미충족 안내는 !ready 조건으로 관리 뷰와 상호배타다', () => { + const notice = findById(root, 'templates_readiness'); + expect(notice).toBeTruthy(); + expect((notice as { if?: string }).if).toBe('{{templates_readiness?.data && !(templates_readiness?.data?.ready)}}'); + }); +}); + +describe('manage — 필터·검색·페이지 (mergeQuery 왕복 규약)', () => { + it('상태 필터 Select: value=query.bz_status, 옵션은 template.status.* 어휘, 변경 시 bz_page 리셋 + refetch', () => { + const select = findAllByName(manageView, 'Select') + .find((s) => (s.props as { value?: string })?.value === "{{query.bz_status ?? ''}}"); + expect(select).toBeTruthy(); + const options = String((select?.props as { options?: string })?.options ?? ''); + expect(options).toContain("template.status.' + s"); + expect(options).toContain("'draft','requested','approved','rejected','stopped','blocked','dormant'"); + const raw = JSON.stringify(select); + expect(raw).toContain('"mergeQuery":true'); + expect(raw).toContain('"bz_status":"{{$event.target.value}}"'); + expect(raw).toContain('"bz_page":""'); + expect(raw).toContain('bizppurio_templates_list'); + }); + + it('검색: 입력은 로컬 초안(bzSearchDraft), 실행 버튼이 bz_search 반영 + bz_page 리셋 + refetch', () => { + const input = findAllByName(manageView, 'Input') + .find((i) => String((i.props as { value?: string })?.value ?? '').includes('bzSearchDraft')); + expect(input).toBeTruthy(); + const searchBtn = findAllByName(manageView, 'Button') + .find((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_search'); + const raw = JSON.stringify(searchBtn); + expect(raw).toContain('"mergeQuery":true'); + expect(raw).toContain('"bz_search":"{{_local.bzSearchDraft ?? \'\'}}"'); + expect(raw).toContain('"bz_page":""'); + expect(raw).toContain('bizppurio_templates_list'); + }); + + it('새로고침 버튼은 URL 을 건드리지 않고 목록만 refetch 한다(보던 목록 유지)', () => { + const refreshBtn = findAllByName(manageView, 'Button') + .find((b) => b.text === '$t:sirsoft-message_bizppurio.templates.list.refresh'); + expect(refreshBtn).toBeTruthy(); + const raw = JSON.stringify(refreshBtn); + expect(raw).toContain('bizppurio_templates_list'); + expect(raw).not.toContain('"handler":"navigate"'); + }); + + it('Pagination 이 last_page 를 사용하고 페이지 이동은 bz_page 를 mergeQuery 로 나른 뒤 refetch 한다', () => { + const pagination = findAllByName(manageView, 'Pagination')[0]; + expect(pagination).toBeTruthy(); + const props = pagination.props as { currentPage?: string; totalPages?: string; hasMorePages?: string }; + expect(props.currentPage).toBe('{{bizppurio_templates_list?.data?.pagination?.current_page ?? 1}}'); + // last_page 는 대용량 목록 계약상 미상(null)일 수 있어 1 로 채우지 않는다 (pagination.md) + expect(props.totalPages).toBe('{{bizppurio_templates_list?.data?.pagination?.last_page ?? null}}'); + expect(props.hasMorePages).toBe('{{bizppurio_templates_list?.data?.pagination?.has_more_pages ?? false}}'); + const raw = JSON.stringify(pagination); + expect(raw).toContain('"mergeQuery":true'); + expect(raw).toContain('"bz_page":"{{$args[0]}}"'); + expect(raw).toContain('bizppurio_templates_list'); + }); + + it('총 건수와 빈 목록 안내가 존재한다', () => { + const raw = JSON.stringify(manageView); + expect(raw).toContain('manage.total_count'); + expect(raw).toContain('bizppurio_templates_list?.data?.pagination?.total'); + expect(raw).toContain('manage.empty'); + }); +}); + +/** + * @effects manage_row_actions_match_row_footer_visibility + */ +describe('manage — 행 액션(알림 설정 탭 행 하단과 동일 엔드포인트)', () => { + /** 노드 JSON 에서 템플릿 admin API 경로만 추출한다({{...}} 표현식 부분은 정규화). */ + const collectEndpoints = (json: unknown): Set => { + const text = JSON.stringify(json); + const matches = text.match(/\/api\/plugins\/sirsoft-message_bizppurio\/admin\/templates[^"]*/g) ?? []; + return new Set(matches.map((m) => m.replace(/\{\{[^}]+\}\}/g, '{id}'))); + }; + + it('상태 전이 4종(request/cancel-request/release/sync)이 목록 갱신과 함께 배선된다', () => { + const raw = JSON.stringify(manageView); + for (const suffix of ['/request', '/cancel-request', '/release', '/sync']) { + expect(raw, `${suffix} 누락`).toContain( + `/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}${suffix}`, + ); + } + // 전이 후에는 map 이 아니라 이 화면의 목록을 갱신한다 + expect(raw).toContain('bizppurio_templates_list'); + }); + + /** + * 관리 화면(bzRow)과 행 하단(요약 맵)의 노출 조건을 같은 상태 집합에 **실제로 태워** 대조한다. + * + * 문자열 동일성 단언은 두 면의 식이 실제로 같은 결과를 내는지 증명하지 못한다 — 실제로 + * `btn_refresh` 는 footer 가 `(row) && row?.template_code`, manage 는 `bzRow.template_code` + * 로 서로 다른 모양이고, `=== true` 와 truthy 도 갈린다. 두 식을 각자의 컨텍스트로 평가해 + * 상태별 결과가 일치하는지를 본다. + */ + const MANAGE_ROWS: Record> = { + '행만 있고 내용 없음': { has_content: false, status: 'draft' }, + draft: { has_content: true, status: 'draft', template_code: null }, + requested: { has_content: true, status: 'requested', template_code: 'g7_a_1' }, + approved: { has_content: true, status: 'approved', template_code: 'g7_a_1' }, + rejected: { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [{ content: '반려 사유' }] }, + '반려(사유 없음)': { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [] }, + dormant: { has_content: true, status: 'dormant', template_code: 'g7_a_1' }, + stopped: { has_content: true, status: 'stopped', template_code: 'g7_a_1' }, + blocked: { has_content: true, status: 'blocked', template_code: 'g7_a_1' }, + }; + + /** manage 화면 식을 bzRow 컨텍스트로 평가 */ + const evalManage = (expr: string, bzRow: Record): boolean => { + const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + // eslint-disable-next-line no-new-func + return Boolean(new Function('bzRow', `return (${body});`)(bzRow)); + }; + + /** footer 식을 요약 맵 컨텍스트로 평가 */ + const evalFooter = (expr: string, bzRow: Record): boolean => { + const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + // eslint-disable-next-line no-new-func + const fn = new Function('bizppurioTemplates', 'extensionPointProps', `return (${body});`); + return Boolean(fn({ data: { templates: { welcome: bzRow } } }, { definition: { type: 'welcome' } })); + }; + + const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode; + const footerRow = findById(footerRoot, 'bizppurio_row_lifecycle') as AnyNode; + const byTextKey = (root: AnyNode, key: string) => findAllByName(root, 'Button') + .find((b) => b.text === `$t:sirsoft-message_bizppurio.${key}`); + + /** 관리 화면 ↔ 행 하단에서 같은 의미를 갖는 버튼 쌍 */ + const PAIRS = [ + 'template.row.btn_request', + 'template.row.btn_cancel_request', + 'template.row.btn_release', + 'template.row.btn_refresh', + 'template.row.btn_edit_approved', + ]; + + it.each(PAIRS)('%s 의 노출 조건이 관리 화면과 행 하단에서 상태별로 같은 결과를 낸다', (key) => { + const m = byTextKey(manageView, key); + const f = byTextKey(footerRow, key); + expect(m?.if, `관리 화면에 ${key} if 가 없다`).toBeTruthy(); + expect(f?.if, `행 하단에 ${key} if 가 없다`).toBeTruthy(); + + for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) { + expect(evalManage(m!.if as string, bzRow), `${key} @ ${state}`) + .toBe(evalFooter(f!.if as string, bzRow)); + } + }); + + it('작성/수정 버튼은 행 하단의 작성+수정 두 버튼을 합친 것과 같은 상태에서 노출된다', () => { + // 관리 화면은 DB 목록이라 "행 없음" 이 없다 — footer 의 btn_compose(!row || !has_content)와 + // btn_edit(has_content && draft|rejected)를 합집합으로 놓고 대조한다. + const m = findAllByName(manageView, 'Button') + .find((b) => typeof b.text === 'string' && b.text.includes("sirsoft-message_bizppurio.manage.' + (bzRow.has_content")); + expect(m?.if, '관리 화면 작성/수정 버튼을 찾지 못했다').toBeTruthy(); + + const compose = byTextKey(footerRow, 'template.row.btn_compose'); + const edit = byTextKey(footerRow, 'template.row.btn_edit'); + expect(compose?.if).toBeTruthy(); + expect(edit?.if).toBeTruthy(); + + for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) { + const footerVisible = evalFooter(compose!.if as string, bzRow) || evalFooter(edit!.if as string, bzRow); + expect(evalManage(m!.if as string, bzRow), `작성/수정 @ ${state}`).toBe(footerVisible); + } + }); + + it('작성/수정 버튼의 라벨이 내용 유무로 갈린다(미작성 행에 "수정" 이 뜨지 않는다)', () => { + const m = findAllByName(manageView, 'Button') + .find((b) => typeof b.text === 'string' && b.text.includes("sirsoft-message_bizppurio.manage.' + (bzRow.has_content")); + const text = m?.text as string; + expect(text).toContain('bzRow.has_content'); + expect(text).toContain('btn_edit'); + expect(text).toContain('btn_compose'); + expect(ko.manage.btn_compose).toBeTruthy(); + expect(en.manage.btn_compose).toBeTruthy(); + }); + + it('승인 취소·SMS 본문·delivery upsert 는 행 하단(footer)과 같은 엔드포인트 계열을 쓴다', () => { + const layoutEndpoints = collectEndpoints(layout); + const footerEndpoints = collectEndpoints(footer); + // footer 가 쓰는 계열(map 조회 제외 — 관리 화면은 DB 목록을 쓴다)이 관리 화면에도 존재한다 + for (const ep of footerEndpoints) { + if (ep.endsWith('/map')) continue; + expect(layoutEndpoints, `관리 화면에 ${ep} 누락`).toContain(ep); + } + // 승인 취소 모달 공유 확인 + expect(JSON.stringify(layout)).toContain( + '/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval', + ); + expect(JSON.stringify(layout)).toContain( + '/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}', + ); + }); + + it('모달 5종(작성/SMS/승인취소/반려/삭제)이 이 레이아웃에 등록된다', () => { + const ids = ((layout as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id); + expect(ids).toEqual([ + 'modal_bizppurio_template', + 'modal_bizppurio_sms', + 'modal_bizppurio_cancel_approval', + 'modal_bizppurio_rejection', + 'modal_bizppurio_delete', + ]); + }); +}); + +describe('manage — 삭제 모달(modal_bizppurio_delete)', () => { + const modal = findById(modalRoot, 'modal_bizppurio_delete'); + + it('행 삭제 버튼이 bz_delete_modal seed 후 삭제 확인 모달을 연다', () => { + const deleteBtn = findAllByName(manageView, 'Button') + .find((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_delete'); + expect(deleteBtn).toBeTruthy(); + const raw = JSON.stringify(deleteBtn); + expect(raw).toContain('"bz_delete_modal"'); + expect(raw).toContain('modal_bizppurio_delete'); + }); + + it('확정 시 DELETE /admin/templates/{id} 후 목록 갱신 + 모달 닫힘', () => { + expect(modal).toBeTruthy(); + const raw = JSON.stringify(modal); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_delete_modal?.id}}'); + expect(raw).toContain('"method":"DELETE"'); + expect(raw).toContain('bizppurio_templates_list'); + expect(raw).toContain('"closeModal"'); + }); + + it('카카오측 동반 삭제 여부(kakao_deleted)에 따라 완료 문구를 분기한다', () => { + const raw = JSON.stringify(modal); + expect(raw).toContain('kakao_deleted'); + expect(raw).toContain('manage.delete.done_with_kakao'); + expect(raw).toContain('manage.delete.done_db_only'); + }); +}); + +describe('manage — i18n 정합(manage.* 키 가족)', () => { + it("동적 접두 'manage.owner.' 가족: core/module/plugin 라벨이 ko·en 에 존재한다", () => { + for (const dict of [ko, en] as Array>) { + for (const owner of ['core', 'module', 'plugin']) { + expect(dict.manage.owner[owner], `manage.owner.${owner}`).toBeTruthy(); + } + } + }); + + it('목록 컬럼 헤더 키 7종이 ko·en 에 존재한다', () => { + const columns = ['notification', 'owner', 'status', 'sms', 'requested_at', 'synced_at', 'actions']; + for (const dict of [ko, en] as Array>) { + for (const col of columns) { + expect(dict.manage.columns[col], `manage.columns.${col}`).toBeTruthy(); + } + } + }); +}); + +/** + * @effects upload_in_progress_locks_save_buttons_not_cancel, sms_modal_edits_body_per_locale_tab + */ +describe('manage 모달 — 이미지 업로드 배선·SMS 언어 탭 (#597 §15.2 U1 / §15.1)', () => { + /** + * 관리 화면(plugin_settings.json)은 3면 오버레이(notification_tab_*.json)의 패리티 대상이 + * **아니다** — 세 오버레이는 서로 전문 동일성으로 묶여 있지만 이 면은 별도 파일이다. + * 그래서 §15.2 U1(업로드 중 저장 잠금)·§15.1(SMS 언어 탭)이 이 면에서 되돌려져도 + * 다른 어떤 테스트도 red 가 되지 않는다. 여기서 직접 고정한다. + */ + const modalRaw = JSON.stringify(modalRoot); + + it('작성 모달의 저장 계열 4버튼이 업로드 중 잠기고, 취소는 잠기지 않는다', () => { + const composeModal = findById(modalRoot, 'modal_bizppurio_template') as AnyNode; + expect(composeModal, '관리 화면에 작성 모달이 없다').toBeTruthy(); + + const buttons = findAllByName(composeModal, 'Button'); + const saveButtons = buttons.filter((b) => /template\.form\.btn_save(_request)?$/ + .test((b.text ?? '').replace('$t:sirsoft-message_bizppurio.', ''))); + expect(saveButtons.length, '신규/수정 × 저장/저장후신청 = 4').toBe(4); + + for (const b of saveButtons) { + const disabled = String((b.props as { disabled?: string } | undefined)?.disabled ?? ''); + expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }), + '업로드 중 저장이 열려 있으면 빈 이미지 URL 로 저장된다').toBe(true); + expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: false } } }), + '업로드가 끝났는데 저장이 잠겨 있으면 저장할 방법이 없다').toBe(false); + } + + const cancel = buttons.find((b) => b.text === '$t:common.cancel'); + const cancelDisabled = String((cancel?.props as { disabled?: string } | undefined)?.disabled ?? ''); + expect(evalBinding(cancelDisabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }), + '취소까지 잠그면 업로드가 매달렸을 때 모달을 빠져나갈 수 없다').toBe(false); + }); + + it('업로드 실패 배너는 상태에서만 읽고 fallback 을 가진다', () => { + expect(modalRaw).toContain("_global.bz_tpl_upload?.error ?? ''"); + }); + + it('SMS 모달이 로케일 탭을 제공하고 본문을 로케일 맵으로 읽고 쓴다', () => { + const tabs = findById(modalRoot, 'bz_sms_lang_tabs'); + expect(tabs, '관리 화면 SMS 모달에 언어 탭이 없으면 ko 한 벌만 입력된다').toBeTruthy(); + expect(JSON.stringify(tabs)).toContain('{{$locales}}'); + expect(modalRaw).toContain('bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale]'); + expect(modalRaw).toContain('"sms_body":"{{Object.assign({}, _global.bz_sms_modal?.body)}}"'); + expect(modalRaw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)"); + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts new file mode 100644 index 00000000..5cf6b09a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts @@ -0,0 +1,201 @@ +/** + * @file template_request_reentrancy_guard.test.ts + * @description 검수 신청 체인 재진입(더블 클릭) 가드 회귀 테스트 (#597 §6.3 10c) + * + * 배경: 검수 신청(POST …/request)은 kapi add/update 를 동반하므로 더블 클릭이 + * 두 체인을 발화하면 카카오측 중복 등록·중복 채번이 발생할 수 있다. disabled + * prop 은 React 리렌더 이후에만 반영되어 같은 태스크에서 연속 디스패치되는 + * 두 번째 click 을 막지 못한다 — 실측: 더블 클릭 → PUT 2회 + request 2회. + * + * 방어는 click 액션(sequence) 레벨의 `if` 재진입 가드다(코어 선례: + * admin_role_form.json 등의 `"if": "{{!_global.isSaving}}"`). 엔진 if 평가는 + * 디스패치 시점의 동기 검사라 리렌더를 기다리지 않는다. + * + * 이 테스트는 신청 체인을 포함하는 모든 click 액션이 재진입 가드(if)를 + * 보유하는지 4개 산출 파일 전수로 고정한다. 가드가 한 파일에서라도 빠지면 + * 그 면에서만 중복 신청이 재발한다. + * + * 실행 검증은 §6.3 10c 브라우저 실측이 담당한다. 라운드 5 실측 결과를 여기 남긴다: + * **클라이언트 if 가드만으로는 요청 2회가 나간다.** setState 가 전역 스토어에 반영되기 + * 전에 두 번째 click 이 디스패치되기 때문이며, 30ms 간격 더블 클릭에서 재현된다. + * + * 중복 신청을 실제로 막는 것은 **서버의 원자 선점**(claimForInspection)이다 — 실측에서 + * 두 번째 POST …/request 는 422 "현재 상태(requested)에서는 검수를 신청할 수 없습니다." + * 로 거부됐고, 카카오측 중복 등록은 발생하지 않았다. 저장(PUT)은 멱등이라 무해하다. + * + * 따라서 이 구조 테스트는 "가드가 선언돼 있다" 만 고정한다 — 효력의 SSoT 는 서버 가드이고, + * 그것은 PHPUnit(claimForInspection)과 §6.3 10c 실측이 고정한다. + * + * @effects request_chain_click_actions_carry_reentrancy_guard, save_chain_click_actions_carry_reentrancy_guard + */ + +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +const BASE = path.resolve(__dirname, '../../../..'); + +const FILES = [ + 'resources/extensions/notification_tab_core.json', + 'resources/extensions/notification_tab_board.json', + 'resources/extensions/notification_tab_ecommerce.json', + 'resources/extensions/notification_row_footer.json', + 'resources/layouts/admin/plugin_settings.json', +]; + +interface ClickAction { + file: string; + targets: string[]; + ifExpr: string | undefined; + raw: string; + buttonDisabled: string | undefined; +} + +/** + * 검수 신청(…/request) apiCall 을 포함하는 click 액션을 전수 수집합니다. + * cancel-request 는 서버 상태 가드(422)로 순차 중복이 무해하므로 제외한다. + * + * @param file 저장소 루트 기준 상대 경로 + * @returns 신청 체인을 품은 click 액션 목록 + */ +function collectRequestClickActions(file: string): ClickAction[] { + const doc = JSON.parse(fs.readFileSync(path.join(BASE, file), 'utf-8')); + const found: ClickAction[] = []; + + const collectTargets = (node: unknown, acc: string[]): void => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach((c) => collectTargets(c, acc)); + return; + } + const n = node as Record; + if ( + n.handler === 'apiCall' && + typeof n.target === 'string' && + /\/request$/.test(n.target) + ) { + acc.push(n.target); + } + Object.values(n).forEach((v) => collectTargets(v, acc)); + }; + + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + const n = node as Record; + if (Array.isArray(n.actions)) { + for (const action of n.actions) { + if (action?.type !== 'click') continue; + const targets: string[] = []; + collectTargets(action, targets); + if (targets.length > 0) { + found.push({ + file, + targets, + ifExpr: action.if, + raw: JSON.stringify(action), + buttonDisabled: n.name === 'Button' ? n.props?.disabled : undefined, + }); + } + } + } + Object.values(n).forEach(walk); + }; + + walk(doc); + return found; +} + +describe('검수 신청 체인 재진입 가드 (#597 10c)', () => { + const all = FILES.flatMap(collectRequestClickActions); + + it('신청 체인을 포함한 click 액션이 산출 파일 전체에서 수집된다 (0건이면 아래 단언은 공회전한다)', () => { + // 모달 저장 후 신청(신규/수정) ×4파일 + row footer 신청 + 관리 화면 행 신청 + expect(all.length).toBeGreaterThanOrEqual(6); + }); + + it.each(FILES)('%s 의 신청 체인 click 액션은 모두 재진입 if 가드를 갖는다', (file) => { + const actions = all.filter((a) => a.file === file); + for (const action of actions) { + expect( + action.ifExpr, + `${file} 의 신청 체인(${action.targets[0]})에 if 재진입 가드가 없다 — 더블 클릭이 중복 신청을 발화한다`, + ).toBeTruthy(); + expect( + action.ifExpr, + `${file} 의 신청 체인 if 가드는 in-flight 플래그(isSaving/bz_row_busy)를 부정 조건으로 검사해야 한다`, + ).toMatch(/!\((_global\.bz_tpl_modal\?\.isSaving|_global\.bz_row_busy) \?\? false\)/); + } + }); + + it('신청 체인 버튼은 in-flight 플래그를 disabled 로 바인딩한다 (구독 없으면 액션 컨텍스트가 갱신되지 않아 if 가드가 항상 stale false 를 본다 — 실측)', () => { + for (const action of all) { + const flag = action.ifExpr?.includes('bz_row_busy') ? 'bz_row_busy' : 'bz_tpl_modal?.isSaving'; + expect( + action.buttonDisabled, + `${action.file} 의 신청 체인 버튼(${action.targets[0]})은 disabled 를 ${flag} 로 바인딩해야 한다 — 바인딩이 없으면 리렌더·구독이 일어나지 않아 더블 클릭이 그대로 통과한다`, + ).toContain(flag); + } + }); + + it('bz_row_busy 플래그를 쓰는 체인은 성공·실패 양쪽에서 플래그를 해제한다', () => { + const rowChains = all.filter((a) => a.ifExpr?.includes('bz_row_busy')); + for (const action of rowChains) { + const resets = action.raw.match(/"bz_row_busy": ?false/g) ?? []; + expect( + resets.length, + `${action.file} 의 row 신청 체인은 onSuccess·onError 양쪽에서 bz_row_busy 를 해제해야 한다 — 한쪽만 해제하면 실패 후 버튼이 영구 잠긴다`, + ).toBeGreaterThanOrEqual(2); + } + }); +}); + +describe('저장 체인 재진입 가드 (#597 라운드 5 §6.3 10c 실측 파생)', () => { + /** + * 브라우저 실측에서 [저장] 더블 클릭 시 PUT 이 **2회** 나갔다. + * + * 형제 버튼인 [저장 후 검수 신청] 은 이미 액션 레벨 if 로 재진입을 막고 있었는데 + * 저장만 빠져 있었다 — 같은 모달의 같은 결함군에 규칙이 두 벌이었던 셈이다. + * disabled prop 은 리렌더 이후에만 반영되므로 같은 태스크의 두 번째 click 을 막지 못한다. + */ + const GUARD = '{{!(_global.bz_tpl_modal?.isSaving ?? false)}}'; + + /** 저장 버튼(btn_save)의 click 액션을 전수 수집한다. */ + const collectSaveClickActions = (file: string): Array<{ file: string; ifExpr?: string }> => { + const doc = JSON.parse(fs.readFileSync(path.join(BASE, file), 'utf-8')); + const found: Array<{ file: string; ifExpr?: string }> = []; + + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { node.forEach(walk); return; } + const n = node as Record; + const isSave = n.name === 'Button' + && typeof n.text === 'string' + && n.text.endsWith('sirsoft-message_bizppurio.template.form.btn_save'); + if (isSave && Array.isArray(n.actions)) { + for (const a of n.actions) { + if (a?.type === 'click') found.push({ file, ifExpr: a.if }); + } + } + Object.values(n).forEach(walk); + }; + + walk(doc); + + return found; + }; + + it('저장 버튼의 click 액션이 4개 산출 파일 전수에서 재진입 가드를 보유한다', () => { + const all = FILES.flatMap(collectSaveClickActions); + // 3면 오버레이 + 관리 화면 = 4면 × (신규/수정) 2버튼 + expect(all.length, '저장 버튼 click 액션 수').toBe(8); + + for (const a of all) { + expect(a.ifExpr, `${a.file}: 저장 체인에 재진입 가드(if)가 없다 — 더블 클릭 시 중복 저장`) + .toBe(GUARD); + } + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts index ffbf6891..134e09c8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts @@ -2,10 +2,16 @@ * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵. * * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인 - * 식별자가 네임스페이스로 접두된다. + * 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable * - * 현재 등록 핸들러 없음(알림톡 템플릿 등록·이미지 업로드 제거로 uploadTemplateImage 제거). - * 커스텀 핸들러 추가 시 이 맵에 등록한다. + * - insertVariable: 작성 모달·SMS 모달 본문에 `#{변수}` 커서 삽입 (#597 §4.2) + * - uploadTemplateImage: 이미지형 템플릿 이미지 업로드 프록시 호출 (multipart — apiCall 미적합) */ -export const handlerMap = {} as const; \ No newline at end of file +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..4938e77e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts @@ -0,0 +1,117 @@ +/** + * insertVariable 핸들러 (#597 §4.2) + * + * 알림톡 템플릿 작성 모달·SMS 본문 모달에서 "사용 가능 변수" 칩을 클릭하면, 대상 본문의 + * 현재 커서 위치에 `#{변수}` 를 삽입한다. 값 자체는 G7 상태를 SSoT 로 삼아 갱신한다 + * (DOM 값을 직접 쓰지 않으므로 다음 렌더에서 상태값으로 덮여 사라지는 충돌이 없다). + * + * 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다. + * + * 레이아웃 JSON 사용 예: + * { + * "handler": "sirsoft-message_bizppurio.insertVariable", + * "params": { + * "variable": "name", + * "target": "bz_template_content", + * "stateTarget": "global", + * "statePath": "bz_tpl_modal.content.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 = 'bz_template_content'; + +/** 상태 경로 기본값 */ +const DEFAULT_STATE_PATH = 'bz_tpl_modal.content.templateContent'; + +/** + * 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다. + * + * @param action 액션 객체(params.variable/target/stateTarget/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 stateTarget = String(params.stateTarget ?? 'global'); + const statePath = String(params.statePath ?? DEFAULT_STATE_PATH); + + if (variable === '') { + logger.warn('삽입할 변수명이 지정되지 않았습니다.'); + return; + } + + const setState = stateTarget === 'local' + ? G7Core?.state?.setLocal + : (G7Core?.state?.setGlobal ?? G7Core?.state?.set); + const getState = stateTarget === 'local' + ? G7Core?.state?.getLocal + : (G7Core?.state?.getGlobal ?? G7Core?.state?.get); + + if (!setState || !getState) { + logger.error('G7Core.state API 를 사용할 수 없습니다.'); + return; + } + + // 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getState. + const stateSnapshot = stateTarget === 'local' + ? ((context as any)?.state ?? getState() ?? {}) + : (getState() ?? {}); + const current = String(readPath(stateSnapshot, 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); + + setState({ [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} → ${stateTarget}:${statePath}`); +} + +/** + * dot notation 경로로 객체에서 값을 읽습니다. (예: "bz_tpl_modal.content.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..d5d6203f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts @@ -0,0 +1,292 @@ +/** + * uploadTemplateImage 핸들러 (#597 §3.2 · 부록 A-7) + * + * 이미지형 알림톡 템플릿 작성 모달에서 파일 선택(change) 시, 선택한 이미지를 플러그인의 + * 이미지 업로드 프록시(`POST /admin/templates/image` — kapi 위임)로 전송하고, 반환된 + * 카카오 이미지 URL 을 params.statePathUrl 에, 파일명을 params.statePathName 에 설정한다. + * + * 업로드는 multipart/form-data 이므로 apiCall 핸들러로는 처리하기 번거로워 전용 핸들러로 둔다. + * 진행 상태·오류는 params.statePathStatus(기본 bz_tpl_upload) 하위의 + * `{uploading, error}` 로 노출한다. + * + * 레이아웃 JSON 사용 예: + * { + * "type": "change", + * "handler": "sirsoft-message_bizppurio.uploadTemplateImage", + * "params": { + * "stateTarget": "global", + * "statePathUrl": "bz_tpl_modal.content.templateImageUrl", + * "statePathName": "bz_tpl_modal.content.templateImageName", + * "statePathStatus": "bz_tpl_upload" + * } + * } + */ + +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/templates/image'; + +/** 서버 사유가 없을 때 쓰는 최후 폴백 문구의 다국어 키 */ +const FAILURE_KEY = 'sirsoft-message_bizppurio.template.form.image_upload_failed'; + +/** + * 운영자 로케일을 Accept-Language 로 실어 보냅니다. + * + * 이 핸들러는 multipart 때문에 코어 ApiClient/ActionDispatcher 를 우회해 raw fetch 를 + * 쓰므로, 두 경로가 공통으로 붙이는 로케일 헤더를 여기서 직접 재현한다. 빠뜨리면 서버 + * 검증 메시지와 카카오 사유 원문이 운영자 언어가 아닌 앱 기본 로케일로 돌아온다. + * + * @returns Accept-Language 헤더 객체 (미설정 시 빈 객체) + */ +function localeHeader(): Record { + if (typeof window === 'undefined') { + return {}; + } + + const locale = localStorage.getItem('g7_locale'); + + return locale ? { 'Accept-Language': locale } : {}; +} + +/** + * 업로드 실패 폴백 문구를 반환합니다. + * + * @param G7Core 전역 G7Core 객체 + * @returns 번역된 문구 (해석 실패 시 영문 폴백) + */ +function failureText(G7Core: any): string { + const translated = G7Core?.t?.(FAILURE_KEY); + + return (typeof translated === 'string' && translated !== FAILURE_KEY) + ? translated + : 'Failed to upload the image.'; +} + +/** + * stateTarget(global|local)에 맞는 상태 setter 를 반환합니다. + * + * @param G7Core 전역 G7Core 객체 + * @param target 'global' | 'local' + * @returns 상태 setter 또는 null + */ +function resolveSetter(G7Core: any, target: string): ((updates: Record) => void) | null { + if (target === 'local') { + return G7Core?.state?.setLocal ?? null; + } + + return G7Core?.state?.setGlobal ?? G7Core?.state?.set ?? null; +} + +/** + * stateTarget(global|local)에 맞는 상태 getter 를 반환합니다. + * + * @param G7Core 전역 G7Core 객체 + * @param target 'global' | 'local' + * @returns 상태 getter 또는 null + */ +function resolveGetter(G7Core: any, target: string): (() => Record) | null { + if (target === 'local') { + return G7Core?.state?.getLocal ?? null; + } + + return G7Core?.state?.getGlobal ?? G7Core?.state?.get ?? null; +} + +/** + * dot notation 경로로 객체에서 값을 읽습니다. + * + * @param obj 대상 객체 + * @param path dot 경로 + * @returns 경로 값(없으면 undefined) + */ +function readPath(obj: Record, path: string): unknown { + return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); +} + +/** + * 이 업로드의 결과를 아직 써도 되는지 판정합니다. + * + * 응답이 도착했을 때 모달이 이미 다른 알림으로 바뀌었거나 다시 열렸다면, 그 결과는 지금 + * 화면이 편집 중인 템플릿의 것이 아니다. 그대로 쓰면 A 알림의 이미지가 B 알림 폼에 기입되고 + * (실패 경로에서는 B 가 방금 시딩한 값이 지워진다) 운영자에게는 아무 단서도 남지 않는다. + * + * 판정 신호는 진행 플래그 자신이다 — 모달을 여는 지점은 모두 statePathStatus 를 + * `{uploading:false, error:null}` 로 리시드하므로, 우리가 켜 둔 uploading 이 사라졌다면 + * 그 사이에 모달이 다시 시딩된 것이다. + * + * @param G7Core 전역 G7Core 객체 + * @param stateTarget 'global' | 'local' + * @param pathStatus 업로드 상태 경로 + * @returns 결과를 반영해도 되면 true + */ +function stillOwnsUpload(G7Core: any, stateTarget: string, pathStatus: string): boolean { + const getter = resolveGetter(G7Core, stateTarget); + + // getter 를 못 쓰면 판정 불가 — 기존 동작(그대로 반영)을 유지한다. + if (!getter) { + return true; + } + + try { + return readPath(getter() ?? {}, `${pathStatus}.uploading`) === true; + } catch { + return true; + } +} + +/** 동시 업로드 차단 플래그 — 진행 중에는 새 파일 선택을 받지 않는다. */ +let uploadInFlight = false; + +/** + * 선택한 이미지 파일을 업로드하고 결과 URL 을 상태에 반영합니다. + * + * @param action 액션 객체(params.stateTarget/statePathUrl/statePathName/statePathStatus) + * @param context 액션 컨텍스트($event 로 파일 접근) + */ +export async function uploadTemplateImageHandler( + action: ActionWithParams, + context: ActionContext, +): Promise { + const G7Core = (window as any).G7Core; + const params = action.params ?? {}; + const stateTarget = String(params.stateTarget ?? 'global'); + const pathUrl = String(params.statePathUrl ?? 'bz_tpl_modal.content.templateImageUrl'); + const pathName = String(params.statePathName ?? 'bz_tpl_modal.content.templateImageName'); + const pathStatus = String(params.statePathStatus ?? 'bz_tpl_upload'); + + const setState = resolveSetter(G7Core, stateTarget); + if (!setState) { + logger.error('G7Core.state setter 를 사용할 수 없습니다.'); + 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; + } + + // 진행 중인 업로드가 있으면 새 선택을 받지 않는다. 두 요청이 겹치면 먼저 끝난 쪽이 + // uploading:false 를 써서 저장 버튼 잠금이 풀리고, 나중에 끝나는 업로드가 저장 이후에 + // templateImageUrl 을 덮는다 — 잠금이 막으려던 "빈/엉뚱한 URL 저장" 이 그대로 재현된다. + if (uploadInFlight) { + logger.warn('이미 업로드가 진행 중입니다 — 새 파일 선택을 무시합니다.'); + if (input) { + input.value = ''; + } + + return; + } + + uploadInFlight = true; + setState({ [`${pathStatus}.uploading`]: true, [`${pathStatus}.error`]: 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}` } : {}), + ...localeHeader(), + }, + body: form, + }); + + const json = await res.json().catch(() => ({})); + + if (!res.ok || json?.success === false) { + // 서버가 내려준 사유(카카오 원문·검증 메시지)는 그대로 보여준다 — + // Accept-Language 를 실어 보냈으므로 운영자 로케일로 도착한다. + const message = json?.errors?.bizppurio_message + || json?.errors?.image?.[0] + || json?.message + || failureText(G7Core); + if (stillOwnsUpload(G7Core, stateTarget, pathStatus)) { + failUpload(setState, pathStatus, pathUrl, pathName, message); + } + logger.warn('이미지 업로드 실패:', message); + return; + } + + const url = String(json?.data?.url ?? '').trim(); + + // 성공 응답인데 url 이 비어 있으면 성공이 아니다 — 그대로 두면 배너는 성공으로 보이는데 + // templateImageUrl 이 빈 채로 저장돼 검수 신청 시점에야 드러난다. + if (url === '') { + if (stillOwnsUpload(G7Core, stateTarget, pathStatus)) { + failUpload(setState, pathStatus, pathUrl, pathName, failureText(G7Core)); + } + logger.warn('이미지 업로드 응답에 url 이 없습니다:', json); + return; + } + + if (!stillOwnsUpload(G7Core, stateTarget, pathStatus)) { + logger.warn('모달이 다시 시딩되어 업로드 결과를 버립니다:', url); + + return; + } + + setState({ + [`${pathStatus}.uploading`]: false, + [`${pathStatus}.error`]: null, + [pathUrl]: url, + [pathName]: file.name, + }); + + logger.log('이미지 업로드 완료:', url); + } catch (e) { + // 예외 원문(TypeError 등)은 운영자에게 의미가 없고 내부 구현을 노출한다 — + // 화면에는 번역 문구만 싣고 원문은 콘솔로만 남긴다. + if (stillOwnsUpload(G7Core, stateTarget, pathStatus)) { + failUpload(setState, pathStatus, pathUrl, pathName, failureText(G7Core)); + } + logger.error('이미지 업로드 예외:', e); + } finally { + uploadInFlight = false; + + // 같은 파일 재선택이 가능하도록 input 값 초기화 + if (input) { + input.value = ''; + } + } +} + +/** + * 업로드 실패 상태를 기록하고 직전 이미지 값을 무효화합니다. + * + * 실패 경로가 url/파일명을 그대로 두면, 배너는 "실패" 인데 폼에는 직전에 성공한 이미지가 + * 남아 그대로 저장된다 — 운영자는 방금 고른 이미지가 저장된다고 믿는다. 실패했으면 + * 화면에 보이는 이미지도 없어야 한다. + * + * @param setState 상태 setter + * @param pathStatus 업로드 상태 경로 (uploading/error) + * @param pathUrl 이미지 URL 상태 경로 + * @param pathName 이미지 파일명 상태 경로 + * @param message 화면에 표시할 실패 사유 + */ +function failUpload( + setState: (patch: Record) => void, + pathStatus: string, + pathUrl: string, + pathName: string, + message: string, +): void { + setState({ + [`${pathStatus}.uploading`]: false, + [`${pathStatus}.error`]: message, + [pathUrl]: '', + [pathName]: '', + }); +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts index 9bff1ba5..02e0e40b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts @@ -3,7 +3,7 @@ * * 플러그인 활성화 시 자동 로드되어 handlerMap 의 커스텀 핸들러를 ActionDispatcher 에 * 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다. - * (현재 등록 핸들러 없음 — 필요 시 handlers/index.ts 에 추가.) + * (현재 등록 핸들러: insertVariable, uploadTemplateImage — handlers/index.ts 참조.) */ import { handlerMap } from './handlers'; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index a53bb8b7..bb2ebd21 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -1,27 +1,27 @@ { "name": "Bizppurio Messaging", - "description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin.", + "description": "Bizppurio SMS/LMS and Kakao Alimtalk delivery plugin.", "settings": { "title": "Bizppurio Messaging Settings", - "description": "Manage Bizppurio integration credentials and sending options.", + "description": "Manage Bizppurio integration and delivery settings.", "save": "Save", "saving": "Saving...", "save_success": "Settings saved.", "save_failed": "Failed to save settings.", "test_mode": { - "label": "Inspection Mode", - "hint": "In inspection mode, messages are sent to the Bizppurio inspection domain. Turn it off to send through the production domain.", - "account_notice": "We recommend using separate Bizppurio accounts for inspection and production." + "label": "Test Mode", + "hint": "In test mode, messages are sent to the Bizppurio test domain. Turn it off to send via the production domain.", + "account_notice": "We recommend using separate Bizppurio accounts for testing and production." }, - "live_mode_warning_title": "Sending in production", - "live_mode_warning_body": "Inspection mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep inspection mode on during inspection.", + "live_mode_warning_title": "Sending to production", + "live_mode_warning_body": "Test mode is off — real customers will receive messages and delivery costs will be billed. Keep test mode on during inspection.", "sections": { "api": { "title": "API Integration", "description": "Credentials shared by the sending system and the Kakao management system." }, "sending": { - "title": "Sending Options", + "title": "Sending Settings", "description": "Sender information used for SMS and alimtalk delivery." } }, @@ -32,20 +32,20 @@ }, "password": { "label": "Bizppurio Module Password", - "hint": "Please enter the Bizppurio module password. (Bizppurio console > Module Integration Settings > Change Module Password)" + "hint": "Enter the Bizppurio module password. (Bizppurio console > Module integration settings > Change module password)" }, "connection_check": { "label": "Connection Check", - "hint": "Click to instantly verify the saved ID and password. (Only reflects saved values)", + "hint": "Verify that the saved ID and password are valid. (Applies after saving)", "button": "Check Connection", "checking": "Checking...", - "success": "Authentication verified successfully. The ID and password are correct.", - "failed": "Failed to verify authentication.", + "success": "Authentication verified. The ID and password are correct.", + "failed": "Authentication check failed.", "unsaved_changes": "Please save your changes first." }, "api_key": { "label": "API Key", - "hint": "The API key is issued after you submit it together with your ID to Bizppurio customer support." + "hint": "Request the API key from Bizppurio customer support along with your account ID." }, "sender_number": { "label": "Sender Number", @@ -53,175 +53,233 @@ }, "sender_key": { "label": "Alimtalk Sender Profile Key", - "hint": "The 40-character sender profile key used for alimtalk delivery and template lookup." - }, - "template_cache_minutes": { - "label": "Alimtalk content cache (minutes)", - "hint": "Reuses Kakao template content for this period. 0 = always latest (not recommended for high volume).", - "clear_cache": "Clear cache", - "clear_cache_hint": "If you edited a template in Kakao, click to apply it immediately.", - "clear_cache_success": "Template content cache cleared. The latest content will apply from the next dispatch.", - "clear_cache_failed": "Failed to clear the cache." + "hint": "The 40-character sender profile key used for alimtalk delivery and template requests." } }, "report": { - "section_title": "Report Endpoint", - "hint": "Bizppurio can push each SMS/alimtalk delivery result (success/failure) to this address (URL PUSH). Register it with the Bizppurio business team (or the report settings in the console).", - "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.", + "section_title": "Report Receiving", + "hint": "Bizppurio pushes delivery results (URL PUSH) to this address after sending. Register the address below with the Bizppurio business team (or the report settings in the console).", + "note": "Once registered, delivery results are recorded automatically. Before registration, only the send request is confirmed and final results are not updated.", "copy": "Copy", - "copied": "Report endpoint copied to clipboard." - }, - "cache": { - "section_title": "Alimtalk dispatch content cache", - "section_description": "When sending alimtalk, the template content (body and buttons) registered in Kakao must be sent as-is. To avoid requesting the content from Kakao on every dispatch, the fetched content is reused (cached) for a set period. This keeps sending fast and avoids hitting Kakao's lookup rate limit even under high volume." + "copied": "Report receiving address copied to clipboard." }, "tabs": { "connection": "Settings", - "templates": "Alimtalk Templates" + "templates": "Notification Templates" }, "preparation": { - "intro": "To send SMS or Kakao Alimtalk messages, first complete the preparations below in the Bizppurio console.", + "intro": "To send SMS and Kakao alimtalk, complete the preparations below in the Bizppurio console first.", "sms_label": "SMS/LMS", - "sms_sender": "Register your sender number.", + "sms_sender": "Register a sender number.", "kakao_label": "Kakao Alimtalk", "kakao_channel": "Create a KakaoTalk business channel and register a sender profile.", - "kakao_template": "Register your Alimtalk templates and get them approved.", - "kakao_apikey": "Request an API key from Bizppurio support.", - "console_link": "Open Bizppurio console" + "kakao_template": "Alimtalk templates are composed and submitted for inspection in the admin notification settings (Bizppurio tab).", + "kakao_apikey": "Request an API key from customer support.", + "console_link": "Open Bizppurio Console" } }, + "channels": { + "bizppurio_tab": "Bizppurio" + }, "binding": { - "section_title": "KakaoTalk Alimtalk Binding", - "list_guide": "Link a KakaoTalk alimtalk template to each notification. Use [Connect] to assign an approved template — the alimtalk message is then sent automatically when that event occurs.", - "section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.", - "modal_title": "Alimtalk Binding · {name}", - "unbound": "Not connected", - "unavailable": "Unavailable — reconnect needed", - "btn_connect": "Connect", - "btn_change": "Change", - "fallback_on": "SMS fallback ON", - "fallback_off": "SMS fallback OFF", - "connected_template": "Connected Template", - "none": "Not connected", - "no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.", - "templates_load_failed": "Failed to load the template list. Check the plugin settings (credentials).", - "fallback_sms": "Fall back to SMS on failure", - "fallback_hint": "If the alimtalk message fails, this notification's body is sent as an SMS instead.", - "variables_hint": "Available variables (auto-substituted on send)", - "saved": "Alimtalk binding saved.", - "save_error": "Failed to save alimtalk binding." + "variables_hint": "Available variables (click to insert at the cursor)" }, "banner": { - "not_ready": "Setup required for delivery is incomplete.", + "not_ready": "Required delivery settings are not complete.", "setup_action": "Set up", - "test_mode": "Inspection mode — messages are not actually sent." + "test_mode": "Test mode — no real messages are sent." }, "dispatch_result": { "column_header": "SMS/Alimtalk Result", - "inspection_label": "Inspection", + "inspection_label": "Test", "low_balance": "Low balance", "fallback": "SMS fallback {status}", - "detail_title": "SMS/Alimtalk delivery result", + "detail_title": "SMS/Alimtalk Delivery Result", "channel_label": "Channel: {channel}", - "sent_content_label": "Actual sent content", - "sent_content_hint": "Alimtalk messages are sent using the actual content of the approved Kakao template, which may differ from the \"Body\" above." + "sent_content_label": "Sent content", + "sent_content_hint": "Alimtalk is sent with the approved template content, which may differ from the body above." }, "editor": { "data_source": { "dispatch_results": "Bizppurio dispatch results" } }, - "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 inspection mode and using templates approved by Kakao." - }, - "list_error": { - "title": "Failed to load the template list" - }, - "list_notice": { - "console_desc": "Register, edit, and review templates in the Bizppurio console.", - "console_link": "Open Bizppurio console" - }, - "list": { - "refresh": "Refresh", - "search": "Search", - "search_placeholder": "Search by name (2-50 chars)", - "filter_all": "All statuses", - "empty": "No alimtalk templates registered.", - "empty_hint": "Templates registered in the Bizppurio console appear here.", - "load_failed": "Failed to load templates. Check the sender profile key and API key.", - "columns": { - "no": "No.", - "name": "Name", - "code": "Code", - "status": "Status", - "requested_at": "Requested", - "processed_at": "Processed", - "actions": "Content" - } - }, + "template": { + "tab_guide": "Compose an alimtalk template per notification and request inspection. Once Kakao approves it, alimtalk is sent automatically. SMS fallback and SMS-only delivery are also configured here.", "status": { - "sendable": "Sendable", - "inspecting": "Inspecting", + "unwritten": "Not composed", + "draft": "Draft", + "requested": "Under inspection", + "approved": "Approved", "rejected": "Rejected", - "uninspected": "Not inspected", "stopped": "Stopped", "blocked": "Blocked", - "dormant": "Dormant", - "unknown": "Unknown" + "dormant": "Dormant" }, - "status_sub": { - "rdy": "(unused)" + "row": { + "alimtalk_label": "Alimtalk", + "requested_on": "Requested {date}", + "synced_on": "Synced {date}", + "alimtalk_enabled": "Enabled", + "btn_view_reason": "View reason", + "btn_compose": "Compose alimtalk template", + "btn_edit": "Edit", + "btn_edit_approved": "Edit (cancel approval)", + "btn_request": "Request inspection", + "btn_cancel_request": "Cancel request", + "btn_release": "Release dormant", + "btn_refresh": "Refresh", + "requested_toast": "Inspection requested.", + "cancel_request_toast": "Inspection request cancelled.", + "released_toast": "Dormant state released.", + "synced_toast": "Kakao inspection status synchronized.", + "fallback_sms": "SMS fallback", + "sms_only": "SMS only", + "sms_body_prefix": "Body:", + "sms_body_missing": "No SMS body", + "btn_edit_sms": "Edit SMS body" }, - "status_guide": { - "title": "Status badges", - "sendable_label": "Sendable", - "sendable": "Approved. Only this status can be linked to notifications and sent.", - "inspecting_label": "Inspecting", - "inspecting": "Kakao review in progress (2-3 business days).", - "pending_label": "Uninspected / Rejected", - "pending": "Cannot be sent yet. Request review or edit in the console." + "form": { + "modal_title": "Alimtalk Template — {name}", + "rejected_banner": "This template was rejected. Review the reasons below, edit, and request inspection again.", + "sender_profile": "Sender profile", + "sender_profile_placeholder": "Select a sender profile (reference)", + "sender_profile_hint": "Inspection requests use the sender profile key saved in the plugin settings.", + "category": "Category", + "category_placeholder": "Select a category", + "name": "Template name", + "message_type": "Message type", + "templateMessageType_options": { + "BA": "Basic", + "EX": "Extra info", + "AD": "Channel add", + "MI": "Mixed" + }, + "emphasize_type": "Emphasis type", + "templateEmphasizeType_options": { + "NONE": "None", + "TEXT": "Text", + "IMAGE": "Image", + "ITEM_LIST": "Item list" + }, + "emphasize_title": "Emphasis title", + "emphasize_subtitle": "Emphasis subtitle", + "image": "Image", + "image_hint": "jpg/png · up to 500KB · width ≥500px · 2:1 ratio (the URL is filled automatically after upload)", + "image_uploading": "Uploading image...", + "image_upload_failed": "Failed to upload the image.", + "content": "Body", + "extra": "Extra info", + "header": "Header", + "header_hint": "Optional header shown above the body (up to 16 characters)", + "buttons": "Buttons (up to 5)", + "button_add": "Add button", + "button_name": "Button name (up to 14 chars)", + "quick_replies": "Quick replies (up to 10)", + "quick_reply_add": "Add quick reply", + "link_mo": "Mobile link (required)", + "link_pc": "PC link (optional)", + "link_and": "Android scheme (required)", + "link_ios": "iOS scheme (required)", + "tel_number": "Phone number (required)", + "plugin_id": "Plugin ID (required)", + "item_highlight": "Item highlight (optional)", + "hl_title": "Highlight title", + "hl_description": "Highlight description", + "hl_image_url": "Thumbnail image URL (optional)", + "item_list": "Items (2–10)", + "item_list_hint": "At least 2 and up to 10 items. (title ≤6 chars, description ≤23 chars)", + "item_add": "Add item", + "item_title": "Title", + "item_description": "Description", + "item_summary": "Summary (price info)", + "summary_description": "Summary value (≤14 chars)", + "btn_remove_item": "Remove item", + "btn_remove_button": "Remove button", + "btn_remove_quick_reply": "Remove quick reply", + "btn_save": "Save", + "btn_save_request": "Save & request inspection", + "saved_toast": "Notification template saved.", + "requested_toast": "Inspection requested. Alimtalk delivery starts once approved." + }, + "sms": { + "modal_title": "SMS Body — {name}", + "hint": "This body is shared by SMS fallback and SMS-only delivery. #{variables} are substituted at send time.", + "btn_save": "Save", + "saved_toast": "SMS body saved." + }, + "cancel_approval": { + "modal_title": "Cancel Approval", + "warning": "Cancelling approval of '{name}' immediately stops alimtalk delivery for this notification. (SMS fallback and SMS-only delivery continue per the SMS settings.) Alimtalk resumes only after you edit, request inspection again, and get approved.", + "btn_confirm": "Cancel approval & edit", + "done_toast": "Approval cancelled. Edit and request inspection again." + }, + "rejection": { + "modal_title": "Rejection Reasons — {name}", + "btn_close": "Close" + } + }, + "manage": { + "guide": "Manage the Bizppurio template status of every notification at a glance. Composing and requesting inspection work the same as in the notification settings screen.", + "filter_all": "All statuses", + "search_placeholder": "Search by notification name/type", + "btn_search": "Search", + "total_count": "Total {count}", + "btn_compose": "Compose", + "btn_edit": "Edit", + "btn_delete": "Delete", + "empty": "No Bizppurio notification templates yet.", + "empty_hint": "Templates appear here once you compose an alimtalk template or configure SMS in the admin notification settings (Bizppurio tab).", + "sms_only": "Only", + "sms_fallback": "Fallback", + "owner": { + "core": "Core", + "module": "Module", + "plugin": "Plugin" + }, + "columns": { + "notification": "Notification", + "owner": "Owner", + "status": "Alimtalk Status", + "sms": "SMS", + "requested_at": "Requested", + "synced_at": "Synced", + "actions": "Actions" + }, + "delete": { + "modal_title": "Delete Template", + "warning": "Delete the Bizppurio template settings of '{name}'. The Kakao-side template is deleted together only when it is in a deletable state (registered/rejected).", + "btn_confirm": "Delete", + "done_with_kakao": "Template deleted. (including the Kakao-side template)", + "done_db_only": "Template settings deleted. The Kakao-side template remains due to its state." + } + }, + "templates": { + "readiness": { + "title": "The settings below are required to use alimtalk templates", + "go_settings": "Go to settings", + "missing_label": "Missing items", + "api_key_missing": "Kakao management API key", + "sender_key_missing": "Alimtalk sender profile key", + "note": "Enter the items above in the Settings tab to compose templates and request inspection. Real delivery requires turning off test mode and an approved template." + }, + "list": { + "refresh": "Refresh" }, "link_type": { "WL": "Web link", "AL": "App link", "DS": "Delivery tracking", "BK": "Bot keyword", - "MD": "Message delivery", + "MD": "Message forward", "AC": "Add channel", - "BC": "Consultation talk", + "BC": "Consult talk", "BT": "Bot transfer", "TN": "Call", "MP": "Map", - "P1": "Secure image send", - "P2": "Privacy consent", + "P1": "Secure image", + "P2": "Personal info", "P3": "One-click pay" - }, - "actions": { - "detail": "Detail" - }, - "detail": { - "title": "Template detail", - "close": "Close", - "buttons": "Buttons", - "extra": "Additional info", - "category": "Category", - "code": "Template code", - "content": "Template content", - "emphasize_type": "Template type", - "image_upload": "Attach image", - "subtitle_field": "Highlight subtitle", - "title_field": "Highlight title", - "type_image": "Image", - "type_none": "Basic", - "type_text": "Highlighted" } } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 9240029c..05787c4e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -53,15 +53,7 @@ }, "sender_key": { "label": "알림톡 발신프로필 키", - "hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다." - }, - "template_cache_minutes": { - "label": "알림톡 내용 캐시 시간(분)", - "hint": "카카오 템플릿 내용을 이 시간만큼 재사용합니다. 0 = 매번 최신(발송 많으면 비권장).", - "clear_cache": "캐시 초기화", - "clear_cache_hint": "카카오에서 템플릿을 수정했다면 눌러서 즉시 반영하세요.", - "clear_cache_success": "템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.", - "clear_cache_failed": "캐시 초기화에 실패했습니다." + "hint": "알림톡 발송·템플릿 신청에 사용하는 발신프로필 키(40자)입니다." } }, "report": { @@ -71,13 +63,9 @@ "copy": "복사", "copied": "리포트 수신 주소가 클립보드에 복사되었습니다." }, - "cache": { - "section_title": "알림톡 발송 내용 캐시", - "section_description": "알림톡은 발송할 때 카카오에 등록된 템플릿 내용(본문·버튼)을 그대로 보내야 합니다. 발송할 때마다 카카오에 내용을 요청하지 않도록, 한 번 가져온 내용을 일정 시간 재사용(캐시)합니다. 덕분에 발송이 많아도 카카오 조회 제한에 걸리지 않고 빠르게 발송됩니다." - }, "tabs": { "connection": "환경설정", - "templates": "알림톡 템플릿" + "templates": "알림 템플릿 관리" }, "preparation": { "intro": "문자·카카오 알림톡을 발송하려면 먼저 비즈뿌리오 콘솔에서 아래 사전 준비를 완료해야 합니다.", @@ -85,31 +73,16 @@ "sms_sender": "발신번호를 등록하세요.", "kakao_label": "카카오 알림톡", "kakao_channel": "카카오톡 비즈니스 채널을 만들고 발신프로필을 등록하세요.", - "kakao_template": "알림톡 템플릿을 등록하고 승인받으세요.", + "kakao_template": "알림톡 템플릿은 관리자 알림 설정(비즈뿌리오 탭)에서 작성해 검수를 신청합니다.", "kakao_apikey": "고객센터에 API 키를 요청하세요.", "console_link": "비즈뿌리오 콘솔 열기" } }, + "channels": { + "bizppurio_tab": "비즈뿌리오" + }, "binding": { - "section_title": "카카오 알림톡 연동", - "list_guide": "각 알림에 발송할 카카오 알림톡 템플릿을 연결하세요. [연결]로 승인된 템플릿을 지정하면 해당 이벤트 발생 시 알림톡이 자동 발송됩니다.", - "section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.", - "modal_title": "알림톡 연결 · {name}", - "unbound": "미연결", - "unavailable": "사용 불가 — 재연결 필요", - "btn_connect": "연결", - "btn_change": "연결 변경", - "fallback_on": "SMS 대체 ON", - "fallback_off": "SMS 대체 OFF", - "connected_template": "연결 템플릿", - "none": "연결 안 함", - "no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.", - "templates_load_failed": "템플릿 목록을 불러오지 못했습니다. 플러그인 설정(자격증명)을 확인하세요.", - "fallback_sms": "실패 시 SMS로 대체발송", - "fallback_hint": "알림톡 발송이 실패하면 이 알림의 본문 내용이 문자(SMS)로 대신 발송됩니다.", - "variables_hint": "제공 변수 (발송 시 자동 치환)", - "saved": "알림톡 연동을 저장했습니다.", - "save_error": "알림톡 연동 저장에 실패했습니다." + "variables_hint": "제공 변수 (클릭하면 본문 커서 위치에 삽입됩니다)" }, "banner": { "not_ready": "발송에 필요한 설정이 완료되지 않았습니다.", @@ -124,70 +97,174 @@ "detail_title": "문자·알림톡 발송 결과", "channel_label": "발송 채널: {channel}", "sent_content_label": "실제 발송 내용", - "sent_content_hint": "알림톡은 카카오 승인 템플릿의 실제 내용으로 발송되며, 위 \"본문\"과 다를 수 있습니다." + "sent_content_hint": "알림톡은 승인 확정된 템플릿 내용으로 발송되며, 위 \"본문\"과 다를 수 있습니다." }, "editor": { "data_source": { "dispatch_results": "비즈뿌리오 발송 결과" } }, + "template": { + "tab_guide": "알림별로 알림톡 템플릿을 작성해 검수를 신청하세요. 카카오 승인 후 알림톡이 자동 발송되며, 대체 SMS·SMS 단독 발송도 이 화면에서 설정합니다.", + "status": { + "unwritten": "미작성", + "draft": "작성중", + "requested": "검수중", + "approved": "승인됨", + "rejected": "반려", + "stopped": "중지", + "blocked": "차단", + "dormant": "휴면" + }, + "row": { + "alimtalk_label": "알림톡", + "requested_on": "{date} 신청", + "synced_on": "{date} 동기화", + "alimtalk_enabled": "발송 사용", + "btn_view_reason": "사유 보기", + "btn_compose": "알림톡 템플릿 작성", + "btn_edit": "수정", + "btn_edit_approved": "수정 (승인 취소)", + "btn_request": "검수 신청", + "btn_cancel_request": "신청 취소", + "btn_release": "휴면 해제", + "btn_refresh": "새로고침", + "requested_toast": "검수를 신청했습니다.", + "cancel_request_toast": "검수 신청을 취소했습니다.", + "released_toast": "휴면 상태를 해제했습니다.", + "synced_toast": "카카오 검수 상태를 동기화했습니다.", + "fallback_sms": "대체 SMS", + "sms_only": "SMS 단독", + "sms_body_prefix": "본문:", + "sms_body_missing": "SMS 본문 미설정", + "btn_edit_sms": "SMS 본문 편집" + }, + "form": { + "modal_title": "알림톡 템플릿 — {name}", + "rejected_banner": "반려된 템플릿입니다. 아래 사유를 확인하고 수정 후 다시 신청하세요.", + "sender_profile": "발신프로필", + "sender_profile_placeholder": "발신프로필 선택(참고용)", + "sender_profile_hint": "검수 신청은 환경설정에 저장된 발신프로필 키로 진행됩니다.", + "category": "카테고리", + "category_placeholder": "카테고리 선택", + "name": "템플릿명", + "message_type": "메시지 유형", + "templateMessageType_options": { + "BA": "기본형", + "EX": "부가정보형", + "AD": "채널추가형", + "MI": "복합형" + }, + "emphasize_type": "강조 유형", + "templateEmphasizeType_options": { + "NONE": "없음", + "TEXT": "강조표기", + "IMAGE": "이미지", + "ITEM_LIST": "아이템리스트" + }, + "emphasize_title": "강조표기 타이틀", + "emphasize_subtitle": "강조표기 서브타이틀", + "image": "이미지", + "image_hint": "jpg/png · 500KB 이하 · 가로 500px 이상 · 가로:세로 2:1 (업로드하면 URL 이 자동 기입됩니다)", + "image_uploading": "이미지 업로드 중...", + "image_upload_failed": "이미지 업로드에 실패했습니다.", + "content": "본문", + "extra": "부가정보", + "header": "헤더", + "header_hint": "본문 상단에 표기되는 최대 16자 문구(선택)", + "buttons": "버튼 (최대 5개)", + "button_add": "버튼 추가", + "button_name": "버튼명 (최대 14자)", + "quick_replies": "바로연결 (최대 10개)", + "quick_reply_add": "바로연결 추가", + "link_mo": "모바일 링크 (필수)", + "link_pc": "PC 링크 (선택)", + "link_and": "Android 스킴 (필수)", + "link_ios": "iOS 스킴 (필수)", + "tel_number": "전화번호 (필수)", + "plugin_id": "플러그인 ID (필수)", + "item_highlight": "아이템 하이라이트 (선택)", + "hl_title": "하이라이트 타이틀", + "hl_description": "하이라이트 설명", + "hl_image_url": "썸네일 이미지 URL (선택)", + "item_list": "아이템 목록 (2~10개)", + "item_list_hint": "아이템은 최소 2개, 최대 10개입니다. (제목 6자·설명 23자 이내)", + "item_add": "아이템 추가", + "item_title": "제목", + "item_description": "설명", + "item_summary": "요약(가격 정보)", + "summary_description": "요약 값 (14자 이내)", + "btn_remove_item": "아이템 삭제", + "btn_remove_button": "버튼 삭제", + "btn_remove_quick_reply": "바로연결 삭제", + "btn_save": "저장", + "btn_save_request": "저장 후 검수 신청", + "saved_toast": "알림 템플릿을 저장했습니다.", + "requested_toast": "검수를 신청했습니다. 승인되면 알림톡 발송이 시작됩니다." + }, + "sms": { + "modal_title": "SMS 본문 — {name}", + "hint": "대체 SMS 와 SMS 단독 발송에 공통으로 사용하는 본문입니다. #{변수} 는 발송 시 자동 치환됩니다.", + "btn_save": "저장", + "saved_toast": "SMS 본문을 저장했습니다." + }, + "cancel_approval": { + "modal_title": "승인 취소 확인", + "warning": "'{name}' 템플릿의 승인을 취소하면 즉시 이 알림의 알림톡 발송이 중단됩니다. (대체 SMS·SMS 단독 발송은 SMS 설정에 따라 계속됩니다.) 수정 후 다시 검수 신청해 승인을 받아야 알림톡 발송이 재개됩니다.", + "btn_confirm": "승인 취소 후 수정", + "done_toast": "승인을 취소했습니다. 수정 후 다시 검수 신청하세요." + }, + "rejection": { + "modal_title": "반려 사유 — {name}", + "btn_close": "닫기" + } + }, + "manage": { + "guide": "모든 알림의 비즈뿌리오 템플릿 상태를 한눈에 관리합니다. 작성·검수 신청은 알림 설정 화면과 동일하게 동작합니다.", + "filter_all": "전체 상태", + "search_placeholder": "알림 이름/유형 검색", + "btn_search": "검색", + "total_count": "총 {count}건", + "btn_compose": "작성", + "btn_edit": "수정", + "btn_delete": "삭제", + "empty": "등록된 비즈뿌리오 알림 템플릿이 없습니다.", + "empty_hint": "관리자 알림 설정의 비즈뿌리오 탭에서 알림톡 템플릿을 작성하거나 SMS 를 설정하면 여기에 표시됩니다.", + "sms_only": "단독", + "sms_fallback": "대체", + "owner": { + "core": "코어", + "module": "모듈", + "plugin": "플러그인" + }, + "columns": { + "notification": "알림", + "owner": "소속", + "status": "알림톡 상태", + "sms": "SMS", + "requested_at": "신청일", + "synced_at": "동기화", + "actions": "관리" + }, + "delete": { + "modal_title": "템플릿 삭제", + "warning": "'{name}' 의 비즈뿌리오 템플릿 설정을 삭제합니다. 카카오에 등록된 템플릿은 삭제 가능한 상태(등록/반려)일 때만 함께 삭제됩니다.", + "btn_confirm": "삭제", + "done_with_kakao": "템플릿을 삭제했습니다. (카카오측 템플릿 포함)", + "done_db_only": "템플릿 설정을 삭제했습니다. 카카오측 템플릿은 상태 제약으로 남아 있습니다." + } + }, "templates": { - "title": "알림톡 템플릿", - "description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.", "readiness": { "title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다", "go_settings": "환경설정으로 이동", "missing_label": "미설정 항목", "api_key_missing": "카카오 관리 API 키", "sender_key_missing": "알림톡 발신프로필 키", - "note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다." - }, - "list_error": { - "title": "템플릿 목록을 불러오지 못했습니다" - }, - "list_notice": { - "console_desc": "템플릿 등록·수정·검수는 비즈뿌리오 콘솔에서 진행해주세요.", - "console_link": "비즈뿌리오 콘솔 열기" + "note": "위 항목을 환경설정 탭에서 입력하면 템플릿 작성·검수 신청이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 승인된 템플릿만 사용할 수 있습니다." }, "list": { - "refresh": "새로고침", - "search": "검색", - "search_placeholder": "템플릿명 검색 (2~50자)", - "filter_all": "전체 상태", - "empty": "등록된 알림톡 템플릿이 없습니다.", - "empty_hint": "비즈뿌리오 콘솔에서 등록한 템플릿이 여기에 표시됩니다.", - "load_failed": "템플릿 목록을 불러오지 못했습니다. 발신프로필 키와 API 키를 확인하세요.", - "columns": { - "no": "번호", - "name": "템플릿명", - "code": "코드", - "status": "상태", - "requested_at": "등록요청일", - "processed_at": "처리일", - "actions": "내용" - } - }, - "status": { - "sendable": "발송가능", - "inspecting": "검수중", - "rejected": "반려", - "uninspected": "미검수", - "stopped": "중지", - "blocked": "차단", - "dormant": "휴면", - "unknown": "알 수 없음" - }, - "status_sub": { - "rdy": "(사용전)" - }, - "status_guide": { - "title": "상태 배지", - "sendable_label": "발송가능", - "sendable": "승인 완료. 이 상태만 알림에 연결·발송할 수 있습니다.", - "inspecting_label": "검수중", - "inspecting": "카카오 검수 진행 중 (영업일 2~3일).", - "pending_label": "미검수·반려", - "pending": "아직 발송할 수 없습니다. 콘솔에서 검수요청·수정하세요." + "refresh": "새로고침" }, "link_type": { "WL": "웹링크", @@ -203,25 +280,6 @@ "P1": "이미지 보안전송", "P2": "개인정보이용", "P3": "원클릭 결제" - }, - "actions": { - "detail": "상세" - }, - "detail": { - "title": "템플릿 상세", - "close": "닫기", - "buttons": "버튼", - "extra": "부가정보", - "category": "카테고리", - "code": "템플릿 코드", - "content": "템플릿 내용", - "emphasize_type": "템플릿 유형", - "image_upload": "이미지 첨부", - "subtitle_field": "보조 문구", - "title_field": "강조표기 문구", - "type_image": "이미지형", - "type_none": "기본형", - "type_text": "강조표기형" } } } 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 7753594a..a5151018 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 @@ -1,1863 +1,5105 @@ { - "version": "1.0.0", - "layout_name": "plugin_settings", - "permissions": [ - "core.plugins.update" - ], - "extends": "_admin_base", - "meta": { - "title": "$t:sirsoft-message_bizppurio.settings.title", - "description": "$t:sirsoft-message_bizppurio.settings.description", - "_e2e_allow": "e2e:allow (§529) 연결 확인 필드·hasChanges 가드 추가는 레이아웃 회귀테스트(plugin_settings.test.tsx) + PHPUnit Feature(TokenCheckEndpointTest·InvalidateTokenOnSettingsSaveListenerTest)로 커버. 실연동 Playwright는 계획서 §9 방침에 따라 PO 직접 수행(인증·발송 환경 필요) 단계에서 도입 예정 — tests/scenarios/plugin-settings.yaml 상단 audit:allow 사유 참고." + "version": "1.0.0", + "layout_name": "plugin_settings", + "permissions": [ + "core.plugins.update" + ], + "extends": "_admin_base", + "meta": { + "title": "$t:sirsoft-message_bizppurio.settings.title", + "description": "$t:sirsoft-message_bizppurio.settings.description", + "_e2e_allow": "e2e:allow (§529) 연결 확인 필드·hasChanges 가드 추가는 레이아웃 회귀테스트(plugin_settings.test.tsx) + PHPUnit Feature(TokenCheckEndpointTest·InvalidateTokenOnSettingsSaveListenerTest)로 커버. 실연동 Playwright는 계획서 §9 방침에 따라 PO 직접 수행(인증·발송 환경 필요) 단계에서 도입 예정 — tests/scenarios/plugin-settings.yaml 상단 audit:allow 사유 참고." + }, + "init_actions": [ + { + "if": "{{(query.tab ?? 'connection') === 'templates'}}", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + } + ], + "data_sources": [ + { + "id": "settings", + "type": "api", + "endpoint": "/api/admin/plugins/{{route.identifier}}/settings", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "initLocal": "form", + "refetchOnMount": true }, - "init_actions": [ - { - "if": "{{(query.tab ?? 'connection') === 'templates'}}", - "handler": "refetchDataSource", - "params": { "dataSourceId": "alimtalk_templates" } + { + "id": "report_url", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/report-url", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "initLocal": "reportInfo", + "if": "{{(query.tab ?? 'connection') === 'connection'}}" + }, + { + "id": "bizppurio_templates_list", + "comment": "비즈뿌리오 알림 템플릿 DB 목록(#597 §4.3). 필터·검색·페이지는 URL 쿼리 SSoT(bz_status/bz_search/bz_page).", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "params": { + "status": "{{query.bz_status ?? ''}}", + "search": "{{query.bz_search ?? ''}}", + "page": "{{query.bz_page ?? 1}}", + "per_page": 20 + } + }, + { + "id": "templates_readiness", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates-readiness", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "if": "{{(query.tab ?? 'connection') === 'templates'}}" + }, + { + "id": "bizppurioCategories", + "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 실패는 suppress + fallback.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" } - ], - "data_sources": [ - { - "id": "settings", - "type": "api", - "endpoint": "/api/admin/plugins/{{route.identifier}}/settings", - "method": "GET", - "auto_fetch": true, - "auth_required": true, - "initLocal": "form", - "refetchOnMount": true + }, + "fallback": { + "data": { + "categories": [], + "load_failed": true + } + } + }, + { + "id": "bizppurioProfiles", + "comment": "작성 모달 발신프로필 참고 셀렉트(kapi profile/use 위임). 모달 열 때만 조회. 실패는 suppress + fallback.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" + } + }, + "fallback": { + "data": { + "profiles": [], + "load_failed": true + } + } + } + ], + "slots": { + "content": [ + { + "id": "plugin_settings_page", + "type": "basic", + "name": "Div", + "props": { + "className": "admin-page-content-responsive" }, - { - "id": "report_url", - "type": "api", - "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/report-url", - "method": "GET", - "auto_fetch": true, - "auth_required": true, - "initLocal": "reportInfo", - "if": "{{(query.tab ?? 'connection') === 'connection'}}" - }, - { - "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 ?? ''}}", - "page": "{{_local.templatePage ?? 1}}" - }, - "onSuccess": { - "handler": "setState", - "params": { "target": "local", "templateListError": null } - }, - "onError": { - "handler": "setState", - "params": { "target": "local", "templateListError": "{{error.errors?.kakao_message ?? error.message}}" } + "children": [ + { + "id": "page_header", + "type": "composite", + "name": "PageHeader", + "props": { + "title": "$t:sirsoft-message_bizppurio.settings.title", + "description": "$t:sirsoft-message_bizppurio.settings.description", + "backPath": "/admin/plugins", + "backLabel": "$t:admin.plugins.back_to_list" } - }, - { - "id": "templates_readiness", - "type": "api", - "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates-readiness", - "method": "GET", - "auto_fetch": true, - "auth_required": true, - "if": "{{(query.tab ?? 'connection') === 'templates'}}" - } - ], - "slots": { - "content": [ - { - "id": "plugin_settings_page", + }, + { + "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": "navigate", + "params": { + "path": "/admin/plugins/{{route.identifier}}/settings", + "query": { + "tab": "connection" + }, + "mergeQuery": true, + "replace": 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": "navigate", + "params": { + "path": "/admin/plugins/{{route.identifier}}/settings", + "query": { + "tab": "templates" + }, + "mergeQuery": true, + "replace": true + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + } + ] + } + ], + "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": "preparation_notice", + "comment": "사용 전 준비 안내(통합 1박스) — 알림톡 템플릿 탭 list_notice 와 동일 구조. 위: 총괄 안내 1줄 + 콘솔 열기 링크. 구분선(border-t). 아래: 문자·카카오 채널별 사전 준비 목록(라벨 굵게 + 들여쓴 하위 불릿).", "type": "basic", "name": "Div", "props": { - "className": "admin-page-content-responsive" + "className": "mt-6 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4" }, "children": [ - { - "id": "page_header", - "type": "composite", - "name": "PageHeader", - "props": { - "title": "$t:sirsoft-message_bizppurio.settings.title", - "description": "$t:sirsoft-message_bizppurio.settings.description", - "backPath": "/admin/plugins", - "backLabel": "$t:admin.plugins.back_to_list" - } - }, - { - "id": "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": "navigate", - "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "connection" }, "mergeQuery": true, "replace": 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": "navigate", - "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "templates" }, "mergeQuery": true, "replace": true } - }, - { - "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": "preparation_notice", - "comment": "사용 전 준비 안내(통합 1박스) — 알림톡 템플릿 탭 list_notice 와 동일 구조. 위: 총괄 안내 1줄 + 콘솔 열기 링크. 구분선(border-t). 아래: 문자·카카오 채널별 사전 준비 목록(라벨 굵게 + 들여쓴 하위 불릿).", - "type": "basic", - "name": "Div", - "props": { "className": "mt-6 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4" }, - "children": [ - { - "comment": "위 — 총괄 안내 1줄 + 콘솔 열기 링크", - "type": "basic", "name": "Div", - "props": { "className": "space-y-1" }, - "children": [ - { - "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, - "children": [ - { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.intro" } - ] - }, - { - "type": "basic", "name": "A", - "props": { "href": "https://www.bizppurio.com", "target": "_blank", "rel": "noopener noreferrer", "className": "inline-flex items-center gap-1.5 ml-4 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" }, - "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.console_link" }, - { "type": "basic", "name": "Icon", "props": { "name": "up-right-from-square", "size": "xs" } } - ] - } - ] - }, - { - "comment": "구분선 아래 — 문자·카카오 채널별 사전 준비 목록 (PC lg 이상 2단, 모바일 1단)", - "type": "basic", "name": "Div", - "props": { "className": "mt-3 pt-3 border-t border-blue-100 dark:border-blue-900 grid grid-cols-1 lg:grid-cols-2 gap-4" }, - "children": [ - { - "comment": "문자(SMS/LMS) — 카드 + 아이콘 라벨 + 발신번호 등록 1줄", - "type": "basic", "name": "Div", - "props": { "className": "space-y-2 rounded-lg bg-blue-100 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 p-3" }, - "children": [ - { "type": "basic", "name": "P", "props": { "className": "flex items-center gap-2 text-sm font-semibold text-blue-900 dark:text-blue-100" }, "children": [ { "type": "basic", "name": "Icon", "props": { "name": "envelope", "size": "sm", "className": "text-blue-500 dark:text-blue-400" } }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_label" } ] }, - { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_sender" } ] } - ] - }, - { - "comment": "카카오 알림톡 — 카드 + 아이콘 라벨 + 채널/템플릿/API키 3줄", - "type": "basic", "name": "Div", - "props": { "className": "space-y-2 rounded-lg bg-blue-100 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 p-3" }, - "children": [ - { "type": "basic", "name": "P", "props": { "className": "flex items-center gap-2 text-sm font-semibold text-blue-900 dark:text-blue-100" }, "children": [ { "type": "basic", "name": "Icon", "props": { "name": "comment-dots", "size": "sm", "className": "text-blue-500 dark:text-blue-400" } }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_label" } ] }, - { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_channel" } ] }, - { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_template" } ] }, - { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_apikey" } ] } - ] - } - ] - } - ] - }, - { - "id": "validation_error", - "type": "basic", - "name": "Div", - "if": "{{_local.errors}}", - "props": { - "id": "bizppurio_settings_validation_error", - "role": "alert", - "aria-live": "assertive", - "tabIndex": -1, - "className": "mt-6 alert-danger scroll-mt-32" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "flex gap-3" }, - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "triangle-exclamation", - "size": "md", - "className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" - } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "H3", - "props": { "className": "text-error-soft" }, - "text": "$t:admin.plugins.settings.validation_error" - }, - { - "type": "basic", - "name": "Ul", - "if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}", - "props": { "className": "mt-2 list-disc list-inside space-y-1" }, - "iteration": { - "source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])", - "item_var": "message" - }, - "children": [ - { - "type": "basic", - "name": "Li", - "props": { "className": "text-error-soft" }, - "text": "{{message}}" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "test_mode_card", - "type": "basic", - "name": "Div", - "props": { - "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "p-6" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "flex-between" }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "form-label" }, - "text": "$t:sirsoft-message_bizppurio.settings.test_mode.label" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.test_mode.hint" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.test_mode.account_notice" - } - ] - }, - { - "type": "composite", - "name": "Toggle", - "props": { - "name": "is_test_mode" - } - } - ] - } - ] - } - ] - }, - { - "id": "live_mode_warning", - "type": "basic", - "name": "Div", - "if": "{{_local.form && !_local.form.is_test_mode}}", - "props": { - "className": "mt-6 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "flex gap-3" }, - "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": "H3", - "props": { "className": "text-sm font-semibold text-warning-soft" }, - "text": "$t:sirsoft-message_bizppurio.settings.live_mode_warning_title" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "mt-1 text-warning-soft" }, - "text": "$t:sirsoft-message_bizppurio.settings.live_mode_warning_body" - } - ] - } - ] - } - ] - }, - { - "id": "section_api", - "type": "basic", - "name": "Div", - "props": { - "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "panel-header-row" }, - "children": [ - { - "type": "basic", - "name": "H3", - "props": { "className": "section-heading-md" }, - "text": "$t:sirsoft-message_bizppurio.settings.sections.api.title" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint mt-1" }, - "text": "$t:sirsoft-message_bizppurio.settings.sections.api.description" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "p-6 space-y-6" }, - "children": [ - { - "id": "field_bizppurio_id", - "type": "basic", - "name": "Div", - "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-4" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "form-label" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.label" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.hint" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-8" }, - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "type": "text", - "name": "bizppurio_id", - "autoComplete": "off", - "className": "{{_local.errors?.bizppurio_id ? 'input border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input'}}" - } - }, - { - "id": "field_bizppurio_id_error", - "type": "basic", - "name": "P", - "if": "{{_local.errors?.bizppurio_id}}", - "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, - "text": "{{_local.errors?.bizppurio_id?.[0] ?? ''}}" - } - ] - } - ] - }, - { - "id": "field_password", - "type": "basic", - "name": "Div", - "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-4" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "form-label" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.password.label" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.password.hint" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-8" }, - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "type": "password", - "name": "password", - "autoComplete": "new-password", - "placeholder": "••••••••", - "className": "{{_local.errors?.password ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" - } - }, - { - "id": "field_password_error", - "type": "basic", - "name": "P", - "if": "{{_local.errors?.password}}", - "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, - "text": "{{_local.errors?.password?.[0] ?? ''}}" - } - ] - } - ] - }, - { - "id": "field_connection_check", - "type": "basic", - "name": "Div", - "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-4" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "form-label" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.label" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.hint" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-8 flex lg:justify-end" }, - "children": [ - { - "id": "connection_check_button", - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "disabled": "{{_local.tokenChecking}}", - "className": "btn btn-outline btn-sm flex-center gap-1.5" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "actions": [ - { - "if": "{{_local.hasChanges}}", - "handler": "toast", - "params": { - "type": "warning", - "message": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.unsaved_changes" - } - }, - { - "if": "{{!_local.hasChanges}}", - "handler": "setState", - "params": { "target": "local", "tokenChecking": true } - }, - { - "if": "{{!_local.hasChanges}}", - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/token/check", - "params": { "method": "POST" }, - "onSuccess": [ - { - "handler": "setState", - "params": { "target": "local", "tokenChecking": false } - }, - { - "handler": "toast", - "params": { - "type": "success", - "message": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.success" - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { "target": "local", "tokenChecking": false } - }, - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{error.errors?.bizppurio_message ?? error.message ?? '$t:sirsoft-message_bizppurio.settings.fields.connection_check.failed'}}" - } - } - ] - } - ] - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "if": "{{_local.tokenChecking}}", - "props": { - "name": "spinner", - "className": "text-sm animate-spin" - } - }, - { - "type": "basic", - "name": "Icon", - "if": "{{!_local.tokenChecking}}", - "props": { - "name": "plug", - "size": "sm" - } - }, - { - "type": "basic", - "name": "Span", - "if": "{{!_local.tokenChecking}}", - "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.button" - }, - { - "type": "basic", - "name": "Span", - "if": "{{_local.tokenChecking}}", - "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.checking" - } - ] - } - ] - } - ] - }, - { - "id": "field_api_key", - "type": "basic", - "name": "Div", - "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-4" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "form-label" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.label" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.hint" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-8" }, - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "type": "password", - "name": "api_key", - "autoComplete": "new-password", - "placeholder": "••••••••", - "className": "{{_local.errors?.api_key ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" - } - }, - { - "id": "field_api_key_error", - "type": "basic", - "name": "P", - "if": "{{_local.errors?.api_key}}", - "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, - "text": "{{_local.errors?.api_key?.[0] ?? ''}}" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "section_sending", - "type": "basic", - "name": "Div", - "props": { - "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "panel-header-row" }, - "children": [ - { - "type": "basic", - "name": "H3", - "props": { "className": "section-heading-md" }, - "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.title" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint mt-1" }, - "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.description" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "p-6 space-y-6" }, - "children": [ - { - "id": "field_sender_number", - "type": "basic", - "name": "Div", - "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-4" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "form-label" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.label" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.hint" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-8" }, - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "type": "text", - "name": "sender_number", - "autoComplete": "off", - "className": "{{_local.errors?.sender_number ? 'input border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input'}}" - } - }, - { - "id": "field_sender_number_error", - "type": "basic", - "name": "P", - "if": "{{_local.errors?.sender_number}}", - "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, - "text": "{{_local.errors?.sender_number?.[0] ?? ''}}" - } - ] - } - ] - }, - { - "id": "field_sender_key", - "type": "basic", - "name": "Div", - "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-4" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "form-label" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.label" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.hint" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-8" }, - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "type": "password", - "name": "sender_key", - "autoComplete": "new-password", - "placeholder": "••••••••", - "className": "{{_local.errors?.sender_key ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" - } - }, - { - "id": "field_sender_key_error", - "type": "basic", - "name": "P", - "if": "{{_local.errors?.sender_key}}", - "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, - "text": "{{_local.errors?.sender_key?.[0] ?? ''}}" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "report_section", - "type": "basic", - "name": "Div", - "props": { - "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-blue-200 dark:border-blue-800" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { - "className": "px-6 py-4 border-b border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 rounded-t-lg" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "flex-center gap-2" }, - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { - "name": "bell", - "size": "sm", - "className": "text-blue-600 dark:text-blue-400" - } - }, - { - "type": "basic", - "name": "H3", - "props": { "className": "text-base font-semibold text-blue-900 dark:text-blue-100" }, - "text": "$t:sirsoft-message_bizppurio.settings.report.section_title" - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "p-6 space-y-4" }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "props": { "className": "text-label mb-2" }, - "text": "$t:sirsoft-message_bizppurio.settings.report.hint" - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "flex-center gap-2" }, - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "type": "text", - "readOnly": true, - "value": "{{report_url?.data?.url ?? '/api/plugins/sirsoft-message_bizppurio/webhook'}}", - "className": "input-readonly flex-1 font-mono" - } - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "flex-center gap-1.5 px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-body border border-gray-300 dark:border-gray-600 rounded-lg font-medium transition-colors whitespace-nowrap" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "actions": [ - { - "handler": "copyToClipboard", - "params": { - "text": "{{report_url?.data?.url ?? '/api/plugins/sirsoft-message_bizppurio/webhook'}}" - } - }, - { - "handler": "toast", - "params": { - "type": "success", - "message": "$t:sirsoft-message_bizppurio.settings.report.copied" - } - } - ] - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { "name": "copy", "size": "sm" } - }, - { - "type": "basic", - "name": "Span", - "text": "$t:sirsoft-message_bizppurio.settings.report.copy" - } - ] - } - ] - } - ] - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.report.note" - } - ] - } - ] - }, - { - "id": "cache_section", - "comment": "알림톡 발송 내용 캐시 — 독립 카드. 캐시 시간(form 저장) + 캐시 초기화(즉시 API, 로컬 캐시만 비움).", - "type": "basic", - "name": "Div", - "props": { - "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" - }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "panel-header-row" }, - "children": [ - { - "type": "basic", - "name": "H3", - "props": { "className": "section-heading-md" }, - "text": "$t:sirsoft-message_bizppurio.settings.cache.section_title" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint mt-1" }, - "text": "$t:sirsoft-message_bizppurio.settings.cache.section_description" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "p-6 space-y-6" }, - "children": [ - { - "comment": "캐시 시간(분) — 좌: 라벨+설명 / 우: 입력. 다른 설정 필드와 동일한 2단 grid.", - "type": "basic", - "name": "Div", - "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-4" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "form-label" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.label" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.hint" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-8 flex lg:justify-end" }, - "children": [ - { - "type": "basic", - "name": "Input", - "props": { - "type": "number", - "name": "template_cache_minutes", - "min": "0", - "autoComplete": "off", - "className": "input w-32" - } - } - ] - } - ] - }, - { - "comment": "캐시 초기화 — 좌: 안내문 / 우: 버튼. 즉시 로컬 캐시 비우기(kapi 호출 없음).", - "type": "basic", - "name": "Div", - "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-4" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "form-label" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint" }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_hint" - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "lg:col-span-8 flex lg:justify-end" }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "btn btn-secondary btn-sm flex-center gap-1.5" }, - "actions": [ - { - "type": "click", - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear", - "params": { "method": "POST" }, - "onSuccess": [ - { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_success" } } - ], - "onError": [ - { "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_failed" } } - ] - } - ], - "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "eraser", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache" } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "footer_buttons", - "type": "basic", - "name": "Div", - "props": { "className": "sticky-footer-buttons" }, - "children": [ - { - "id": "cancel_button", - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "btn btn-secondary" - }, - "actions": [ - { - "type": "click", - "handler": "navigate", - "params": { "path": "/admin/plugins" } - } - ], - "children": [ - { "type": "basic", "name": "Span", "text": "$t:common.cancel" } - ] - }, - { - "id": "save_button", - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "disabled": "{{!_local.hasChanges || _local.isSaving}}", - "className": "btn btn-primary flex-center gap-1.5" - }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "actions": [ - { - "handler": "setState", - "params": { "target": "local", "isSaving": true, "errors": null } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/admin/plugins/{{route.identifier}}/settings", - "params": { - "method": "PUT", - "body": "{{_local.form}}" - }, - "onSuccess": [ - { - "handler": "setState", - "params": { "target": "local", "isSaving": false, "hasChanges": false } - }, - { - "handler": "toast", - "params": { "type": "success", "message": "$t:admin.plugins.settings.save_success" } - }, - { - "handler": "refetchDataSource", - "params": { "dataSourceId": "settings" } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "local", - "isSaving": false, - "errors": "{{error.errors ?? { _general: error.message }}}" - } - }, - { - "handler": "scrollIntoView", - "params": { - "selector": "#bizppurio_settings_validation_error", - "behavior": "smooth", - "block": "start", - "waitForElement": true, - "timeout": 2000 - } - }, - { - "handler": "toast", - "params": { "type": "error", "message": "$t:admin.plugins.settings.save_error" } - } - ] - } - ] - } - ], - "children": [ - { - "type": "basic", - "name": "Icon", - "if": "{{_local.isSaving}}", - "props": { "name": "spinner", "className": "spinner-sm" } - }, - { - "type": "basic", - "name": "Span", - "if": "{{!_local.isSaving}}", - "text": "$t:common.save" - }, - { - "type": "basic", - "name": "Span", - "if": "{{_local.isSaving}}", - "text": "$t:common.saving" - } - ] - } - ] - } - ] - }, - { - "id": "templates_tab_panel", - "type": "basic", - "name": "Div", - "if": "{{(query.tab ?? 'connection') === 'templates'}}", - "props": { "className": "mt-6" }, - "children": [ - { - "id": "templates_list_error", - "comment": "목록 조회 실패 배너 — 준비 완료(ready)일 때만 노출. 키 미설정(ready=false)이면 아래 readiness 안내 배너가 담당하므로, 같은 원인으로 두 배너가 동시에 뜨지 않게 ready 게이트를 둔다. 즉 '키는 넣었는데 다른 이유로 실패'한 경우에만 카카오가 준 실제 사유를 표시.", - "type": "basic", - "name": "Div", - "if": "{{_local.templateListError && templates_readiness?.data?.ready}}", - "props": { "className": "mb-4 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950 p-4 flex gap-2 items-start" }, - "children": [ - { - "type": "basic", - "name": "Icon", - "props": { "name": "circle-exclamation", "size": "md", "className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" } - }, - { - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "P", - "props": { "className": "text-sm font-medium text-red-800 dark:text-red-200" }, - "text": "$t:sirsoft-message_bizppurio.templates.list_error.title" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "text-xs text-red-700 dark:text-red-300 mt-1 leading-relaxed" }, - "text": "{{_local.templateListError}}" - } - ] - } - ] - }, - { - "id": "templates_readiness", - "type": "basic", - "name": "Div", - "if": "{{templates_readiness?.data && !(templates_readiness?.data?.ready)}}", - "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": "{{!(templates_readiness?.data?.api_key_set)}}", - "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": "{{!(templates_readiness?.data?.sender_key_set)}}", - "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": "navigate", "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "connection" }, "mergeQuery": true, "replace": 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", - "children": [ - { - "id": "templates_list_notice", - "comment": "조회 전용 안내 1박스(파랑). 상단: '상태 배지 읽는 법' 제목 + 배지명(의미색: 발송가능=초록/검수중=amber/미검수·반려=빨강) + 설명. 하단(구분선): 콘솔 위임 1줄 + 콘솔 링크. 제목/본문 sm.", - "type": "basic", - "name": "Div", - "props": { "className": "mb-4 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4" }, - "children": [ - { - "comment": "위 — 콘솔 위임 안내 1줄 + 콘솔 링크.", - "type": "basic", "name": "Div", - "props": { "className": "space-y-1" }, - "children": [ - { - "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, - "children": [ - { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list_notice.console_desc" } - ] - }, - { - "type": "basic", "name": "A", - "props": { "href": "https://www.bizppurio.com", "target": "_blank", "rel": "noopener noreferrer", "className": "inline-flex items-center gap-1.5 ml-4 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" }, - "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list_notice.console_link" }, - { "type": "basic", "name": "Icon", "props": { "name": "up-right-from-square", "size": "xs" } } - ] - } - ] - }, - { - "comment": "구분선 아래 — 상태 배지 의미 3줄. 배지명은 표의 배지와 눈으로 매칭되도록 solid 의미색(다크에서도 뚜렷).", - "type": "basic", "name": "Div", - "props": { "className": "mt-3 pt-3 border-t border-blue-100 dark:border-blue-900 space-y-1.5" }, - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.title" }, - { - "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, - "children": [ - { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, - { "type": "basic", "name": "Span", "children": [ - { "type": "basic", "name": "Span", "props": { "className": "font-semibold text-green-700 dark:text-green-300" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.sendable_label" }, - { "type": "basic", "name": "Span", "text": " — " }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.status_guide.sendable" } - ] } - ] - }, - { - "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, - "children": [ - { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, - { "type": "basic", "name": "Span", "children": [ - { "type": "basic", "name": "Span", "props": { "className": "font-semibold text-amber-700 dark:text-amber-300" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.inspecting_label" }, - { "type": "basic", "name": "Span", "text": " — " }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.status_guide.inspecting" } - ] } - ] - }, - { - "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, - "children": [ - { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, - { "type": "basic", "name": "Span", "children": [ - { "type": "basic", "name": "Span", "props": { "className": "font-semibold text-red-700 dark:text-red-300" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.pending_label" }, - { "type": "basic", "name": "Span", "text": " — " }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.status_guide.pending" } - ] } - ] - } - ] - } - ] - }, - { - "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}}", "templatePage": 1 } }, - { "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}}" } }, - { - "comment": "④ 엔터로 검색 실행 — 입력값을 상태에 반영 후 목록 refetch(검색 파라미터 반영)", - "type": "keypress", - "key": "Enter", - "handler": "sequence", - "actions": [ - { "handler": "setState", "params": { "target": "local", "templateKeyword": "{{$event.target.value}}", "templatePage": 1 } }, - { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } - ] - } - ] - }, - { - "comment": "④ 검색 버튼 — 엔터 대신 클릭으로도 검색 실행", - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "btn btn-secondary btn-sm flex-center gap-1.5" }, - "actions": [ - { "type": "click", "handler": "sequence", "actions": [ - { "handler": "setState", "params": { "target": "local", "templatePage": 1 } }, - { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } - ] } - ], - "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "magnifying-glass", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list.search" } - ] - } - ] - }, - { - "comment": "우측 액션 그룹 — 조회 전용(새로고침만). 캐시 초기화·캐시 시간은 환경설정 탭으로 이관. 템플릿 등록은 비즈뿌리오 콘솔로 위임.", - "type": "basic", - "name": "Div", - "props": { "className": "flex gap-2 items-center" }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "btn btn-secondary btn-sm flex-center gap-1.5" }, - "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" } - ] - } - ] - } - ] - }, - { - "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 whitespace-nowrap w-16" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.no" }, - { "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-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-40" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.requested_at" }, - { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-40" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.processed_at" }, - { "type": "basic", "name": "Th", "props": { "className": "text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-16" }, "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", - "index_var": "tplIndex" - }, - "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" }, - "children": [ - { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 text-gray-500 dark:text-gray-400 tabular-nums" }, "text": "{{((alimtalk_templates?.data?.pagination?.current_page ?? 1) - 1) * (alimtalk_templates?.data?.pagination?.per_page ?? 20) + tplIndex + 1}}" }, - { "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 gap-1 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'}}" - }, - "children": [ - { "type": "basic", "name": "Span", "text": "{{$t(tpl.status_badge?.label_key ?? 'sirsoft-message_bizppurio.templates.status.unknown')}}" }, - { "type": "basic", "name": "Span", "if": "{{tpl.service_status === 'RDY'}}", "props": { "className": "opacity-70" }, "text": "$t:sirsoft-message_bizppurio.templates.status_sub.rdy" } - ] - } - ] - }, - { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap" }, "text": "{{tpl.createdAt ?? '—'}}" }, - { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap" }, "text": "{{tpl.modifiedAt ?? '—'}}" }, - { - "type": "basic", - "name": "Td", - "props": { "className": "px-4 py-3 w-20" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "flex items-center justify-center" }, - "children": [ - { - "comment": "조회 전용 — [내용] 버튼 하나. 상세 조회 후 내용 모달 열기(관리 액션 없음).", - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "btn btn-secondary btn-xs flex-center gap-1 whitespace-nowrap" }, - "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": 7 }, - "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" } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - "comment": "페이지네이션 — 항상 노출(총 1페이지여도 표시). 페이지 변경 시 _local.templatePage 갱신 후 목록 refetch.", - "type": "basic", - "name": "Div", - "props": { "className": "flex justify-center mt-4" }, - "children": [ - { - "type": "composite", - "name": "Pagination", - "props": { - "currentPage": "{{alimtalk_templates?.data?.pagination?.current_page ?? 1}}", - "totalPages": "{{alimtalk_templates?.data?.pagination?.total_page ?? 1}}" - }, - "actions": [ - { - "event": "onPageChange", - "type": "action", - "handler": "sequence", - "actions": [ - { "handler": "setState", "params": { "target": "local", "templatePage": "{{$args[0]}}" } }, - { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - }, - "modals": [ - { - "id": "alimtalk_template_detail_modal", - "type": "composite", - "name": "Modal", - "props": { - "title": "$t:sirsoft-message_bizppurio.templates.detail.title", - "size": "lg" - }, - "children": [ - { + { + "comment": "위 — 총괄 안내 1줄 + 콘솔 열기 링크", "type": "basic", "name": "Div", - "props": { "className": "space-y-3 text-gray-900 dark:text-gray-100" }, + "props": { + "className": "space-y-1" + }, "children": [ - { - "comment": "A안 헤더 — 템플릿명 + 상태 배지(⑦) 한 줄. 배지는 RDY 세부(사용전)까지 표기.", + { + "type": "basic", + "name": "P", + "props": { + "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" + }, + "text": "•" + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.preparation.intro" + } + ] + }, + { + "type": "basic", + "name": "A", + "props": { + "href": "https://www.bizppurio.com", + "target": "_blank", + "rel": "noopener noreferrer", + "className": "inline-flex items-center gap-1.5 ml-4 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.preparation.console_link" + }, + { + "type": "basic", + "name": "Icon", + "props": { + "name": "up-right-from-square", + "size": "xs" + } + } + ] + } + ] + }, + { + "comment": "구분선 아래 — 문자·카카오 채널별 사전 준비 목록 (PC lg 이상 2단, 모바일 1단)", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-3 pt-3 border-t border-blue-100 dark:border-blue-900 grid grid-cols-1 lg:grid-cols-2 gap-4" + }, + "children": [ + { + "comment": "문자(SMS/LMS) — 카드 + 아이콘 라벨 + 발신번호 등록 1줄", + "type": "basic", + "name": "Div", + "props": { + "className": "space-y-2 rounded-lg bg-blue-100 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 p-3" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "flex items-center gap-2 text-sm font-semibold text-blue-900 dark:text-blue-100" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "envelope", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_label" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" + }, + "text": "•" + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_sender" + } + ] + } + ] + }, + { + "comment": "카카오 알림톡 — 카드 + 아이콘 라벨 + 채널/템플릿/API키 3줄", + "type": "basic", + "name": "Div", + "props": { + "className": "space-y-2 rounded-lg bg-blue-100 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 p-3" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "flex items-center gap-2 text-sm font-semibold text-blue-900 dark:text-blue-100" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "comment-dots", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_label" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" + }, + "text": "•" + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_channel" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" + }, + "text": "•" + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_template" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" + }, + "text": "•" + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_apikey" + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "validation_error", + "type": "basic", + "name": "Div", + "if": "{{_local.errors}}", + "props": { + "id": "bizppurio_settings_validation_error", + "role": "alert", + "aria-live": "assertive", + "tabIndex": -1, + "className": "mt-6 alert-danger scroll-mt-32" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex gap-3" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "triangle-exclamation", + "size": "md", + "className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "text-error-soft" + }, + "text": "$t:admin.plugins.settings.validation_error" + }, + { + "type": "basic", + "name": "Ul", + "if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}", + "props": { + "className": "mt-2 list-disc list-inside space-y-1" + }, + "iteration": { + "source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])", + "item_var": "message" + }, + "children": [ + { + "type": "basic", + "name": "Li", + "props": { + "className": "text-error-soft" + }, + "text": "{{message}}" + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "test_mode_card", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between" + }, + "children": [ + { "type": "basic", "name": "Div", - "props": { "className": "flex items-start justify-between gap-3" }, "children": [ - { "type": "basic", "name": "H3", "props": { "className": "text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug" }, "text": "{{_local.templateDetail?.templateName ?? ''}}" }, - { + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.test_mode.label" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.test_mode.hint" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.test_mode.account_notice" + } + ] + }, + { + "type": "composite", + "name": "Toggle", + "props": { + "name": "is_test_mode" + } + } + ] + } + ] + } + ] + }, + { + "id": "live_mode_warning", + "type": "basic", + "name": "Div", + "if": "{{_local.form && !_local.form.is_test_mode}}", + "props": { + "className": "mt-6 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex gap-3" + }, + "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": "H3", + "props": { + "className": "text-sm font-semibold text-warning-soft" + }, + "text": "$t:sirsoft-message_bizppurio.settings.live_mode_warning_title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "mt-1 text-warning-soft" + }, + "text": "$t:sirsoft-message_bizppurio.settings.live_mode_warning_body" + } + ] + } + ] + } + ] + }, + { + "id": "section_api", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "panel-header-row" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "section-heading-md" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.api.title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.api.description" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-6" + }, + "children": [ + { + "id": "field_bizppurio_id", + "type": "basic", + "name": "Div", + "props": { + "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-4" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.label" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-8" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "bizppurio_id", + "autoComplete": "off", + "className": "{{_local.errors?.bizppurio_id ? 'input border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input'}}" + } + }, + { + "id": "field_bizppurio_id_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.bizppurio_id}}", + "props": { + "className": "mt-1 text-sm text-red-600 dark:text-red-400" + }, + "text": "{{_local.errors?.bizppurio_id?.[0] ?? ''}}" + } + ] + } + ] + }, + { + "id": "field_password", + "type": "basic", + "name": "Div", + "props": { + "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-4" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.password.label" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.password.hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-8" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "password", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "{{_local.errors?.password ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" + } + }, + { + "id": "field_password_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.password}}", + "props": { + "className": "mt-1 text-sm text-red-600 dark:text-red-400" + }, + "text": "{{_local.errors?.password?.[0] ?? ''}}" + } + ] + } + ] + }, + { + "id": "field_connection_check", + "type": "basic", + "name": "Div", + "props": { + "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-4" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.label" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-8 flex lg:justify-end" + }, + "children": [ + { + "id": "connection_check_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{_local.tokenChecking}}", + "className": "btn btn-outline btn-sm flex-center gap-1.5" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "if": "{{_local.hasChanges}}", + "handler": "toast", + "params": { + "type": "warning", + "message": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.unsaved_changes" + } + }, + { + "if": "{{!_local.hasChanges}}", + "handler": "setState", + "params": { + "target": "local", + "tokenChecking": true + } + }, + { + "if": "{{!_local.hasChanges}}", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/token/check", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "local", + "tokenChecking": false + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.success" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "local", + "tokenChecking": false + } + }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.errors?.bizppurio_message ?? error.message ?? '$t:sirsoft-message_bizppurio.settings.fields.connection_check.failed'}}" + } + } + ] + } + ] + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "if": "{{_local.tokenChecking}}", + "props": { + "name": "spinner", + "className": "text-sm animate-spin" + } + }, + { + "type": "basic", + "name": "Icon", + "if": "{{!_local.tokenChecking}}", + "props": { + "name": "plug", + "size": "sm" + } + }, + { "type": "basic", "name": "Span", + "if": "{{!_local.tokenChecking}}", + "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.button" + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.tokenChecking}}", + "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.checking" + } + ] + } + ] + } + ] + }, + { + "id": "field_api_key", + "type": "basic", + "name": "Div", + "props": { + "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-4" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.label" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-8" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "api_key", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "{{_local.errors?.api_key ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" + } + }, + { + "id": "field_api_key_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.api_key}}", + "props": { + "className": "mt-1 text-sm text-red-600 dark:text-red-400" + }, + "text": "{{_local.errors?.api_key?.[0] ?? ''}}" + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "section_sending", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "panel-header-row" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "section-heading-md" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.description" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-6" + }, + "children": [ + { + "id": "field_sender_number", + "type": "basic", + "name": "Div", + "props": { + "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-4" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.label" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-8" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "sender_number", + "autoComplete": "off", + "className": "{{_local.errors?.sender_number ? 'input border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input'}}" + } + }, + { + "id": "field_sender_number_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.sender_number}}", + "props": { + "className": "mt-1 text-sm text-red-600 dark:text-red-400" + }, + "text": "{{_local.errors?.sender_number?.[0] ?? ''}}" + } + ] + } + ] + }, + { + "id": "field_sender_key", + "type": "basic", + "name": "Div", + "props": { + "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-4" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.label" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "lg:col-span-8" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "sender_key", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "{{_local.errors?.sender_key ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" + } + }, + { + "id": "field_sender_key_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.sender_key}}", + "props": { + "className": "mt-1 text-sm text-red-600 dark:text-red-400" + }, + "text": "{{_local.errors?.sender_key?.[0] ?? ''}}" + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "report_section", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "px-6 py-4 border-b border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 rounded-t-lg" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "bell", + "size": "sm", + "className": "text-blue-600 dark:text-blue-400" + } + }, + { + "type": "basic", + "name": "H3", + "props": { + "className": "text-base font-semibold text-blue-900 dark:text-blue-100" + }, + "text": "$t:sirsoft-message_bizppurio.settings.report.section_title" + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-4" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-label mb-2" + }, + "text": "$t:sirsoft-message_bizppurio.settings.report.hint" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "readOnly": true, + "value": "{{report_url?.data?.url ?? '/api/plugins/sirsoft-message_bizppurio/webhook'}}", + "className": "input-readonly flex-1 font-mono" + } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "flex-center gap-1.5 px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-body border border-gray-300 dark:border-gray-600 rounded-lg font-medium transition-colors whitespace-nowrap" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "copyToClipboard", + "params": { + "text": "{{report_url?.data?.url ?? '/api/plugins/sirsoft-message_bizppurio/webhook'}}" + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:sirsoft-message_bizppurio.settings.report.copied" + } + } + ] + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", "props": { - "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 {{_local.templateDetail?.status_badge?.variant === 'green' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : _local.templateDetail?.status_badge?.variant === 'yellow' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : _local.templateDetail?.status_badge?.variant === 'red' ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : _local.templateDetail?.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'}}" + "name": "copy", + "size": "sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.report.copy" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.report.note" + } + ] + } + ] + }, + { + "id": "footer_buttons", + "type": "basic", + "name": "Div", + "props": { + "className": "sticky-footer-buttons" + }, + "children": [ + { + "id": "cancel_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-secondary" + }, + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:common.cancel" + } + ] + }, + { + "id": "save_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{!_local.hasChanges || _local.isSaving}}", + "className": "btn btn-primary flex-center gap-1.5" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": true, + "errors": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/admin/plugins/{{route.identifier}}/settings", + "params": { + "method": "PUT", + "body": "{{_local.form}}" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": false, + "hasChanges": false + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:admin.plugins.settings.save_success" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "settings" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": false, + "errors": "{{error.errors ?? { _general: error.message }}}" + } + }, + { + "handler": "scrollIntoView", + "params": { + "selector": "#bizppurio_settings_validation_error", + "behavior": "smooth", + "block": "start", + "waitForElement": true, + "timeout": 2000 + } + }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "$t:admin.plugins.settings.save_error" + } + } + ] + } + ] + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "if": "{{_local.isSaving}}", + "props": { + "name": "spinner", + "className": "spinner-sm" + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{!_local.isSaving}}", + "text": "$t:common.save" + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.isSaving}}", + "text": "$t:common.saving" + } + ] + } + ] + } + ] + }, + { + "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": "{{templates_readiness?.data && !(templates_readiness?.data?.ready)}}", + "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": "{{!(templates_readiness?.data?.api_key_set)}}", + "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": "{{!(templates_readiness?.data?.sender_key_set)}}", + "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": "navigate", + "params": { + "path": "/admin/plugins/{{route.identifier}}/settings", + "query": { + "tab": "connection" + }, + "mergeQuery": true, + "replace": 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_manage_view", + "comment": "비즈뿌리오 알림 템플릿 관리(#597 §4.3) — DB 목록(정의 라벨 조인). 필터·검색·페이지는 URL 쿼리(bz_status/bz_search/bz_page, mergeQuery 왕복 규약). 행 액션은 알림 설정 탭 행 하단과 동일 의미(작성 모달·SMS 모달·승인취소·삭제 공유).", + "type": "basic", + "name": "Div", + "if": "{{templates_readiness?.data?.ready}}", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.manage.guide", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + } + } + ], + "props": { + "className": "flex items-center gap-2 mb-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-sm", + "value": "{{query.bz_status ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.manage.filter_all')}, ...['draft','requested','approved','rejected','stopped','blocked','dormant'].map(s => ({value: s, label: $t('sirsoft-message_bizppurio.template.status.' + s)}))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "actions": [ + { + "handler": "navigate", + "params": { + "path": "/admin/plugins/{{route.identifier}}/settings", + "replace": true, + "mergeQuery": true, + "query": { + "bz_status": "{{$event.target.value}}", + "bz_page": "" + } + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 w-56", + "placeholder": "$t:sirsoft-message_bizppurio.manage.search_placeholder", + "value": "{{_local.bzSearchDraft ?? (query.bz_search ?? '')}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "bzSearchDraft": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border 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": "sequence", + "params": { + "actions": [ + { + "handler": "navigate", + "params": { + "path": "/admin/plugins/{{route.identifier}}/settings", + "replace": true, + "mergeQuery": true, + "query": { + "bz_search": "{{_local.bzSearchDraft ?? ''}}", + "bz_page": "" + } + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.manage.btn_search" + } + ], + "props": { + "className": "flex-center gap-2 flex-wrap" + } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border 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": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.templates.list.refresh" + } + ], + "props": { + "className": "flex-between gap-2 mb-3" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.manage.total_count|count={{bizppurio_templates_list?.data?.pagination?.total ?? 0}}", + "props": { + "className": "text-label-subtle mb-2" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Table", + "props": { + "className": "w-full text-left" + }, + "children": [ + { + "type": "basic", + "name": "Thead", + "props": { + "className": "bg-gray-50 dark:bg-gray-900" + }, + "children": [ + { + "type": "basic", + "name": "Tr", + "children": [ + { + "type": "basic", + "name": "Th", + "props": { + "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.notification" + }, + { + "type": "basic", + "name": "Th", + "props": { + "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.owner" + }, + { + "type": "basic", + "name": "Th", + "props": { + "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.status" + }, + { + "type": "basic", + "name": "Th", + "props": { + "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.sms" + }, + { + "type": "basic", + "name": "Th", + "props": { + "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.requested_at" + }, + { + "type": "basic", + "name": "Th", + "props": { + "className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.synced_at" + }, + { + "type": "basic", + "name": "Th", + "props": { + "className": "px-4 py-2.5 text-right text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.actions" + } + ] + } + ] + }, + { + "type": "basic", + "name": "Tbody", + "if": "{{(bizppurio_templates_list?.data?.templates ?? []).length > 0}}", + "children": [ + { + "type": "basic", + "name": "Tr", + "iteration": { + "source": "{{bizppurio_templates_list?.data?.templates ?? []}}", + "item_var": "bzRow", + "index_var": "bzRowIdx" + }, + "props": { + "className": "border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50" + }, + "children": [ + { + "type": "basic", + "name": "Td", + "props": { + "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200" }, "children": [ - { "type": "basic", "name": "Span", "text": "{{$t(_local.templateDetail?.status_badge?.label_key ?? 'sirsoft-message_bizppurio.templates.status.unknown')}}" }, - { "type": "basic", "name": "Span", "if": "{{_local.templateDetail?.service_status === 'RDY'}}", "props": { "className": "opacity-70" }, "text": "$t:sirsoft-message_bizppurio.templates.status_sub.rdy" } - ] - } - ] - }, - { - "comment": "A안 메타 그리드 — 코드·카테고리·유형·등록요청일·처리일 압축 표시. 없는 값은 '—'.", - "type": "basic", - "name": "Div", - "props": { "className": "grid grid-cols-2 gap-x-4 gap-y-2 pb-3 border-b border-gray-200 dark:border-gray-700" }, - "children": [ - { - "type": "basic", "name": "Div", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.code" }, - { "type": "basic", "name": "P", "props": { "className": "font-mono text-sm text-gray-700 dark:text-gray-200 break-all" }, "text": "{{_local.templateDetail?.templateCode ?? '—'}}" } - ] - }, - { - "type": "basic", "name": "Div", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.category" }, - { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.categoryName ?? _local.templateDetail?.categoryCode ?? '—'}}" } - ] - }, - { - "type": "basic", "name": "Div", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.emphasize_type" }, - { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.templateEmphasizeType === 'TEXT' ? $t('sirsoft-message_bizppurio.templates.detail.type_text') : _local.templateDetail?.templateEmphasizeType === 'IMAGE' ? $t('sirsoft-message_bizppurio.templates.detail.type_image') : $t('sirsoft-message_bizppurio.templates.detail.type_none')}}" } - ] - }, - { - "type": "basic", "name": "Div", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.requested_at" }, - { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.createdAt ?? '—'}}" } - ] - }, - { - "type": "basic", "name": "Div", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.processed_at" }, - { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.modifiedAt ?? '—'}}" } - ] - } - ] - }, - { - "comment": "강조표기형(TEXT)일 때만 — 강조문구/보조문구", - "type": "basic", "name": "Div", - "if": "{{_local.templateDetail?.templateEmphasizeType === 'TEXT'}}", - "props": { "className": "grid grid-cols-2 gap-x-4" }, - "children": [ - { - "type": "basic", "name": "Div", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.title_field" }, - { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.templateTitle ?? '—'}}" } - ] - }, - { - "type": "basic", "name": "Div", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.subtitle_field" }, - { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.templateSubtitle ?? '—'}}" } - ] - } - ] - }, - { - "comment": "A안 — 내용을 가장 크게 강조", - "type": "basic", - "name": "Div", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.content" }, - { "type": "basic", "name": "Div", "props": { "className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3 whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-100 leading-relaxed" }, "text": "{{_local.templateDetail?.templateContent ?? ''}}" } - ] - }, - { - "comment": "이미지형(IMAGE)일 때만 — 미리보기 이미지", - "type": "basic", "name": "Div", - "if": "{{_local.templateDetail?.templateEmphasizeType === 'IMAGE' && _local.templateDetail?.templateImageUrl}}", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.image_upload" }, - { "type": "basic", "name": "Img", "props": { "src": "{{_local.templateDetail?.templateImageUrl}}", "className": "w-24 h-24 object-cover rounded border border-gray-200 dark:border-gray-700" } } - ] - }, - { - "comment": "버튼 목록 — 있을 때만. 각 버튼 = 이름 + 링크타입 배지 + 링크 URL(있으면).", - "type": "basic", "name": "Div", - "if": "{{(_local.templateDetail?.buttons ?? []).length > 0}}", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.buttons" }, - { - "type": "basic", "name": "Div", - "props": { "className": "space-y-1.5" }, - "iteration": { "source": "_local.templateDetail?.buttons ?? []", "item_var": "btn" }, - "children": [ - { - "type": "basic", "name": "Div", - "props": { "className": "flex items-center gap-2 flex-wrap rounded border border-gray-200 dark:border-gray-700 px-2.5 py-1.5" }, - "children": [ - { "type": "basic", "name": "Span", "props": { "className": "text-sm font-medium text-gray-800 dark:text-gray-100" }, "text": "{{btn.name ?? ''}}" }, - { "type": "basic", "name": "Span", "props": { "className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-200 flex-shrink-0" }, "text": "{{$t('sirsoft-message_bizppurio.templates.link_type.' + (btn.linkType ?? 'WL'))}}" }, - { "type": "basic", "name": "Span", "if": "{{btn.linkMo || btn.linkPc || btn.linkAnd || btn.linkIos}}", "props": { "className": "font-mono text-xs text-gray-500 dark:text-gray-400 break-all" }, "text": "{{btn.linkMo || btn.linkPc || btn.linkAnd || btn.linkIos}}" } - ] + { + "type": "basic", + "name": "P", + "text": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}", + "props": { + "className": "font-medium" } + }, + { + "type": "basic", + "name": "P", + "text": "{{bzRow.notification_type}}", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + } ] - } - ] - }, - { - "comment": "부가정보 — 있을 때만", - "type": "basic", "name": "Div", - "if": "{{_local.templateDetail?.templateExtra}}", - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.extra" }, - { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-700 dark:text-gray-200 whitespace-pre-wrap" }, "text": "{{_local.templateDetail?.templateExtra}}" } - ] - }, - { - "comment": "푸터 — 조회 전용. [닫기]만 노출(관리 액션 없음, 등록·상태변경은 비즈뿌리오 콘솔).", - "type": "basic", - "name": "Div", - "props": { "className": "flex items-center justify-end gap-2 pt-3 border-t border-gray-200 dark:border-gray-700" }, - "children": [ - { + }, + { "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "btn btn-secondary flex-shrink-0" }, - "actions": [ - { "type": "click", "handler": "closeModal", "target": "alimtalk_template_detail_modal" } - ], + "name": "Td", + "props": { + "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200" + }, + "text": "{{$t('sirsoft-message_bizppurio.manage.owner.' + (bzRow.definition_extension_type ?? 'core'))}} {{(bzRow.definition_extension_type ?? 'core') === 'core' ? '' : ('(' + (bzRow.definition_extension_identifier ?? '') + ')')}}" + }, + { + "type": "basic", + "name": "Td", + "props": { + "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200" + }, "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.detail.close" } + { + "type": "basic", + "name": "Span", + "text": "{{$t('sirsoft-message_bizppurio.template.status.' + (bzRow.has_content ? bzRow.status : 'unwritten'))}}", + "props": { + "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ' + (((bzRow.has_content ? bzRow.status : 'unwritten')) === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : ((bzRow.has_content ? bzRow.status : 'unwritten')) === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : ((bzRow.has_content ? bzRow.status : 'unwritten')) === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : ((bzRow.has_content ? bzRow.status : 'unwritten')) === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : (((bzRow.has_content ? bzRow.status : 'unwritten')) === 'stopped' || ((bzRow.has_content ? bzRow.status : 'unwritten')) === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + } + } ] - } + }, + { + "type": "basic", + "name": "Td", + "props": { + "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200" + }, + "text": "{{bzRow.sms_only ? $t('sirsoft-message_bizppurio.manage.sms_only') : (bzRow.fallback_sms_enabled ? $t('sirsoft-message_bizppurio.manage.sms_fallback') : '-')}}" + }, + { + "type": "basic", + "name": "Td", + "props": { + "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200 tabular-nums" + }, + "text": "{{(bzRow.requested_at ?? '').slice(0, 10) || '-'}}" + }, + { + "type": "basic", + "name": "Td", + "props": { + "className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200 tabular-nums" + }, + "text": "{{(bzRow.last_synced_at ?? '').slice(0, 10) || '-'}}" + }, + { + "type": "basic", + "name": "Td", + "props": { + "className": "px-4 py-3" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_reject_view": { + "notification_name": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}", + "comments": [] + } + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}", + "params": { + "method": "GET" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_reject_view.comments": "{{response.data?.template?.inspection_detail ?? []}}" + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_rejection" + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_view_reason", + "if": "{{bzRow.status === 'rejected'}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_upload": { + "uploading": false, + "error": null + }, + "bz_tpl_modal": { + "id": "{{bzRow.id}}", + "notification_type": "{{bzRow.notification_type}}", + "notification_name": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}", + "variables": "{{bzRow.definition_variables ?? []}}", + "status": "{{bzRow.status}}", + "inspection_detail": [], + "isSaving": false, + "error": null, + "sender_profile": "", + "content": { + "templateName": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}", + "templateMessageType": "BA", + "templateEmphasizeType": "NONE", + "templateContent": "", + "templateExtra": "", + "templateHeader": "", + "templateTitle": "", + "templateSubtitle": "", + "templateImageName": "", + "templateImageUrl": "", + "categoryCode": "", + "buttons": [], + "quickReplies": [], + "templateItem": { + "list": [], + "summary": { + "title": "", + "description": "" + } + }, + "templateItemHighlight": { + "title": "", + "description": "", + "imageUrl": "" + } + } + } + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}", + "params": { + "method": "GET" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "{{response.data?.template?.status}}", + "bz_tpl_modal.inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}", + "bz_tpl_modal.content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}" + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioCategories" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioProfiles" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "comment": "라벨은 내용 유무로 갈린다 — 행 하단(row_footer)이 미작성 행에 '작성', 작성된 행에 '수정' 을 그리는 것과 같은 어휘. 상태 배지도 같은 has_content 분기를 쓴다(:1916).", + "text": "{{$t('sirsoft-message_bizppurio.manage.' + (bzRow.has_content ? 'btn_edit' : 'btn_compose'))}}", + "if": "{{bzRow.status === 'draft' || bzRow.status === 'rejected'}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border 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": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval": { + "id": "{{bzRow.id}}", + "notification_name": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}", + "isSaving": false + } + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_cancel_approval" + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_approved", + "if": "{{bzRow.status === 'approved'}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30", + "disabled": "{{_global.bz_row_busy ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_row_busy": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}/request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_row_busy": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.requested_toast", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_row_busy": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_row_busy ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_request", + "if": "{{bzRow.status === 'draft' && bzRow.has_content}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border 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": "sequence", + "params": { + "actions": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}/cancel-request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.cancel_request_toast", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_cancel_request", + "if": "{{bzRow.status === 'requested'}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}/release", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.released_toast", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_release", + "if": "{{bzRow.status === 'dormant'}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border 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": "sequence", + "params": { + "actions": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}/sync", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.synced_toast", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_refresh", + "if": "{{bzRow.template_code}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border 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": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal": { + "notification_type": "{{bzRow.notification_type}}", + "notification_name": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}", + "variables": "{{bzRow.definition_variables ?? []}}", + "body": "", + "isSaving": false + } + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}", + "params": { + "method": "GET" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.body": "{{Object.assign({}, response.data?.template?.sms_body)}}", + "bz_sms_modal.editLang": "{{$locale}}" + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_sms" + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_sms" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_delete_modal": { + "id": "{{bzRow.id}}", + "notification_name": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}", + "isSaving": false + } + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_delete" + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.manage.btn_delete" + } + ], + "props": { + "className": "flex-center gap-1 flex-wrap justify-end" + } + } + ] + } + ] + } ] - } - ] - } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-inbox", + "className": "text-3xl text-gray-300 dark:text-gray-600 mb-2" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.manage.empty", + "props": { + "className": "font-medium" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.manage.empty_hint", + "props": { + "className": "text-xs mt-1" + } + } + ], + "props": { + "className": "py-10 text-center text-gray-500 dark:text-gray-400" + }, + "if": "{{(bizppurio_templates_list?.data?.templates ?? []).length === 0}}" + } + ], + "props": { + "className": "admin-card overflow-x-auto p-0" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Pagination", + "props": { + "currentPage": "{{bizppurio_templates_list?.data?.pagination?.current_page ?? 1}}", + "totalPages": "{{bizppurio_templates_list?.data?.pagination?.last_page ?? null}}", + "hasMorePages": "{{bizppurio_templates_list?.data?.pagination?.has_more_pages ?? false}}" + }, + "actions": [ + { + "event": "onPageChange", + "type": "action", + "handler": "sequence", + "actions": [ + { + "handler": "navigate", + "params": { + "path": "/admin/plugins/{{route.identifier}}/settings", + "replace": true, + "mergeQuery": true, + "query": { + "bz_page": "{{$args[0]}}" + } + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + } + ] + } + ] + } + ], + "props": { + "className": "flex justify-center mt-4" + } + } + ] + } ] - } + } + ] + } ] + }, + "modals": [ + { + "id": "modal_bizppurio_template", + "comment": "알림톡 템플릿 작성/신청 모달(#597 §4.2, 3면+플러그인 관리 화면 공유). 값은 _global.bz_tpl_modal 에 담는다(모달 컨텍스트 분리). content 는 kapi 등록 페이로드 필드 그대로이며, 유형 전환 잔여 필드는 서버(FormRequest)가 정돈한다. 신규/기존 분기는 id 유무의 저장 버튼 if 2벌(POST/PUT)로 처리한다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.form.modal_title|name={{_global.bz_tpl_modal?.notification_name ?? ''}}", + "size": "xl", + "closeOnOverlayClick": false + }, + "children": [ + { + "id": "bizppurio_tpl_modal_body", + "type": "basic", + "name": "Div", + "props": { + "className": "space-y-4 max-h-[65vh] overflow-y-auto pr-1" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.form.rejected_banner", + "props": { + "className": "text-xs font-medium text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2" + } + }, + { + "type": "basic", + "name": "P", + "iteration": { + "source": "{{_global.bz_tpl_modal?.inspection_detail ?? []}}", + "item_var": "cm" + }, + "props": { + "className": "text-xs text-red-700 dark:text-red-300 mt-1" + }, + "text": "{{(cm.createdAt ?? '').slice(0, 10)}} — {{cm.content ?? ''}}" + } + ], + "props": { + "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "if": "{{_global.bz_tpl_modal?.status === 'rejected' && (_global.bz_tpl_modal?.inspection_detail ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-triangle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_modal?.error}}", + "props": { + "className": "text-xs text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "if": "{{_global.bz_tpl_modal?.error}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile" + }, + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-full text-sm", + "value": "{{_global.bz_tpl_modal?.sender_profile ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.sender_profile_placeholder')}, ...((bizppurioProfiles?.data?.profiles ?? []).map(pr => ({value: pr.senderKey ?? '', label: (pr.name ?? pr.senderKey ?? '')})))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.sender_profile": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.category *" + }, + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-full text-sm", + "value": "{{_global.bz_tpl_modal?.content?.categoryCode ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.category_placeholder')}, ...((bizppurioCategories?.data?.categories ?? []).map(cat => ({value: cat.code ?? '', label: ((cat.groupName ? cat.groupName + ' › ' : '') + (cat.name ?? cat.code ?? ''))})))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.categoryCode": "{{$event.target.value}}" + } + } + ] + } + ] + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-3" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.name *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateName ?? ''}}", + "name": "bz_template_name", + "maxLength": 200 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateName": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.message_type *" + }, + { + "type": "basic", + "name": "Div", + "comment": "메시지 유형 — BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형", + "props": { + "className": "flex flex-wrap gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'BA' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "BA" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.BA" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'EX' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "EX" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.EX" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'AD' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "AD" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.AD" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'MI' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateMessageType": "MI" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.MI" + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_type *" + }, + { + "type": "basic", + "name": "Div", + "comment": "강조 유형 — NONE / TEXT 강조표기 / IMAGE 이미지 / ITEM_LIST 아이템리스트", + "props": { + "className": "flex flex-wrap gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "NONE" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.NONE" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "TEXT" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.TEXT" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "IMAGE" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.IMAGE" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'ITEM_LIST' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-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": "global", + "bz_tpl_modal.content.templateEmphasizeType": "ITEM_LIST" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.ITEM_LIST" + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_title *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateTitle ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateTitle": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.emphasize_subtitle *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateSubtitle ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateSubtitle": "{{$event.target.value}}" + } + } + ] + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-3" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.image *" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "file", + "accept": ".jpg,.jpeg,.png", + "className": "block w-full text-xs text-gray-600 dark:text-gray-300" + }, + "actions": [ + { + "type": "change", + "handler": "sirsoft-message_bizppurio.uploadTemplateImage", + "params": { + "stateTarget": "global", + "statePathUrl": "bz_tpl_modal.content.templateImageUrl", + "statePathName": "bz_tpl_modal.content.templateImageName", + "statePathStatus": "bz_tpl_upload" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.image_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.form.image_uploading", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400" + }, + "if": "{{_global.bz_tpl_upload?.uploading}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_upload?.error ?? ''}}", + "props": { + "className": "text-xs text-red-600 dark:text-red-400" + }, + "if": "{{_global.bz_tpl_upload?.error}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{_global.bz_tpl_modal?.content?.templateImageName}} — {{_global.bz_tpl_modal?.content?.templateImageUrl}}", + "props": { + "className": "text-xs text-green-700 dark:text-green-400 break-all" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateImageUrl}}" + } + ], + "props": { + "className": "space-y-1" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_highlight" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_title", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {title: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_description", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {description: $event.target.value})}}" + } + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-1.5" + } + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_image_url", + "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.imageUrl ?? ''}}", + "maxLength": 500 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {imageUrl: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_list" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.templateItem?.list ?? []).length >= 10}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: [...(_global.bz_tpl_modal?.content?.templateItem?.list ?? []), {title: '', description: ''}]})}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.item_add" + } + ], + "props": { + "className": "flex-between mt-2" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.item_list_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.templateItem?.list ?? []}}", + "item_var": "itm", + "index_var": "itmIdx" + }, + "props": { + "className": "flex items-center gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 6, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", + "value": "{{itm.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {title: $event.target.value}) : r)})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 23, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_description", + "value": "{{itm.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {description: $event.target.value}) : r)})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_item", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).filter((r, i) => i !== itmIdx)})}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium mt-2" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.item_summary" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 6, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title", + "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.title ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {title: $event.target.value})})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "maxLength": 14, + "placeholder": "$t:sirsoft-message_bizppurio.template.form.summary_description", + "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.description ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {description: $event.target.value})})}}" + } + } + ] + } + ], + "props": { + "className": "grid grid-cols-2 gap-1.5" + } + } + ], + "props": { + "className": "space-y-1.5" + }, + "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.content *" + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.content?.templateContent ?? '').length}}/1000", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_template_content", + "rows": 6, + "maxLength": 1000, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 font-mono", + "value": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateContent": "{{$event.target.value}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{_global.bz_tpl_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "type": "button", + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}", + "actions": [ + { + "type": "click", + "handler": "sirsoft-message_bizppurio.insertVariable", + "params": { + "variable": "{{v.key}}", + "target": "bz_template_content", + "stateTarget": "global", + "statePath": "bz_tpl_modal.content.templateContent" + } + } + ] + } + ], + "props": { + "className": "flex flex-wrap gap-1" + } + } + ], + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "if": "{{(_global.bz_tpl_modal?.variables ?? []).length > 0}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.extra *" + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "rows": 2, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content?.templateExtra ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateExtra": "{{$event.target.value}}" + } + } + ] + } + ], + "if": "{{_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'}}" + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.header" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_tpl_modal?.content.templateHeader ?? ''}}", + "maxLength": 16 + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.templateHeader": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.form.header_hint", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.buttons" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{[...(_global.bz_tpl_modal?.content?.buttons ?? []), {name: '', linkType: 'WL'}]}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.button_add" + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.buttons ?? []}}", + "item_var": "buttonsItem", + "index_var": "buttonsIdx" + }, + "props": { + "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-xs", + "value": "{{buttonsItem.linkType ?? 'WL'}}", + "options": "{{['WL','AL','DS','BK','MD','AC','BC','BT','P1','P2','P3','TN','MP'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", + "value": "{{buttonsItem.name ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_button", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).filter((b, i) => i !== buttonsIdx)}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ], + "props": { + "className": "flex items-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", + "value": "{{buttonsItem.linkMo ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", + "value": "{{buttonsItem.linkPc ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", + "value": "{{buttonsItem.linkAnd ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", + "value": "{{buttonsItem.linkIos ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", + "value": "{{buttonsItem.telNumber ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'TN'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", + "value": "{{buttonsItem.pluginId ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" + } + } + ], + "if": "{{buttonsItem.linkType === 'P1' || buttonsItem.linkType === 'P2' || buttonsItem.linkType === 'P3'}}" + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" + } + } + ] + } + ], + "props": { + "className": "space-y-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary text-xs font-medium" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.quick_replies" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700", + "disabled": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{[...(_global.bz_tpl_modal?.content?.quickReplies ?? []), {name: '', linkType: 'WL'}]}}" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.quick_reply_add" + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_tpl_modal?.content?.quickReplies ?? []}}", + "item_var": "quickRepliesItem", + "index_var": "quickRepliesIdx" + }, + "props": { + "className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "className": "w-40 text-xs", + "value": "{{quickRepliesItem.linkType ?? 'WL'}}", + "options": "{{['WL','AL','BK','MD','BC','BT'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name", + "value": "{{quickRepliesItem.name ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_quick_reply", + "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400" + }, + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).filter((b, i) => i !== quickRepliesIdx)}}" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-trash-can", + "size": "sm" + } + } + ] + } + ], + "props": { + "className": "flex items-center gap-1.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo", + "value": "{{quickRepliesItem.linkMo ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc", + "value": "{{quickRepliesItem.linkPc ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'WL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and", + "value": "{{quickRepliesItem.linkAnd ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios", + "value": "{{quickRepliesItem.linkIos ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'AL'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number", + "value": "{{quickRepliesItem.telNumber ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'TN'}}" + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id", + "value": "{{quickRepliesItem.pluginId ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}" + } + } + ], + "if": "{{quickRepliesItem.linkType === 'P1' || quickRepliesItem.linkType === 'P2' || quickRepliesItem.linkType === 'P3'}}" + } + ], + "props": { + "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5" + } + } + ] + } + ], + "props": { + "className": "space-y-1.5" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bz_tpl_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_template" + } + ], + "text": "$t:common.cancel" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", + "params": { + "method": "POST", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}", + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", + "if": "{{!(_global.bz_tpl_modal?.id)}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save", + "if": "{{_global.bz_tpl_modal?.id}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", + "params": { + "method": "POST", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}", + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", + "if": "{{!(_global.bz_tpl_modal?.id)}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": true, + "content": "{{_global.bz_tpl_modal?.content}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_template" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}" + } + } + ] + } + ] + }, + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", + "if": "{{_global.bz_tpl_modal?.id}}" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_sms", + "comment": "대체 SMS/SMS 단독 본문 편집 모달(#597 §4.1). 값은 _global.bz_sms_modal, 저장은 delivery upsert(행 없으면 draft 생성). #{var} 치환 규칙은 알림톡 본문과 동일.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.sms.modal_title|name={{_global.bz_sms_modal?.notification_name ?? ''}}", + "size": "md", + "closeOnOverlayClick": false + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.sms.hint", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + } + }, + { + "id": "bz_sms_lang_tabs", + "comment": "SMS 본문 언어 탭(§14.3). sms_body 는 로케일 맵이며 수신자 로케일로 발송된다 — 코어 알림 템플릿 편집 모달과 동일한 editLang 패턴.", + "type": "basic", + "name": "Div", + "props": { + "className": "flex border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{$locales}}", + "item_var": "loc" + }, + "props": { + "type": "button", + "className": "{{(_global.bz_sms_modal?.editLang ?? $locale) === loc ? 'border-b-2 border-teal-500 dark:border-teal-400 text-teal-600 dark:text-teal-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none" + }, + "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}", + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.editLang": "{{loc}}" + } + } + ] + } + ] + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_sms_body", + "rows": 4, + "maxLength": 2000, + "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100", + "value": "{{_global.bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale] ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.body": "{{Object.assign({}, _global.bz_sms_modal?.body, {[_global.bz_sms_modal?.editLang ?? $locale]: $event.target.value})}}" + } + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "iteration": { + "source": "{{_global.bz_sms_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "type": "button", + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300 hover:border-teal-400" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}", + "actions": [ + { + "type": "click", + "handler": "sirsoft-message_bizppurio.insertVariable", + "params": { + "variable": "{{v.key}}", + "target": "bz_sms_body", + "stateTarget": "global", + "statePath": "{{'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)}}" + } + } + ] + } + ], + "props": { + "className": "flex flex-wrap gap-1" + } + } + ], + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "if": "{{(_global.bz_sms_modal?.variables ?? []).length > 0}}" + } + ], + "props": { + "className": "space-y-3" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_sms" + } + ], + "text": "$t:common.cancel" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{_global.bz_sms_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}", + "params": { + "method": "PUT", + "body": { + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.sms.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_sms" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.sms.btn_save" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_cancel_approval", + "comment": "승인 취소 경고 확인 모달(#597 §3.2). 확정 시 kapi cancel_approval → draft 복귀 — status 가 approved 를 벗어나는 즉시 발송 게이트가 차단된다(대체 SMS 는 sms 채널 규칙으로 계속). 취소 후 상세를 받아 작성 모달을 이어서 연다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.cancel_approval.modal_title", + "size": "sm", + "closeOnOverlayClick": false + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-triangle-exclamation", + "className": "text-amber-500 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_cancel_approval?.notification_name ?? ''}}", + "props": { + "className": "text-sm text-gray-700 dark:text-gray-200" + } + } + ], + "props": { + "className": "flex items-start gap-2" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_cancel_approval" + } + ], + "text": "$t:common.cancel" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 disabled:opacity-50", + "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast", + "type": "info" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_cancel_approval" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_cancel_approval.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_rejection", + "comment": "반려 사유 열람 모달(#597 §4.1). kapi detail 의 comments 스냅샷(inspection_detail) 원문을 그대로 보여준다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.template.rejection.modal_title|name={{_global.bz_reject_view?.notification_name ?? ''}}", + "size": "md" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "iteration": { + "source": "{{_global.bz_reject_view?.comments ?? []}}", + "item_var": "cm" + }, + "props": { + "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "P", + "text": "{{(cm.createdAt ?? '').slice(0, 16).split('T').join(' ')}}", + "props": { + "className": "text-xs text-red-500 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "P", + "text": "{{cm.content ?? ''}}", + "props": { + "className": "text-sm text-red-800 dark:text-red-200 whitespace-pre-wrap" + } + } + ] + } + ], + "props": { + "className": "space-y-2 max-h-80 overflow-y-auto" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_rejection" + } + ], + "text": "$t:sirsoft-message_bizppurio.template.rejection.btn_close" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "id": "modal_bizppurio_delete", + "comment": "템플릿 삭제 확인 모달(#597 §3.2 DELETE). 카카오측 코드는 삭제 가능 상태(R+REG/REJ)일 때만 동반 삭제되며, 그 외에는 DB 행만 삭제되고 사유가 응답에 실린다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.manage.delete.modal_title", + "size": "sm", + "closeOnOverlayClick": false + }, + "children": [ + { + "type": "basic", + "name": "P", + "text": "$t:sirsoft-message_bizppurio.manage.delete.warning|name={{_global.bz_delete_modal?.notification_name ?? ''}}", + "props": { + "className": "text-sm text-gray-700 dark:text-gray-200" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bz_delete_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_delete" + } + ], + "text": "$t:common.cancel" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 disabled:opacity-50", + "disabled": "{{_global.bz_delete_modal?.isSaving ?? false}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_delete_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_delete_modal?.id}}", + "params": { + "method": "DELETE" + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + }, + { + "handler": "toast", + "params": { + "message": "{{response.data?.kakao_deleted ? $t('sirsoft-message_bizppurio.manage.delete.done_with_kakao') : $t('sirsoft-message_bizppurio.manage.delete.done_db_only')}}", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_delete" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_delete_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_delete_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ], + "text": "$t:sirsoft-message_bizppurio.manage.delete.btn_confirm" + } + ], + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + } + } + ] + } + ] } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php index 0694e26f..6864ddbd 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php @@ -15,6 +15,11 @@ use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; * 하여 카카오가 준 실패 사유(message)와 결과 코드를 그대로 422 로 반환한다. 운영자가 반려/차단 * 사유를 화면에서 바로 확인할 수 있게 한다. 알림톡 템플릿 관리(Phase 5)와 연동(Phase 6) * 컨트롤러가 공유한다. + * + * errors 키는 `bizppurio_message` 로 고정한다 — 이 플러그인의 다른 kapi 경로 + * (BizppurioTemplateController·TokenCheckController)와 화면 소비처, API 문서가 모두 그 키를 + * 읽는다. 이 트레이트만 다른 키를 쓰면 카테고리·발신프로필 조회 실패에서만 사유 원문이 + * 버려지고 고정 문구만 노출된다. */ trait GuardsKakaoRequests { @@ -33,7 +38,7 @@ trait GuardsKakaoRequests 'sirsoft-message_bizppurio::messages.error.kakao_request_failed', 422, [ - 'kakao_message' => $e->getMessage(), + 'bizppurio_message' => $e->getMessage(), 'result_code' => $e->getResultCode(), ], ); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Console/SyncTemplateStatusCommand.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Console/SyncTemplateStatusCommand.php new file mode 100644 index 00000000..8ab36e72 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Console/SyncTemplateStatusCommand.php @@ -0,0 +1,60 @@ +syncRequested(); + + if ($result['checked'] === 0) { + $this->info('검수중(requested) 템플릿이 없어 카카오 조회를 건너뜁니다.'); + + return self::SUCCESS; + } + + $this->info(sprintf( + '검수중 템플릿 %d건 점검, %d건 상태 전이.', + $result['checked'], + $result['transitioned'], + )); + + return self::SUCCESS; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php index fe007bab..056d2a6f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php @@ -8,68 +8,33 @@ use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests; -use Plugins\Sirsoft\MessageBizppurio\Http\Requests\AlimtalkTemplateListRequest; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; -use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService; /** - * 알림톡 템플릿 조회 컨트롤러 (Phase 5). + * 알림톡 작성 모달 참조 조회 컨트롤러 (#597). * - * 카카오 관리 API(kapi)로 알림톡 템플릿을 실시간 조회한다(목록·상세·카테고리·발신프로필). - * 등록·수정·삭제·검수·상태변경은 비즈뿌리오 콘솔로 위임하며, 이 화면은 조회 + 알림 연결만 - * 담당한다. 템플릿은 DB 에 저장하지 않고 목록/상세를 매 요청 실시간 조회한다. + * 알림톡 템플릿 작성 모달의 발신프로필 셀렉트·카테고리 셀렉트가 소비하는 kapi 조회를 + * 위임한다. 템플릿 목록/상세의 실시간 조회 화면(구 Phase 5)은 DB 기반 라이프사이클 + * (BizppurioTemplateController)로 대체되어 제거됐다. * - * 권한(라우트 미들웨어): - * - 조회(list/detail/categories/profiles): sirsoft-message_bizppurio.messaging.view + * 권한(라우트 미들웨어): 조회(categories/profiles) = messaging.view * - * kapi 실패는 BizppurioApiException 으로 전달되므로, 각 액션에서 catch 하여 카카오가 준 - * 실패 사유(message)를 그대로 422 로 반환한다(운영자가 조회 실패 원인을 바로 확인). + * kapi 실패는 BizppurioApiException 으로 전달되므로, guard 로 감싸 카카오가 준 + * 실패 사유(message)를 그대로 422 로 반환한다. */ class AlimtalkTemplateController extends AdminBaseController { use GuardsKakaoRequests; /** - * @param AlimtalkTemplateService $service 알림톡 템플릿 서비스 - * @param NotificationBindingService $bindings 연동 서비스(발송 내용 캐시 초기화 위임) + * @param AlimtalkTemplateService $service 발신프로필·카테고리 조회 서비스 */ public function __construct( private readonly AlimtalkTemplateService $service, - private readonly NotificationBindingService $bindings, ) { parent::__construct(); } - /** - * 알림톡 템플릿 목록을 실시간 조회합니다. - * - * 쿼리: status(templateStatus)·keyword(최대 50자)·page·count - * - * @param AlimtalkTemplateListRequest $request 검증된 목록 필터 - * @return JsonResponse data 에 templates·pagination - */ - public function index(AlimtalkTemplateListRequest $request): JsonResponse - { - return $this->guard(function () use ($request) { - $result = $this->service->list($request->filters()); - - return ResponseHelper::success('messages.success', $result); - }); - } - - /** - * 알림톡 템플릿 상세를 실시간 조회합니다. - * - * @param string $templateCode 템플릿 코드 - * @return JsonResponse data.template 에 배지·가능 액션이 부가된 상세 - */ - public function show(string $templateCode): JsonResponse - { - return $this->guard(fn () => ResponseHelper::success('messages.success', [ - 'template' => $this->service->detail($templateCode), - ])); - } - /** * 템플릿 등록에 사용할 카테고리 전체를 조회합니다. * @@ -93,26 +58,4 @@ class AlimtalkTemplateController extends AdminBaseController 'profiles' => $this->service->senderProfiles(), ])); } - - /** - * 발송용 템플릿 내용 캐시를 초기화합니다 (관리자 수동 갱신). - * - * 카카오에서 템플릿 내용을 방금 변경해 캐시 만료(기본 1시간)를 기다리지 않고 즉시 - * 반영하고 싶을 때 호출한다. 연결된 모든 알림톡 템플릿의 캐시를 비워, 다음 발송에서 - * 최신 내용으로 재조회되게 한다. kapi 호출 없이 로컬 캐시만 비우므로 rate limit 영향 없음. - * - * @return JsonResponse data.cleared 에 초기화한 캐시 수 - */ - public function clearCache(): JsonResponse - { - $cleared = $this->bindings->clearTemplateContentCache(); - - return ResponseHelper::success('messages.cache.cleared', [ - 'cleared' => $cleared, - ]); - } - - // kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환하는 guard() 는 - // GuardsKakaoRequests 트레이트로 이관(연동 컨트롤러와 공유). - // 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 조회 실패 원인을 즉시 파악한다. } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php new file mode 100644 index 00000000..c114a620 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php @@ -0,0 +1,380 @@ +service->list($request->filters(), $request->page(), $request->perPage()); + + return ResponseHelper::success('messages.success', [ + 'templates' => collect($paginator->items()) + ->map(fn (BizppurioTemplate $row) => $this->listRow($row)) + ->all(), + 'pagination' => [ + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + 'has_more_pages' => $paginator->hasMorePages(), + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + ], + ]); + } + + /** + * 알림 설정 탭 행 표시용 요약 맵을 조회합니다 (notification_type 키). + * + * 알림 설정 3면(코어/게시판/이커머스)의 행 하단 UI 가 소비한다. 행 수는 알림 정의 + * 수에 묶인 설정성 데이터라 페이지네이션 없이 전량 내려준다. + * + * @return JsonResponse data.templates 에 notification_type 키 요약 맵 + */ + public function map(): JsonResponse + { + return ResponseHelper::success('messages.success', [ + 'templates' => $this->service->summaryMap(), + ]); + } + + /** + * 템플릿 상세를 조회합니다 (content·inspection_detail 포함). + * + * @param int $id 템플릿 PK + * @return JsonResponse data.template 에 상세 + */ + public function show(int $id): JsonResponse + { + $template = $this->service->find($id); + if ($template === null) { + return ResponseHelper::error('messages.not_found', 404); + } + + return ResponseHelper::success('messages.success', [ + 'template' => $this->detailRow($template), + ]); + } + + /** + * 템플릿을 생성합니다 (status=draft). + * + * @param StoreBizppurioTemplateRequest $request 검증된 생성 데이터 + * @return JsonResponse data.template 에 생성된 행 + */ + public function store(StoreBizppurioTemplateRequest $request): JsonResponse + { + $template = $this->service->create($request->validated()); + + return ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.created', + ['template' => $this->detailRow($template)], + 201, + ); + } + + /** + * 템플릿을 수정합니다 (content 변경은 draft/rejected 만). + * + * @param UpdateBizppurioTemplateRequest $request 검증된 수정 데이터 + * @param int $id 템플릿 PK + * @return JsonResponse data.template 에 갱신된 행 + */ + public function update(UpdateBizppurioTemplateRequest $request, int $id): JsonResponse + { + $template = $this->service->find($id); + if ($template === null) { + return ResponseHelper::error('messages.not_found', 404); + } + + return $this->guardLifecycle(fn () => ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.updated', + ['template' => $this->detailRow($this->service->update($template, $request->validated()))], + )); + } + + /** + * 발송 설정(알림톡 사용·대체 SMS·SMS 본문·SMS 단독·활성)을 upsert 합니다. + * + * 알림 설정 탭 행 하단 토글의 즉시 저장 경로. 대상 행이 없으면 draft 로 생성한다. + * + * @param UpdateBizppurioDeliveryRequest $request 검증된 발송 설정 + * @return JsonResponse data.template 에 저장된 행 + */ + public function upsertDelivery(UpdateBizppurioDeliveryRequest $request): JsonResponse + { + $template = $this->service->upsertDelivery( + (string) $request->route('notificationType'), + $request->deliveryData(), + ); + + return ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.updated', + ['template' => $this->detailRow($template)], + ); + } + + /** + * 검수를 신청합니다 (채번 → kapi add/update → request → status=requested). + * + * @param int $id 템플릿 PK + * @return JsonResponse data.template 에 갱신된 행 + */ + public function requestInspection(int $id): JsonResponse + { + $template = $this->service->find($id); + if ($template === null) { + return ResponseHelper::error('messages.not_found', 404); + } + + return $this->guardLifecycle(fn () => ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.requested', + ['template' => $this->detailRow($this->service->requestInspection($template))], + )); + } + + /** + * 검수 신청을 취소합니다 (REQ→REG, status=draft 복귀). + * + * @param int $id 템플릿 PK + * @return JsonResponse data.template 에 갱신된 행 + */ + public function cancelRequest(int $id): JsonResponse + { + $template = $this->service->find($id); + if ($template === null) { + return ResponseHelper::error('messages.not_found', 404); + } + + return $this->guardLifecycle(fn () => ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.request_cancelled', + ['template' => $this->detailRow($this->service->cancelRequest($template))], + )); + } + + /** + * 승인을 취소합니다 (승인→등록 복귀 — 알림톡 발송 즉시 차단). + * + * @param int $id 템플릿 PK + * @return JsonResponse data.template 에 갱신된 행 + */ + public function cancelApproval(int $id): JsonResponse + { + $template = $this->service->find($id); + if ($template === null) { + return ResponseHelper::error('messages.not_found', 404); + } + + return $this->guardLifecycle(fn () => ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.approval_cancelled', + ['template' => $this->detailRow($this->service->cancelApproval($template))], + )); + } + + /** + * 휴면(DMT) 템플릿을 해제합니다. + * + * @param int $id 템플릿 PK + * @return JsonResponse data.template 에 갱신된 행 + */ + public function release(int $id): JsonResponse + { + $template = $this->service->find($id); + if ($template === null) { + return ResponseHelper::error('messages.not_found', 404); + } + + return $this->guardLifecycle(fn () => ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.released', + ['template' => $this->detailRow($this->service->releaseDormant($template))], + )); + } + + /** + * 단건 상태를 카카오와 동기화합니다 (수동 [새로고침]). + * + * @param int $id 템플릿 PK + * @return JsonResponse data.template 에 갱신된 행 + */ + public function sync(int $id): JsonResponse + { + $template = $this->service->find($id); + if ($template === null) { + return ResponseHelper::error('messages.not_found', 404); + } + + return $this->guardLifecycle(fn () => ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.synced', + ['template' => $this->detailRow($this->service->sync($template))], + )); + } + + /** + * 이미지형 템플릿용 이미지를 업로드합니다 (kapi 업로드 프록시). + * + * @param BizppurioTemplateImageRequest $request 검증된 이미지 파일 + * @return JsonResponse data.url 에 카카오 이미지 URL + */ + public function uploadImage(BizppurioTemplateImageRequest $request): JsonResponse + { + return $this->guardLifecycle(fn () => ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.image_uploaded', + ['url' => $this->service->uploadImage($request->file('image'))], + )); + } + + /** + * 템플릿을 삭제합니다 (카카오측은 삭제 가능 상태일 때만 동반 삭제). + * + * @param int $id 템플릿 PK + * @return JsonResponse data 에 kakao_deleted·kakao_skip_reason + */ + public function destroy(int $id): JsonResponse + { + $template = $this->service->find($id); + if ($template === null) { + return ResponseHelper::error('messages.not_found', 404); + } + + return ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.deleted', + $this->service->delete($template), + ); + } + + /** + * 라이프사이클 액션을 실행하고 도메인 실패를 422 로 매핑합니다. + * + * - BizppurioApiException: 카카오가 준 사유 원문(bizppurio_message)·결과코드를 errors 로 노출 + * (관리자 전용 면 — 조치 근거 제공, TokenCheckController 규약과 동일) + * - BizppurioTemplateStateException: 예외가 든 메시지 키를 해석해 422 + * 그 외 예외는 잡지 않는다 — 인프라 장애·코드 결함은 500 으로 드러나야 한다. + * + * @param callable():JsonResponse $callback 라이프사이클 액션 + * @return JsonResponse 성공 응답 또는 422 + */ + private function guardLifecycle(callable $callback): JsonResponse + { + try { + return $callback(); + } catch (BizppurioApiException $e) { + return ResponseHelper::error( + 'sirsoft-message_bizppurio::messages.error.kakao_request_failed', + 422, + [ + 'bizppurio_message' => $e->getMessage(), + 'result_code' => $e->getResultCode(), + ], + ); + } catch (BizppurioTemplateStateException $e) { + return ResponseHelper::error($e->getMessageKey(), 422, null, $e->getMessageParams()); + } + } + + /** + * 관리 화면 목록 행을 직렬화합니다 (조인된 정의 라벨·소속 포함, 대형 JSON 제외). + * + * @param BizppurioTemplate $row 목록 행 (definition_* 조인 별칭 포함) + * @return array 직렬화된 행 + */ + private function listRow(BizppurioTemplate $row): array + { + $definitionName = $row->getAttribute('definition_name'); + $definitionVariables = $row->getAttribute('definition_variables'); + + return [ + 'id' => $row->id, + 'notification_type' => $row->notification_type, + 'definition_name' => is_string($definitionName) ? (json_decode($definitionName, true) ?: null) : null, + 'definition_variables' => is_string($definitionVariables) ? (json_decode($definitionVariables, true) ?: []) : [], + 'definition_extension_type' => $row->getAttribute('definition_extension_type'), + 'definition_extension_identifier' => $row->getAttribute('definition_extension_identifier'), + 'alimtalk_enabled' => $row->alimtalk_enabled, + 'template_code' => $row->template_code, + 'status' => $row->status->value, + 'has_content' => (bool) $row->getAttribute('has_content'), + 'requested_at' => $row->requested_at?->toIso8601String(), + 'approved_at' => $row->approved_at?->toIso8601String(), + 'last_synced_at' => $row->last_synced_at?->toIso8601String(), + 'fallback_sms_enabled' => $row->fallback_sms_enabled, + 'sms_only' => $row->sms_only, + 'is_active' => $row->is_active, + ]; + } + + /** + * 템플릿 상세를 직렬화합니다 (content·승인 스냅샷·반려 사유 포함). + * + * @param BizppurioTemplate $template 대상 행 + * @return array 직렬화된 상세 + */ + private function detailRow(BizppurioTemplate $template): array + { + return [ + 'id' => $template->id, + 'notification_type' => $template->notification_type, + 'alimtalk_enabled' => $template->alimtalk_enabled, + 'template_code' => $template->template_code, + 'sender_key' => $template->sender_key, + 'content' => $template->content, + 'approved_content' => $template->approved_content, + 'status' => $template->status->value, + 'is_approved' => $template->status === BizppurioTemplateStatus::Approved, + 'inspection_detail' => $template->inspection_detail, + 'requested_at' => $template->requested_at?->toIso8601String(), + 'approved_at' => $template->approved_at?->toIso8601String(), + 'last_synced_at' => $template->last_synced_at?->toIso8601String(), + 'fallback_sms_enabled' => $template->fallback_sms_enabled, + 'sms_body' => $template->sms_body, + 'sms_only' => $template->sms_only, + 'is_active' => $template->is_active, + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php deleted file mode 100644 index b74b9e47..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php +++ /dev/null @@ -1,99 +0,0 @@ - $this->service->all(withAvailability: true), - ]); - } - - /** - * 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 드롭다운). - * - * kapi 실패는 카카오가 준 사유를 그대로 422 로 반환한다. - * - * @return JsonResponse data.templates 에 승인 템플릿(code/name) 배열, 실패 시 422 - */ - public function approvedTemplates(): JsonResponse - { - return $this->guard(fn () => ResponseHelper::success('messages.success', [ - 'templates' => $this->service->approvedTemplates(), - ])); - } - - /** - * 연결 템플릿·SMS 대체를 즉시 저장합니다 (전용 칸 변경 시 자동 호출). - * - * 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다("빈 코드=해제" 규칙 — - * 드롭다운에서 "연결 안 함"을 고르면 해제까지 한 번에 처리). SMS 대체는 연결이 있을 때만 - * 의미가 있다. - * - * @param StoreNotificationBindingRequest $request 검증된 연동 입력 - * @return JsonResponse 저장 결과 (해제 시에도 200) - */ - public function store(StoreNotificationBindingRequest $request): JsonResponse - { - $validated = $request->validated(); - - return $this->guard(function () use ($validated) { - $this->service->applyFromTemplateSave( - $validated['notification_type'], - $validated['template_code'] ?? null, - $validated['template_name'] ?? null, - (bool) ($validated['fallback_sms_enabled'] ?? false), - ); - - return ResponseHelper::success('messages.binding.saved', [ - 'bindings' => $this->service->all(), - ]); - }); - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/BizppurioTemplateStatus.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/BizppurioTemplateStatus.php new file mode 100644 index 00000000..2992f16a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/BizppurioTemplateStatus.php @@ -0,0 +1,110 @@ + self::Draft, + 'REQ' => self::Requested, + 'REJ' => self::Rejected, + 'RDY', 'ACT' => self::Approved, + 'STP' => self::Stopped, + 'BLK' => self::Blocked, + 'DMT' => self::Dormant, + default => null, + }; + } + + /** + * kapi 상세 응답(status/inspectionStatus/block/dormant)에서 serviceStatus 를 유도합니다. + * + * 상세 조회(template/detail)는 serviceStatus 대신 status(S/A/R)+inspectionStatus + * (REG/REQ/REJ/APR)+block/dormant 를 내려주므로 목록과 동일한 어휘로 환원한다. + * serviceStatus 필드가 이미 있으면 그대로 사용한다. + * + * @param array $detail kapi 템플릿 상세(또는 목록) 행 + * @return string serviceStatus 코드 + */ + public static function serviceStatusFromDetail(array $detail): string + { + if (! empty($detail['serviceStatus'])) { + return (string) $detail['serviceStatus']; + } + + $inspection = (string) ($detail['inspectionStatus'] ?? ''); + $status = (string) ($detail['status'] ?? ''); + $block = (bool) ($detail['block'] ?? false); + $dormant = (bool) ($detail['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', + }; + } + + /** + * 발송 가능 상태(승인) 여부를 반환합니다. + * + * @return bool 승인 상태면 true + */ + public function isApproved(): bool + { + return $this === self::Approved; + } + + /** + * 카카오 등록 내용(content) 수정·삭제가 가능한 상태인지 반환합니다. + * + * kapi 는 status R + inspectionStatus REG/REJ 에서만 update/delete 를 허용한다(부록 A-4). + * 우리 상태로는 Draft(등록·검수취소·승인취소 복귀)와 Rejected(반려)가 이에 해당한다. + * + * @return bool 수정 가능 상태면 true + */ + public function allowsContentEdit(): bool + { + return $this === self::Draft || $this === self::Rejected; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioTemplateStateException.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioTemplateStateException.php new file mode 100644 index 00000000..57a0d715 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioTemplateStateException.php @@ -0,0 +1,49 @@ + $replace 메시지 치환 파라미터 + */ + public function __construct( + private readonly string $messageKey, + private readonly array $replace = [], + ) { + parent::__construct(__($messageKey, $replace)); + } + + /** + * 다국어 메시지 키를 반환합니다. + * + * @return string 다국어 메시지 키 + */ + public function getMessageKey(): string + { + return $this->messageKey; + } + + /** + * 메시지 치환 파라미터를 반환합니다. + * + * @return array 치환 파라미터 + */ + public function getMessageParams(): array + { + return $this->replace; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/AlimtalkTemplateListRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/AlimtalkTemplateListRequest.php deleted file mode 100644 index f0e1112c..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/AlimtalkTemplateListRequest.php +++ /dev/null @@ -1,58 +0,0 @@ - - */ - public function rules(): array - { - return [ - 'status' => ['nullable', 'string', 'max:30'], - 'keyword' => ['nullable', 'string', 'max:50'], - 'page' => ['nullable', 'integer', 'min:1'], - 'count' => ['nullable', 'integer', 'min:1'], - ]; - } - - /** - * 서비스 list() 에 전달할 필터 배열을 반환합니다. - * - * @return array status·keyword·page·count (미전달 키는 null) - */ - public function filters(): array - { - return [ - 'status' => $this->validated('status'), - 'keyword' => $this->validated('keyword'), - 'page' => $this->validated('page'), - 'count' => $this->validated('count'), - ]; - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateImageRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateImageRequest.php new file mode 100644 index 00000000..bf446f06 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateImageRequest.php @@ -0,0 +1,91 @@ + + */ + public function rules(): array + { + return [ + 'image' => [ + 'required', + 'file', + 'mimes:jpg,jpeg,png', + 'max:'.self::MAX_KILOBYTES, + 'dimensions:min_width='.self::MIN_WIDTH, + ], + ]; + } + + /** + * 가로:세로 = 2:1 비율을 after 훅으로 검증합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $v) { + $file = $this->file('image'); + if ($file === null || ! $file->isValid()) { + return; + } + + $size = @getimagesize((string) $file->getRealPath()); + if ($size === false) { + return; + } + + [$width, $height] = $size; + if ($height <= 0 || abs(($width / $height) - 2.0) > 0.01) { + $v->errors()->add('image', __('sirsoft-message_bizppurio::messages.validation.image_ratio_invalid')); + } + }); + } + + /** + * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다. + * + * @return array 필드 경로 → 라벨 + */ + public function attributes(): array + { + $label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}"); + + return [ + 'image' => $label('image'), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateListRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateListRequest.php new file mode 100644 index 00000000..cfaa0577 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateListRequest.php @@ -0,0 +1,96 @@ + + */ + public function rules(): array + { + return [ + 'status' => ['nullable', Rule::enum(BizppurioTemplateStatus::class)], + 'search' => ['nullable', 'string', 'max:100'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:'.self::MAX_PER_PAGE], + ]; + } + + /** + * 저장소 필터 배열을 반환합니다. + * + * @return array status / search + */ + public function filters(): array + { + return [ + 'status' => $this->validated('status'), + 'search' => $this->validated('search'), + ]; + } + + /** + * 요청 페이지 번호를 반환합니다. + * + * @return int 페이지 번호 (기본 1) + */ + public function page(): int + { + return max(1, (int) ($this->validated('page') ?? 1)); + } + + /** + * 요청 페이지 크기를 반환합니다. + * + * @return int 페이지 크기 (기본 20, 최대 100) + */ + public function perPage(): int + { + return min(self::MAX_PER_PAGE, max(1, (int) ($this->validated('per_page') ?? self::DEFAULT_PER_PAGE))); + } + + /** + * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다. + * + * @return array 필드 경로 → 라벨 + */ + public function attributes(): array + { + $label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}"); + + return [ + 'status' => $label('status'), + 'search' => $label('search'), + 'page' => $label('page'), + 'per_page' => $label('per_page'), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/Concerns/ValidatesTemplateContent.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/Concerns/ValidatesTemplateContent.php new file mode 100644 index 00000000..d5ef5471 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/Concerns/ValidatesTemplateContent.php @@ -0,0 +1,366 @@ + 필드 경로 → 라벨 + */ + private function contentAttributes(): array + { + $label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}"); + + return [ + 'content' => $label('content'), + 'content.templateName' => $label('template_name'), + 'content.templateMessageType' => $label('message_type'), + 'content.templateEmphasizeType' => $label('emphasize_type'), + 'content.templateContent' => $label('template_content'), + 'content.templatePreviewMessage' => $label('preview_message'), + 'content.categoryCode' => $label('category_code'), + 'content.securityFlag' => $label('security_flag'), + 'content.templateExtra' => $label('extra'), + 'content.templateTitle' => $label('title'), + 'content.templateSubtitle' => $label('subtitle'), + 'content.templateHeader' => $label('header'), + 'content.templateImageName' => $label('image_name'), + 'content.templateImageUrl' => $label('image_url'), + 'content.templateItem' => $label('item'), + 'content.templateItem.list' => $label('item_list'), + 'content.templateItem.list.*.title' => $label('item_title'), + 'content.templateItem.list.*.description' => $label('item_description'), + 'content.templateItem.summary' => $label('summary'), + 'content.templateItem.summary.title' => $label('summary_title'), + 'content.templateItem.summary.description' => $label('summary_description'), + 'content.templateItemHighlight' => $label('highlight'), + 'content.templateItemHighlight.title' => $label('highlight_title'), + 'content.templateItemHighlight.description' => $label('highlight_description'), + 'content.templateItemHighlight.imageUrl' => $label('highlight_image_url'), + 'content.templateRepresentLink' => $label('represent_link'), + 'content.templateRepresentLink.linkMo' => $label('link_mo'), + 'content.templateRepresentLink.linkPc' => $label('link_pc'), + 'content.templateRepresentLink.linkAnd' => $label('link_and'), + 'content.templateRepresentLink.linkIos' => $label('link_ios'), + 'content.buttons' => $label('buttons'), + 'content.buttons.*.name' => $label('button_name'), + 'content.buttons.*.linkType' => $label('button_link_type'), + 'content.buttons.*.linkMo' => $label('link_mo'), + 'content.buttons.*.linkPc' => $label('link_pc'), + 'content.buttons.*.linkAnd' => $label('link_and'), + 'content.buttons.*.linkIos' => $label('link_ios'), + 'content.buttons.*.telNumber' => $label('tel_number'), + 'content.buttons.*.pluginId' => $label('plugin_id'), + 'content.quickReplies' => $label('quick_replies'), + 'content.quickReplies.*.name' => $label('quick_reply_name'), + 'content.quickReplies.*.linkType' => $label('quick_reply_link_type'), + 'content.quickReplies.*.linkMo' => $label('link_mo'), + 'content.quickReplies.*.linkPc' => $label('link_pc'), + 'content.quickReplies.*.linkAnd' => $label('link_and'), + 'content.quickReplies.*.linkIos' => $label('link_ios'), + ]; + } + + /** + * SMS 본문(다국어 맵)의 검증 규칙을 반환합니다 (#597 §14.3). + * + * sms_body 는 로케일별 본문 맵이다. 허용 로케일 판정·길이 검사는 코어 TranslatableField + * 규칙에 위임한다 — 로케일 목록(`app.translatable_locales`)은 활성 언어팩에 따라 부팅마다 + * 달라지는 가변값이고, 그 규칙이 이미 "비활성 언어팩의 기존 번역은 지우지 않는다" 는 + * 판정까지 담고 있다. 여기서 다시 조립하면 같은 판정이 두 벌이 된다. + * + * Store/Update/Delivery 세 경로가 같은 규칙을 써야 한다 — 한 곳만 느슨하면 그 경로가 + * 우회로가 된다. + * + * @return array> 필드 경로 → 규칙 + */ + private function smsBodyRules(): array + { + return [ + 'sms_body' => ['sometimes', 'nullable', 'array', new TranslatableField(maxLength: 2000)], + ]; + } + + /** + * 발송 설정 필드의 표시용 라벨을 반환합니다 (FormRequest::attributes 병합용). + * + * @return array 필드 경로 → 라벨 + */ + private function deliveryAttributes(): array + { + $label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}"); + + return [ + 'notification_type' => $label('notification_type'), + 'alimtalk_enabled' => $label('alimtalk_enabled'), + 'fallback_sms_enabled' => $label('fallback_sms_enabled'), + 'sms_body' => $label('sms_body'), + 'sms_only' => $label('sms_only'), + 'is_active' => $label('is_active'), + ]; + } + + /** + * 유형과 무관한 content 필드를 잘라내 kapi 등록 페이로드를 정돈합니다. + * + * 화면(작성 모달)은 폼 상태 전체를 그대로 전송한다 — 유형 전환 잔여값(강조표기 + * 타이틀·이미지 URL·아이템리스트 등)을 클라이언트가 조건부로 빼는 대신, 서버가 + * 선택된 templateMessageType/templateEmphasizeType 기준으로 무관 필드·빈 값을 + * 제거한다. 저장된 content 가 곧 kapi add/update 페이로드이므로(§3.1) 이 정돈이 + * 등록 시 불필요 필드 거부를 예방한다. prepareForValidation 에서 호출한다. + */ + private function pruneContentPayload(): void + { + $content = $this->input('content'); + if (! is_array($content)) { + return; + } + + $messageType = (string) ($content['templateMessageType'] ?? ''); + $emphasizeType = (string) ($content['templateEmphasizeType'] ?? ''); + + if (! in_array($messageType, ['EX', 'MI'], true)) { + unset($content['templateExtra']); + } + + if ($emphasizeType !== 'TEXT') { + unset($content['templateTitle'], $content['templateSubtitle']); + } + + if ($emphasizeType !== 'IMAGE') { + unset($content['templateImageName'], $content['templateImageUrl']); + } + + if ($emphasizeType !== 'ITEM_LIST') { + unset($content['templateItem'], $content['templateItemHighlight']); + } + + // 빈 선택 값 제거 — 빈 문자열/빈 배열을 kapi 에 보내면 등록이 거부될 수 있다. + foreach (['templatePreviewMessage', 'templateHeader', 'templateExtra', 'templateTitle', 'templateSubtitle', 'templateImageName', 'templateImageUrl'] as $key) { + if (array_key_exists($key, $content) && trim((string) $content[$key]) === '') { + unset($content[$key]); + } + } + + foreach (['buttons', 'quickReplies'] as $group) { + if (array_key_exists($group, $content)) { + $items = is_array($content[$group]) ? array_values(array_filter($content[$group], 'is_array')) : []; + $items = array_map($this->pruneEmptyStrings(...), $items); + + if ($items === []) { + unset($content[$group]); + } else { + $content[$group] = $items; + } + } + } + + if (array_key_exists('templateItemHighlight', $content)) { + $highlight = is_array($content['templateItemHighlight']) + ? $this->pruneEmptyStrings($content['templateItemHighlight']) + : []; + if ($highlight === []) { + unset($content['templateItemHighlight']); + } else { + $content['templateItemHighlight'] = $highlight; + } + } + + if (array_key_exists('templateRepresentLink', $content)) { + $link = is_array($content['templateRepresentLink']) + ? $this->pruneEmptyStrings($content['templateRepresentLink']) + : []; + if ($link === []) { + unset($content['templateRepresentLink']); + } else { + $content['templateRepresentLink'] = $link; + } + } + + if (array_key_exists('templateItem', $content) && is_array($content['templateItem'])) { + $item = $content['templateItem']; + if (isset($item['summary']) && is_array($item['summary'])) { + $summary = $this->pruneEmptyStrings($item['summary']); + if ($summary === []) { + unset($item['summary']); + } else { + $item['summary'] = $summary; + } + } + $content['templateItem'] = $item; + } + + $this->merge(['content' => $content]); + } + + /** + * 연관 배열에서 빈 문자열 값을 제거합니다. + * + * @param array $row 대상 배열 + * @return array 빈 문자열이 제거된 배열 + */ + private function pruneEmptyStrings(array $row): array + { + return array_filter( + $row, + static fn ($value) => ! (is_string($value) && trim($value) === ''), + ); + } + + /** + * content 하위 검증 규칙을 반환합니다. + * + * @return array + */ + private function contentRules(): array + { + return [ + 'content' => ['sometimes', 'nullable', 'array'], + 'content.templateName' => ['required_with:content', 'string', 'max:200'], + 'content.templateMessageType' => ['required_with:content', 'in:BA,EX,AD,MI'], + 'content.templateEmphasizeType' => ['required_with:content', 'in:NONE,TEXT,IMAGE,ITEM_LIST'], + 'content.templateContent' => ['required_with:content', 'string', 'max:1000'], + 'content.templatePreviewMessage' => ['nullable', 'string', 'max:40'], + 'content.categoryCode' => ['required_with:content', 'string', 'max:20'], + 'content.securityFlag' => ['nullable', 'boolean'], + // EX(부가정보형)·MI(복합형)는 부가정보 필수 (A-2) + 'content.templateExtra' => ['nullable', 'string', 'required_if:content.templateMessageType,EX,MI'], + // TEXT(강조표기) 필수쌍 — 길이 수치는 문서 미기재라 미강제(kapi 위임) + 'content.templateTitle' => ['nullable', 'string', 'required_if:content.templateEmphasizeType,TEXT'], + 'content.templateSubtitle' => ['nullable', 'string', 'required_if:content.templateEmphasizeType,TEXT'], + 'content.templateHeader' => ['nullable', 'string', 'max:16'], + // IMAGE(이미지형) 필수쌍 — url 은 업로드 프록시 응답값만 화면이 기입한다 + 'content.templateImageName' => ['nullable', 'string', 'required_if:content.templateEmphasizeType,IMAGE'], + 'content.templateImageUrl' => ['nullable', 'string', 'max:500', 'required_if:content.templateEmphasizeType,IMAGE'], + // ITEM_LIST(아이템리스트) — list 2~10개, 각 title≤6·description≤23 (A-2) + 'content.templateItem' => ['nullable', 'array', 'required_if:content.templateEmphasizeType,ITEM_LIST'], + 'content.templateItem.list' => ['nullable', 'array', 'min:2', 'max:10', 'required_with:content.templateItem'], + 'content.templateItem.list.*.title' => ['required', 'string', 'max:6'], + 'content.templateItem.list.*.description' => ['required', 'string', 'max:23'], + 'content.templateItem.summary' => ['nullable', 'array'], + 'content.templateItem.summary.title' => ['nullable', 'string', 'max:6'], + 'content.templateItem.summary.description' => ['nullable', 'string', 'max:14'], + 'content.templateItemHighlight' => ['nullable', 'array'], + 'content.templateItemHighlight.title' => ['nullable', 'string'], + 'content.templateItemHighlight.description' => ['nullable', 'string'], + 'content.templateItemHighlight.imageUrl' => ['nullable', 'string', 'max:500'], + 'content.templateRepresentLink' => ['nullable', 'array'], + 'content.templateRepresentLink.linkMo' => ['nullable', 'string', 'max:500'], + 'content.templateRepresentLink.linkPc' => ['nullable', 'string', 'max:500'], + 'content.templateRepresentLink.linkAnd' => ['nullable', 'string', 'max:500'], + 'content.templateRepresentLink.linkIos' => ['nullable', 'string', 'max:500'], + 'content.buttons' => ['nullable', 'array', 'max:5'], + 'content.buttons.*.name' => ['required', 'string', 'max:14'], + 'content.buttons.*.linkType' => ['required', 'string', 'in:'.implode(',', self::BUTTON_LINK_TYPES)], + 'content.buttons.*.linkMo' => ['nullable', 'string', 'max:500'], + 'content.buttons.*.linkPc' => ['nullable', 'string', 'max:500'], + 'content.buttons.*.linkAnd' => ['nullable', 'string', 'max:500'], + 'content.buttons.*.linkIos' => ['nullable', 'string', 'max:500'], + 'content.buttons.*.telNumber' => ['nullable', 'string', 'max:20'], + 'content.buttons.*.pluginId' => ['nullable', 'string', 'max:100'], + 'content.quickReplies' => ['nullable', 'array', 'max:10'], + 'content.quickReplies.*.name' => ['required', 'string'], + 'content.quickReplies.*.linkType' => ['required', 'string', 'in:'.implode(',', self::QUICK_REPLY_LINK_TYPES)], + 'content.quickReplies.*.linkMo' => ['nullable', 'string', 'max:500'], + 'content.quickReplies.*.linkPc' => ['nullable', 'string', 'max:500'], + 'content.quickReplies.*.linkAnd' => ['nullable', 'string', 'max:500'], + 'content.quickReplies.*.linkIos' => ['nullable', 'string', 'max:500'], + ]; + } + + /** + * 배열 항목별 조건부 필수(linkType 별 링크 필드)와 하이라이트 썸네일 연동 길이를 검증합니다. + * + * Laravel 룰 문법으로 표현하기 어려운 항목별 조건을 after 훅에서 판정한다: + * - WL → linkMo 필수 / AL → linkAnd+linkIos 둘 다 / TN → telNumber / P1~P3 → pluginId (A-3) + * - itemHighlight 는 썸네일(imageUrl) 유무에 따라 title≤30/21 · description≤19/13 (A-2) + * + * @param Validator $validator 검증기 + */ + private function validateContentConditionals(Validator $validator): void + { + $content = $this->input('content'); + if (! is_array($content)) { + return; + } + + foreach (['buttons', 'quickReplies'] as $group) { + $items = $content[$group] ?? null; + if (! is_array($items)) { + continue; + } + + foreach ($items as $index => $item) { + if (! is_array($item)) { + continue; + } + + $linkType = (string) ($item['linkType'] ?? ''); + $path = "content.{$group}.{$index}"; + + if ($linkType === 'WL' && trim((string) ($item['linkMo'] ?? '')) === '') { + $validator->errors()->add("{$path}.linkMo", __('sirsoft-message_bizppurio::messages.validation.link_mo_required')); + } + + if ($linkType === 'AL') { + if (trim((string) ($item['linkAnd'] ?? '')) === '') { + $validator->errors()->add("{$path}.linkAnd", __('sirsoft-message_bizppurio::messages.validation.link_and_required')); + } + if (trim((string) ($item['linkIos'] ?? '')) === '') { + $validator->errors()->add("{$path}.linkIos", __('sirsoft-message_bizppurio::messages.validation.link_ios_required')); + } + } + + if ($linkType === 'TN' && trim((string) ($item['telNumber'] ?? '')) === '') { + $validator->errors()->add("{$path}.telNumber", __('sirsoft-message_bizppurio::messages.validation.tel_number_required')); + } + + if (in_array($linkType, ['P1', 'P2', 'P3'], true) && trim((string) ($item['pluginId'] ?? '')) === '') { + $validator->errors()->add("{$path}.pluginId", __('sirsoft-message_bizppurio::messages.validation.plugin_id_required')); + } + } + } + + $highlight = $content['templateItemHighlight'] ?? null; + if (is_array($highlight)) { + $hasThumbnail = trim((string) ($highlight['imageUrl'] ?? '')) !== ''; + $titleMax = $hasThumbnail ? 21 : 30; + $descriptionMax = $hasThumbnail ? 13 : 19; + + if (mb_strlen((string) ($highlight['title'] ?? '')) > $titleMax) { + $validator->errors()->add('content.templateItemHighlight.title', __('sirsoft-message_bizppurio::messages.validation.highlight_title_too_long', ['max' => $titleMax])); + } + + if (mb_strlen((string) ($highlight['description'] ?? '')) > $descriptionMax) { + $validator->errors()->add('content.templateItemHighlight.description', __('sirsoft-message_bizppurio::messages.validation.highlight_description_too_long', ['max' => $descriptionMax])); + } + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreBizppurioTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreBizppurioTemplateRequest.php new file mode 100644 index 00000000..24206a14 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreBizppurioTemplateRequest.php @@ -0,0 +1,84 @@ +pruneContentPayload(); + } + + /** + * 검증 규칙을 반환합니다. + * + * @return array + */ + public function rules(): array + { + return array_merge([ + 'notification_type' => [ + 'required', + 'string', + 'max:100', + Rule::exists(NotificationDefinition::class, 'type'), + Rule::unique(BizppurioTemplate::class, 'notification_type'), + ], + 'alimtalk_enabled' => ['sometimes', 'boolean'], + 'fallback_sms_enabled' => ['sometimes', 'boolean'], + 'sms_only' => ['sometimes', 'boolean'], + 'is_active' => ['sometimes', 'boolean'], + ], $this->smsBodyRules(), $this->contentRules()); + } + + /** + * 항목별 조건부 필수(linkType 별 링크 필드 등)를 after 훅으로 검증합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(fn (Validator $v) => $this->validateContentConditionals($v)); + } + + /** + * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다. + * + * @return array 필드 경로 → 라벨 + */ + public function attributes(): array + { + return array_merge($this->deliveryAttributes(), $this->contentAttributes()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php deleted file mode 100644 index c9cfd3a7..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php +++ /dev/null @@ -1,41 +0,0 @@ - - */ - public function rules(): array - { - return [ - 'notification_type' => ['required', 'string', 'max:100'], - 'template_code' => ['nullable', 'string', 'max:50'], - 'template_name' => ['nullable', 'string', 'max:255'], - 'fallback_sms_enabled' => ['sometimes', 'boolean'], - ]; - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioDeliveryRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioDeliveryRequest.php new file mode 100644 index 00000000..15ee2ece --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioDeliveryRequest.php @@ -0,0 +1,89 @@ +merge([ + 'notification_type' => (string) $this->route('notificationType'), + ]); + } + + /** + * 검증 규칙을 반환합니다. + * + * @return array + */ + public function rules(): array + { + return [ + 'notification_type' => [ + 'required', + 'string', + 'max:100', + Rule::exists(NotificationDefinition::class, 'type'), + ], + 'alimtalk_enabled' => ['sometimes', 'boolean'], + 'fallback_sms_enabled' => ['sometimes', 'boolean'], + 'sms_only' => ['sometimes', 'boolean'], + 'is_active' => ['sometimes', 'boolean'], + ] + $this->smsBodyRules(); + } + + /** + * 저장할 발송 설정 필드만 반환합니다 (notification_type 제외). + * + * @return array + */ + public function deliveryData(): array + { + return collect($this->validated()) + ->only(['alimtalk_enabled', 'fallback_sms_enabled', 'sms_body', 'sms_only', 'is_active']) + ->all(); + } + + /** + * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다. + * + * @return array 필드 경로 → 라벨 + */ + public function attributes(): array + { + // 공용 deliveryAttributes() 를 그대로 쓴다 — 인라인으로 다시 나열하면 이 클래스만 + // notification_type 라벨을 빠뜨린 채 그 필드를 검증하게 된다(실제로 그랬다). + return $this->deliveryAttributes(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioTemplateRequest.php new file mode 100644 index 00000000..32154c77 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioTemplateRequest.php @@ -0,0 +1,73 @@ +pruneContentPayload(); + } + + /** + * 검증 규칙을 반환합니다. + * + * @return array + */ + public function rules(): array + { + return array_merge([ + 'alimtalk_enabled' => ['sometimes', 'boolean'], + 'fallback_sms_enabled' => ['sometimes', 'boolean'], + 'sms_only' => ['sometimes', 'boolean'], + 'is_active' => ['sometimes', 'boolean'], + ], $this->smsBodyRules(), $this->contentRules()); + } + + /** + * 항목별 조건부 필수(linkType 별 링크 필드 등)를 after 훅으로 검증합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(fn (Validator $v) => $this->validateContentConditionals($v)); + } + + /** + * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다. + * + * @return array 필드 경로 → 라벨 + */ + public function attributes(): array + { + return array_merge($this->deliveryAttributes(), $this->contentAttributes()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php index 3754868a..e2f49828 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -25,10 +25,11 @@ use App\Services\SettingsService; * ChannelManager 에 등록되어야 발화하므로, 드라이버 등록은 ServiceProvider::boot() 가 * 담당한다(이 리스너는 채널 "노출·판정"만 책임진다). * - * 알림톡 탭은 코어 기본 목록을 그대로 사용한다(Phase 6 재설계, 계획서 ⚑⚑ 블록 A). - * 연결 템플릿·SMS 대체 등 알림톡 전용 설정은 코어 목록 행 하단에 overlay 로 얹은 - * [연결/변경] 버튼 → 우리 연결 모달 → 우리 API(notification-bindings)로 직접 저장하므로, - * 이 리스너는 코어 목록을 숨기는 별도 플래그를 두지 않는다. + * 화면 노출은 '비즈뿌리오' 통합 탭 방식이다(#597 §3.3). sms 채널 메타의 hidden_tab 으로 + * 개별 탭만 숨기고(채널 토글 카드·발송 축은 유지), alimtalk 메타의 tab_channels · + * tab_label_key 로 통합 탭의 노출 조건·라벨을 선언한다. 알림톡 템플릿 작성·검수·대체 SMS + * 설정은 코어 목록 행 하단 overlay(row_footer)가 담당하며 저장은 우리 템플릿 API + * (admin/templates)로 직접 수행한다. */ class RegisterNotificationChannelsListener implements HookListenerInterface { @@ -265,6 +266,13 @@ class RegisterNotificationChannelsListener implements HookListenerInterface /** * sms·alimtalk 채널 메타 정의를 반환합니다. * + * '비즈뿌리오' 통합 탭 메타(#597 §3.3 — 범용 키, 확장명 하드코딩 없음): + * - sms.hidden_tab: 탭만 숨긴다(채널 토글 카드·발송 축은 그대로). + * - alimtalk.tab_channels: 이 목록 중 하나라도 활성 저장이면 탭을 노출한다. + * - alimtalk.tab_label_key: 탭 라벨용 프론트 lang 키($t 해석 — 카드 라벨(name)과 분리). + * 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다 + * (is_test_mode 와 동일 경로). + * * @return array> */ private function channelMetas(): array @@ -278,6 +286,7 @@ class RegisterNotificationChannelsListener implements HookListenerInterface 'source' => self::PLUGIN_IDENTIFIER, 'source_label_key' => self::LANG.'.channels.source_label', 'allow_guest' => true, + 'hidden_tab' => true, ], [ 'id' => 'alimtalk', @@ -287,6 +296,8 @@ class RegisterNotificationChannelsListener implements HookListenerInterface 'source' => self::PLUGIN_IDENTIFIER, 'source_label_key' => self::LANG.'.channels.source_label', 'allow_guest' => true, + 'tab_channels' => ['sms', 'alimtalk'], + 'tab_label_key' => self::PLUGIN_IDENTIFIER.'.channels.bizppurio_tab', ], ]; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php index f0455c70..01adcf9e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php @@ -29,6 +29,13 @@ use App\Contracts\Extension\HookListenerInterface; * 증강 대상 = 회원(사용자) 대상 알림(결정 E). 관리자 전용(수신자가 role:admin 뿐인) 알림은 * 문자/알림톡 대상이 아니므로 건너뛴다. 기본 body 는 그 알림의 database 채널 body(짧은 평문)를 * 재활용하고, 없으면 mail body 의 HTML 을 제거해 만든다. + * + * #597 이후에도 이 시딩은 존치한다. 발송 본문의 SSoT 는 bizppurio_templates 로 옮겨졌지만 + * (SmsChannelDriver 는 sms_body, AlimtalkChannelDriver 는 approved_content 를 읽는다), + * 코어·게시판·이커머스의 알림 설정 화면이 여전히 이 template 행을 그리기 때문이다 + * (채널 서브탭의 행 제목·수신자 표시, 행이 없으면 "no_template_for_channel" 문구). + * 즉 이 행들은 화면 표시 전용이며 발송 경로는 소비하지 않는다 — 발송이 안 쓴다는 이유로 + * 시딩을 지우면 알림 설정 화면의 비즈뿌리오 탭에서 행 정보가 사라진다. */ class SeedChannelTemplatesListener implements HookListenerInterface { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php deleted file mode 100644 index 9ab0a20f..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php +++ /dev/null @@ -1,87 +0,0 @@ - - */ - protected $fillable = [ - 'notification_type', - 'channel', - 'template_code', - 'template_name', - 'fallback_sms_enabled', - 'is_active', - ]; - - /** - * 속성 캐스팅 정의 - * - * @return array - */ - protected function casts(): array - { - return [ - 'channel' => DispatchChannel::class, - 'fallback_sms_enabled' => 'boolean', - 'is_active' => 'boolean', - ]; - } - - /** - * 활성 연동만 조회하는 스코프. - * - * @param Builder $query - * @return Builder - */ - public function scopeActive(Builder $query): Builder - { - return $query->where('is_active', true); - } - - /** - * 알림 유형으로 조회하는 스코프 (발송 시 template_code 해석). - * - * @param Builder $query - * @param string $notificationType 코어 notification_definitions.type - * @return Builder - */ - public function scopeByNotificationType(Builder $query, string $notificationType): Builder - { - return $query->where('notification_type', $notificationType); - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioTemplate.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioTemplate.php new file mode 100644 index 00000000..92e76913 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioTemplate.php @@ -0,0 +1,168 @@ + + */ + protected $fillable = [ + 'notification_type', + 'alimtalk_enabled', + 'template_code', + 'sender_key', + 'content', + 'approved_content', + 'status', + 'inspection_detail', + 'requested_at', + 'approved_at', + 'last_synced_at', + 'fallback_sms_enabled', + 'sms_body', + 'sms_only', + 'is_active', + ]; + + /** + * 속성 캐스팅 정의 + * + * @return array + */ + protected function casts(): array + { + return [ + 'alimtalk_enabled' => 'boolean', + 'content' => 'array', + 'approved_content' => 'array', + 'status' => BizppurioTemplateStatus::class, + 'inspection_detail' => 'array', + 'sms_body' => 'array', + 'requested_at' => 'datetime', + 'approved_at' => 'datetime', + 'last_synced_at' => 'datetime', + 'fallback_sms_enabled' => 'boolean', + 'sms_only' => 'boolean', + 'is_active' => 'boolean', + ]; + } + + /** + * 알림 유형으로 조회하는 스코프 (발송 시 행 해석). + * + * @param Builder $query + * @param string $notificationType 코어 notification_definitions.type + * @return Builder + */ + public function scopeByNotificationType(Builder $query, string $notificationType): Builder + { + return $query->where('notification_type', $notificationType); + } + + /** + * 수신자 로케일의 SMS 본문을 반환합니다 (#597 §14.3). + * + * sms_body 는 다국어 맵이다. 코어 NotificationContentBehavior::getLocalizedBody() 와 + * 동일한 해석 규칙을 쓴다 — 해당 로케일이 없으면 fallback_locale, 그것도 없으면 빈 문자열. + * 알림톡 content 가 단일 언어인 것은 카카오 제약 때문이며 SMS 에는 그 제약이 없다. + * + * @param string|null $locale 수신자 로케일 (null 이면 현재 앱 로케일) + * @return string 로케일에 맞는 본문 (없으면 빈 문자열) + */ + public function getLocalizedSmsBody(?string $locale = null): string + { + $locale = $locale ?? app()->getLocale(); + $body = is_array($this->sms_body) ? $this->sms_body : []; + + $resolved = $body[$locale] ?? $body[config('app.fallback_locale', 'ko')] ?? ''; + + return is_string($resolved) ? $resolved : ''; + } + + /** + * SMS 본문이 어느 로케일에든 채워져 있는지 판정합니다 (발송 게이트용). + * + * 게이트는 "이 알림에 문자 본문이 설정되어 있는가" 를 묻는다. 특정 수신자의 로케일에 + * 본문이 없더라도 fallback 으로 발송되므로, 한 로케일이라도 비어있지 않으면 true 다. + * 로케일별 공백 판정은 발송 시점에 getLocalizedSmsBody() 로 다시 한다. + * + * @return bool 하나 이상의 로케일에 본문이 있으면 true + */ + public function hasSmsBody(): bool + { + foreach (is_array($this->sms_body) ? $this->sms_body : [] as $text) { + if (is_string($text) && trim($text) !== '') { + return true; + } + } + + return false; + } + + /** + * 알림톡 발송 가능 여부를 판정합니다 (발송 게이트 §3.5). + * + * alimtalk_enabled + is_active + 승인 상태 + 승인 스냅샷 존재를 모두 요구하고, + * sms_only("SMS 단독")가 켜져 있으면 차단한다. + * 승인 취소로 status 가 draft 로 복귀하면 approved_content 가 남아 있어도 즉시 차단된다. + * + * sms_only 는 이 알림에서 알림톡을 쓰지 않겠다는 운영자의 명시 선언이다(§3.5). 화면 라벨이 + * "SMS 단독" 인 이상 이 판정에 반영되지 않으면 체크가 아무것도 바꾸지 못한다. 반대로 SMS + * 발송은 sms_only 와 무관하게 sms_body 기준으로만 판정한다(SmsChannelDriver) — sms_only 는 + * 알림톡을 끄는 플래그지 SMS 를 켜는 플래그가 아니다. + * + * @return bool 알림톡 발송 가능하면 true + */ + public function isAlimtalkSendable(): bool + { + return $this->alimtalk_enabled + && $this->is_active + && ! $this->sms_only + && $this->status->isApproved() + && is_array($this->approved_content) + && $this->approved_content !== []; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php index 64dd1114..3dc403c8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -6,14 +6,14 @@ namespace Plugins\Sirsoft\MessageBizppurio\Providers; use App\Extension\BasePluginServiceProvider; use Illuminate\Notifications\ChannelManager; +use Plugins\Sirsoft\MessageBizppurio\Console\SyncTemplateStatusCommand; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; -use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository; +use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioTemplateRepository; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; -use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService; use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext; -use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver; use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService; @@ -31,13 +31,14 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider /** * Repository 인터페이스 ↔ 구현체 매핑. * - * Phase 4 에서 발송 이력·이벤트 연동 Repository 를 등록한다. + * - BizppurioDispatchRepository: 발송 이력 + * - BizppurioTemplateRepository: 알림 템플릿 라이프사이클(#597 — bindings 대체) * * @var array */ protected array $repositories = [ BizppurioDispatchRepositoryInterface::class => BizppurioDispatchRepository::class, - BizppurioNotificationBindingRepositoryInterface::class => BizppurioNotificationBindingRepository::class, + BizppurioTemplateRepositoryInterface::class => BizppurioTemplateRepository::class, ]; /** @@ -51,7 +52,6 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider protected array $cacheServices = [ BizppurioTokenService::class, WebhookReportService::class, - KakaoTemplateContentResolver::class, ]; /** @@ -60,12 +60,21 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * DispatchLinkContext 는 한 발송 사이클(HTTP 요청/큐 잡) 안에서 refkey↔코어 로그 연결을 * 잇기 위해 상태를 공유해야 하므로 scoped(요청 단위 싱글턴)로 바인딩한다. 채널 드라이버와 * LinkNotificationLogListener 가 같은 인스턴스를 주입받아 refkey 를 주고받는다(A-2). + * + * 콘솔 커맨드(bizppurio:sync-template-status)는 plugin.php::getSchedules() 가 30분 + * 주기로 스케줄한다(#597 §3.4). */ public function register(): void { parent::register(); $this->app->scoped(DispatchLinkContext::class); + + if ($this->app->runningInConsole()) { + $this->commands([ + SyncTemplateStatusCommand::class, + ]); + } } /** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php deleted file mode 100644 index ad1deff0..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php +++ /dev/null @@ -1,79 +0,0 @@ -active() - ->byNotificationType($notificationType) - ->where('channel', $channel) - ->first(); - } - - /** - * 채널의 모든 연동을 조회합니다. - * - * @param string $channel 채널 - * @return Collection - */ - public function allByChannel(string $channel = 'alimtalk'): Collection - { - return BizppurioNotificationBinding::query() - ->where('channel', $channel) - ->get(); - } - - /** - * 알림 유형+채널 연동을 생성하거나 갱신합니다. - * - * @param string $notificationType 코어 notification_definitions.type - * @param string $channel 채널 - * @param array $data 갱신 데이터 - * @return BizppurioNotificationBinding 저장된 연동 - */ - public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding - { - $binding = BizppurioNotificationBinding::firstOrNew([ - 'notification_type' => $notificationType, - 'channel' => $channel, - ]); - - $binding->fill($data)->save(); - - return $binding; - } - - /** - * 알림 유형+채널 연동을 삭제합니다. - * - * @param string $notificationType 코어 notification_definitions.type - * @param string $channel 채널 - * @return void - */ - public function delete(string $notificationType, string $channel = 'alimtalk'): void - { - BizppurioNotificationBinding::query() - ->byNotificationType($notificationType) - ->where('channel', $channel) - ->delete(); - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioTemplateRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioTemplateRepository.php new file mode 100644 index 00000000..75a56036 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioTemplateRepository.php @@ -0,0 +1,240 @@ + + */ + private const LIST_COLUMNS = [ + 'id', + 'notification_type', + 'alimtalk_enabled', + 'template_code', + 'status', + 'requested_at', + 'approved_at', + 'last_synced_at', + 'fallback_sms_enabled', + 'sms_only', + 'is_active', + ]; + + /** + * PK 로 템플릿을 조회합니다. + * + * @param int $id 템플릿 PK + * @return BizppurioTemplate|null 매칭 행 또는 null + */ + public function find(int $id): ?BizppurioTemplate + { + return BizppurioTemplate::query()->find($id); + } + + /** + * 알림 유형으로 템플릿을 조회합니다. + * + * @param string $notificationType 코어 notification_definitions.type + * @return BizppurioTemplate|null 매칭 행 또는 null + */ + public function findByType(string $notificationType): ?BizppurioTemplate + { + return BizppurioTemplate::query() + ->byNotificationType($notificationType) + ->first(); + } + + /** + * 관리 화면 목록을 페이지네이션 조회합니다 (알림 정의 라벨·소속 조인). + * + * 알림 정의(1:1, type unique)와 LEFT JOIN 해 목록이 그리는 라벨(definition_name)과 + * 소속(definition_source)을 함께 싣는다. 1:1 조인이므로 행 부풀림이 없다. 정렬은 + * 비고유 컬럼(updated_at) 뒤에 기본키를 덧붙여 전순서를 보장한다. + * + * @param array $filters status / search + * @param int $page 페이지 번호 + * @param int $perPage 페이지 크기 + * @return LengthAwarePaginator 페이지네이션 결과 + */ + public function paginateWithDefinitions(array $filters, int $page, int $perPage): LengthAwarePaginator + { + $templates = (new BizppurioTemplate)->getTable(); + $definitions = (new NotificationDefinition)->getTable(); + + $query = BizppurioTemplate::query() + ->leftJoin($definitions.' as nd', 'nd.type', '=', $templates.'.notification_type') + ->select(array_map( + static fn (string $column): string => $templates.'.'.$column, + self::LIST_COLUMNS, + )) + ->addSelect([ + 'nd.name as definition_name', + 'nd.variables as definition_variables', + 'nd.extension_type as definition_extension_type', + 'nd.extension_identifier as definition_extension_identifier', + ]) + // 목록 배지가 "미작성 vs 작성중"을 구분하도록 content 존재 플래그만 계산해 싣는다. + // raw 안의 테이블명에는 빌더가 프리픽스를 붙여주지 않으므로 직접 부착한다. + ->selectRaw('('.DB::getTablePrefix().$templates.'.content is not null) as has_content'); + + if (! empty($filters['status'])) { + $query->where($templates.'.status', (string) $filters['status']); + } + + if (! empty($filters['search'])) { + $term = '%'.addcslashes((string) $filters['search'], '%_\\').'%'; + // nd.name 은 translatable JSON 컬럼 — raw LIKE 는 유니코드 이스케이프 저장값과 + // 비교되어 비ASCII(한글 등) 검색이 항상 0건이 된다. 운영자가 화면에서 보는 + // 표시명으로 검색되도록 로케일별 값을 추출해 비교한다 (선례: + // sirsoft-ecommerce ProductInquiryRepository 의 product_name_snapshot 검색). + // 로케일 키는 config 유래(사용자 입력 아님)이고 검색어는 바인딩으로 전달된다. + $locales = config('app.translatable_locales', ['ko', 'en']); + // raw 안의 조인 별칭에는 빌더가 프리픽스를 붙여주지 않으므로 직접 부착한다 + // (조인 선언의 'as nd' 는 실제로 '{prefix}nd' 별칭이 된다). + $ndName = DB::getTablePrefix().'nd.name'; + $query->where(function ($q) use ($templates, $term, $locales, $ndName) { + $q->where($templates.'.notification_type', 'like', $term); + foreach ($locales as $locale) { + $q->orWhereRaw( + "JSON_UNQUOTE(JSON_EXTRACT({$ndName}, '$.{$locale}')) LIKE ?", + [$term], + ); + } + }); + } + + return $query + ->orderByDesc($templates.'.updated_at') + // audit:allow repository-pagination-key-tiebreak reason: 아래 기본키 tiebreak 이 전순서를 보장한다 — + // 조인 모호성 회피를 위해 테이블 한정 식별자(변수 결합)를 쓰므로 룰 정규식이 인식하지 못할 뿐이다 + ->orderByDesc($templates.'.id') + // audit:allow repository-paginate-column-pruning reason: 위 select()/addSelect() 가 목록 소비 컬럼만 + // 명시 조회한다 — paginate 인자의 컬럼 기본값은 기존 select 를 덮지 않는다 + ->paginate($perPage, page: $page); + } + + /** + * 알림 설정 탭 행 표시용 전체 요약을 조회합니다. + * + * 행 수는 알림 정의 수에 묶인 설정성 테이블이라 전량 조회한다(페이지네이션 규정의 + * 설정성 테이블 예외). 요약에 불필요한 대형 JSON(content/approved_content)은 제외하되, + * 반려 사유(inspection_detail)는 행 UI 의 [사유 보기]가 소비하므로 포함한다. + * + * @return Collection 요약 컬럼만 실린 행 컬렉션 + */ + public function allSummaries(): Collection + { + return BizppurioTemplate::query() + ->select(array_merge(self::LIST_COLUMNS, ['inspection_detail', 'sms_body'])) + // content 원본은 요약에 불필요하지만 "작성 여부"(미작성 vs 작성중 배지 분기)는 + // 행 UI 가 소비하므로 존재 플래그만 계산해 싣는다. + ->selectRaw('(content is not null) as has_content') + ->orderBy('notification_type') + ->get(); + } + + /** + * 특정 상태의 행 전체를 조회합니다 (동기화 커맨드 대상 선별). + * + * @param string $status BizppurioTemplateStatus value + * @return Collection + */ + public function allByStatus(string $status): Collection + { + return BizppurioTemplate::query() + ->where('status', $status) + ->orderBy('id') + ->get(); + } + + /** + * 템플릿 행을 생성합니다. + * + * @param array $data 생성 데이터 + * @return BizppurioTemplate 생성된 행 + */ + public function create(array $data): BizppurioTemplate + { + return BizppurioTemplate::create($data); + } + + /** + * 템플릿 행을 갱신합니다. + * + * @param BizppurioTemplate $template 대상 행 + * @param array $data 갱신 데이터 + * @return BizppurioTemplate 갱신된 행 + */ + public function update(BizppurioTemplate $template, array $data): BizppurioTemplate + { + $template->fill($data)->save(); + + return $template; + } + + /** + * 템플릿 행을 삭제합니다. + * + * @param BizppurioTemplate $template 대상 행 + */ + public function delete(BizppurioTemplate $template): void + { + $template->delete(); + } + + /** + * 특정 템플릿 코드가 이미 사용 중인지 확인합니다 (자체 채번 충돌 방지). + * + * @param string $templateCode 검사할 코드 + * @return bool 사용 중이면 true + */ + public function templateCodeExists(string $templateCode): bool + { + return BizppurioTemplate::query() + ->where('template_code', $templateCode) + ->exists(); + } + + /** + * 검수 신청을 위해 행을 원자적으로 선점합니다. + * + * 신청 가능 상태(draft/rejected)를 WHERE 조건에 포함한 조건부 UPDATE 라 + * 같은 행을 동시에 든 요청 중 정확히 하나만 1행 갱신을 받는다. + * + * @param BizppurioTemplate $template 대상 행 + * @return bool 선점 성공 여부 + */ + public function claimForInspection(BizppurioTemplate $template): bool + { + $claimed = BizppurioTemplate::query() + ->whereKey($template->id) + ->whereIn('status', [ + BizppurioTemplateStatus::Draft->value, + BizppurioTemplateStatus::Rejected->value, + ]) + ->update(['status' => BizppurioTemplateStatus::Requested->value]) === 1; + + if ($claimed) { + $template->refresh(); + } + + return $claimed; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php deleted file mode 100644 index e48b7bf8..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php +++ /dev/null @@ -1,53 +0,0 @@ - - */ - public function allByChannel(string $channel = 'alimtalk'): Collection; - - /** - * 알림 유형+채널 연동을 생성하거나 갱신합니다 (탭 편집 모달 저장). - * - * @param string $notificationType 코어 notification_definitions.type - * @param string $channel 채널 - * @param array $data template_code / template_name / fallback_sms_enabled / is_active - * @return BizppurioNotificationBinding 저장된 연동 - */ - public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding; - - /** - * 알림 유형+채널 연동을 삭제합니다 (연동 해제). - * - * @param string $notificationType 코어 notification_definitions.type - * @param string $channel 채널 - * @return void - */ - public function delete(string $notificationType, string $channel = 'alimtalk'): void; -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioTemplateRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioTemplateRepositoryInterface.php new file mode 100644 index 00000000..f92512a2 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioTemplateRepositoryInterface.php @@ -0,0 +1,106 @@ + $filters status / search + * @param int $page 페이지 번호 + * @param int $perPage 페이지 크기 + * @return LengthAwarePaginator 페이지네이션 결과 + */ + public function paginateWithDefinitions(array $filters, int $page, int $perPage): LengthAwarePaginator; + + /** + * 알림 설정 탭 행 표시용 전체 요약을 조회합니다 (notification_type 키 맵 소스). + * + * 행 수는 운영자가 등록한 알림 정의 수에 묶인 설정성 테이블이므로 상한 없이 전량 + * 조회한다(페이지네이션 규정의 설정성 테이블 예외). content 등 대형 JSON 컬럼은 + * 요약에 필요 없으므로 제외한다(컬럼 프루닝). + * + * @return Collection 요약 컬럼만 실린 행 컬렉션 + */ + public function allSummaries(): Collection; + + /** + * 특정 상태의 행 전체를 조회합니다 (동기화 커맨드 대상 선별). + * + * 대상 행 수는 알림 정의 수에 묶인다(설정성 테이블 예외 — 상한 불요). + * + * @param string $status BizppurioTemplateStatus value + * @return Collection + */ + public function allByStatus(string $status): Collection; + + /** + * 템플릿 행을 생성합니다. + * + * @param array $data 생성 데이터 + * @return BizppurioTemplate 생성된 행 + */ + public function create(array $data): BizppurioTemplate; + + /** + * 템플릿 행을 갱신합니다. + * + * @param BizppurioTemplate $template 대상 행 + * @param array $data 갱신 데이터 + * @return BizppurioTemplate 갱신된 행 + */ + public function update(BizppurioTemplate $template, array $data): BizppurioTemplate; + + /** + * 템플릿 행을 삭제합니다. + * + * @param BizppurioTemplate $template 대상 행 + */ + public function delete(BizppurioTemplate $template): void; + + /** + * 특정 템플릿 코드가 이미 사용 중인지 확인합니다 (자체 채번 충돌 방지). + * + * @param string $templateCode 검사할 코드 + * @return bool 사용 중이면 true + */ + public function templateCodeExists(string $templateCode): bool; + + /** + * 검수 신청을 위해 행을 원자적으로 선점합니다. + * + * `status IN (draft, rejected)` 인 행만 조건부 UPDATE 로 requested 로 전이한다. + * 더블 클릭·이중 탭·복수 관리자처럼 같은 draft 상태를 동시에 든 요청 중 + * 정확히 하나만 true 를 받는다 — 경합의 패자는 kapi 에 도달하기 전에 걸러진다. + * + * @param BizppurioTemplate $template 대상 행 + * @return bool 선점 성공(이 요청이 신청을 진행) 여부 + */ + public function claimForInspection(BizppurioTemplate $template): bool; +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php index 1ec99c73..682a92e0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php @@ -8,7 +8,6 @@ use App\Models\User; use App\Notifications\BaseNotification; use App\Notifications\GenericNotification; use App\Services\NotificationDefinitionService; -use App\Services\NotificationTemplateService; use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Str; @@ -17,40 +16,32 @@ use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchSource; use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; -use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface; /** - * 코어 알림 시스템의 alimtalk 채널 드라이버 (계획서 §6-2·Phase 6). + * 코어 알림 시스템의 alimtalk 채널 드라이버 (#597 §3.5). * * ChannelManager::extend('alimtalk', …)(ServiceProvider::boot)로 등록되어, 코어가 - * `via()` 에서 'alimtalk' 채널을 선택하면 이 드라이버의 send() 가 호출된다. Phase 3 까지는 - * no-op 스텁이 등록돼 있었고(크래시 방지), 이 드라이버가 그 스텁을 실발송으로 교체한다. + * `via()` 에서 'alimtalk' 채널을 선택하면 이 드라이버의 send() 가 호출된다. * - * SmsChannelDriver 와 흐름은 같으나 핵심 차이는 "발송 본문·요소의 출처"다: - * - SMS: 코어 알림 템플릿(sms 채널) 본문을 그대로 발송한다. - * - 알림톡: 관리자가 알림톡 탭에서 연결한 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을 - * 카카오 상세조회로 가져와 발송한다(B안). 비즈뿌리오 발송 API 는 완성본을 요구하므로 - * templatecode 만으로는 안 되고, 카카오에 등록된 templateContent·buttons·quickReplies· - * title·header·item·itemHighlight·representLink 를 발송 형식으로 변환해 채운다. - * 연결(binding)이 없으면 발송하지 않는다(알림톡은 임의 본문 발송 불가 — 승인 템플릿 필수). + * 발송 본문·요소의 출처는 bizppurio_templates 의 승인 스냅샷(approved_content)이다 — + * 운영자가 시스템에서 작성해 검수 신청하고 승인된 그 내용 그대로다. 발송 시점의 + * 카카오 실시간 조회는 없다(DB 가 유일한 판정 근거, 이슈 #597 요구). + * + * 발송 게이트: 행 존재 + alimtalk_enabled + is_active + status=approved + + * approved_content 존재(BizppurioTemplate::isAlimtalkSendable). 승인 취소로 status 가 + * draft 로 복귀하면 스냅샷이 남아 있어도 즉시 차단된다. * * 처리 흐름: - * 1. 알림 유형(type)으로 활성 binding 조회. 없으면 NotificationSendSkippedException. - * 2. 카카오 승인 템플릿 내용 조회(KakaoTemplateContentResolver, 캐시). 실패 시 동일 예외. - * 3. 전화번호 해석(회원=mobile, 비회원=data 의 _recipient_phone — SmsChannelDriver 와 동일 계약). - * 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환(AlimtalkPayloadMapper). 본문 비면 동일 예외. - * 5. refkey 생성 → payload 조립(버튼·요소는 extra, 대체발송 ON 시 resend/recontent 병합) → - * 이력 pending → SendMessageJob 위임. - * - * 1~4 단계는 비즈뿌리오 API 호출 자체를 시도하지 못하는 사전 조건 미비 상태라 - * NotificationSendSkippedException 을 던진다. 코어 NotificationDispatcher 의 - * catch(\Exception)가 이를 channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 - * "실패"로 정확히 기록되게 한다(조용히 return 하면 코어가 "정상 처리 완료"로 오인해 성공으로 - * 기록하는 문제 — 이슈 #28). 이 단계에선 알림톡 발송 자체가 일어나지 않으므로 SMS 대체발송도 - * 트리거되지 않는다 — SMS 대체는 알림톡 payload 에 resend 로 병합되어, 알림톡이 접수된 뒤 - * 비즈뿌리오 측 발송 실패(수신 거부·미가입 등)에만 작동한다(fallback_sms_enabled ON 시). - * 발송된 알림톡의 개별 실패는 비즈뿌리오 결과코드로 webhook·Job 이 이력에 기록한다(Phase 4). + * 1. 알림 유형(type)으로 템플릿 행 조회 → 발송 게이트 판정. 불충족 시 + * NotificationSendSkippedException(코어가 실패로 기록 — 이슈 #28 계약 유지). + * 2. 전화번호 해석(회원=mobile, 비회원=data 의 _recipient_phone — SmsChannelDriver 와 동일 계약). + * 3. 승인 스냅샷 → 발송 형식 변환 + 변수(#{var}) 치환(AlimtalkPayloadMapper — 등록 + * 페이로드와 kapi 상세의 필드명이 동일해 스냅샷을 그대로 공급한다). + * 4. refkey 생성 → payload 조립(버튼·요소는 extra, fallback_sms_enabled ON 시 + * resend/recontent 병합 — 대체 본문은 행의 sms_body) → 이력 pending → SendMessageJob 위임. */ class AlimtalkChannelDriver { @@ -61,24 +52,20 @@ class AlimtalkChannelDriver public const RECIPIENT_PHONE_KEY = '_recipient_phone'; /** - * @param NotificationTemplateService $templateService SMS 대체발송 본문(코어 alimtalk 템플릿) resolve * @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지용) - * @param BizppurioNotificationBindingRepositoryInterface $bindings 이벤트↔템플릿 연결 조회 + * @param BizppurioTemplateRepositoryInterface $templates 알림 템플릿 행 조회(발송 게이트·본문 소스) * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화 * @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2) - * @param KakaoTemplateContentResolver $kakaoContent 카카오 승인 템플릿 내용 조회·캐시(B안) - * @param AlimtalkPayloadMapper $payloadMapper 카카오 내용 → 발송 형식 변환·치환(B안) + * @param AlimtalkPayloadMapper $payloadMapper 승인 스냅샷 → 발송 형식 변환·치환 * @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용) */ public function __construct( - private readonly NotificationTemplateService $templateService, private readonly NotificationDefinitionService $definitionService, - private readonly BizppurioNotificationBindingRepositoryInterface $bindings, + private readonly BizppurioTemplateRepositoryInterface $templates, private readonly MessagePayloadBuilder $payloadBuilder, private readonly BizppurioDispatchRepositoryInterface $dispatches, private readonly DispatchLinkContext $linkContext, - private readonly KakaoTemplateContentResolver $kakaoContent, private readonly AlimtalkPayloadMapper $payloadMapper, private readonly PluginSettingsService $pluginSettings, ) {} @@ -119,27 +106,17 @@ class AlimtalkChannelDriver $type = $notification->getType(); - // 1. 이벤트↔알림톡 템플릿 연결 조회 (미연결/비활성이면 알림톡 미발송) + // 1. 템플릿 행 조회 + 발송 게이트 판정 (미승인·비활성·행 없음 → skip). // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를 // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다. - $binding = $this->bindings->findActive($type, DispatchChannel::Alimtalk->value); - if ($binding === null) { + $template = $this->templates->findByType($type); + if ($template === null || ! $template->isAlimtalkSendable()) { throw new NotificationSendSkippedException( - __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_binding_missing', ['type' => $this->resolveTypeLabel($type)]) + __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_template_not_approved', ['type' => $this->resolveTypeLabel($type)]) ); } - // 2. 카카오 승인 템플릿 내용(본문·버튼·요소) 조회 — 발송 API 는 완성본을 요구하므로, - // templatecode 만으로는 안 되고 카카오에 등록된 실제 내용을 가져와 채운다(B안). - // 조회 실패(고아·장애·rate limit)면 알림톡 본문 소스가 없으므로 skip 한다. - $kakaoContent = $this->kakaoContent->resolve($binding->template_code); - if ($kakaoContent === null) { - throw new NotificationSendSkippedException( - __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_kakao_content_unavailable', ['type' => $this->resolveTypeLabel($type)]) - ); - } - - // 3. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) + // 2. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) $to = $this->resolvePhone($notifiable, $notification->getData()); if ($to === null) { throw new NotificationSendSkippedException( @@ -147,8 +124,8 @@ class AlimtalkChannelDriver ); } - // 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip. - $mapped = $this->payloadMapper->map($kakaoContent, $notification->getData()); + // 3. 승인 스냅샷 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip. + $mapped = $this->payloadMapper->map((array) $template->approved_content, $notification->getData()); $message = (string) ($mapped['message'] ?? ''); if (trim($message) === '') { throw new NotificationSendSkippedException( @@ -156,21 +133,25 @@ class AlimtalkChannelDriver ); } - // 5. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra 로 전달, 대체발송 ON 시 SMS resend 병합) + // 4. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra, 대체발송 ON 시 SMS resend 병합) $refkey = $this->generateRefkey(); $payload = $this->payloadBuilder->buildAlimtalk( $to, - $binding->template_code, + (string) $template->template_code, $message, $refkey, (array) ($mapped['extra'] ?? []), ); - if ($binding->fallback_sms_enabled) { - $payload = $this->withSmsFallback($payload, $this->smsFallbackBody($type, $notifiable, $notification)); + if ($template->fallback_sms_enabled) { + $payload = $this->withSmsFallback($payload, $this->smsFallbackBody( + $template, + $notification, + BaseNotification::resolveNotifiableLocale($notifiable), + )); } - // 6. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신. + // 5. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신. $this->dispatches->create([ 'refkey' => $refkey, 'channel' => DispatchChannel::Alimtalk->value, @@ -194,40 +175,39 @@ class AlimtalkChannelDriver } /** - * SMS 대체발송 본문을 코어 alimtalk 템플릿에서 렌더합니다 (B안). + * SMS 대체발송 본문을 행의 sms_body 에서 렌더합니다 (#597 §3.5). * - * 알림톡 본문은 카카오 승인 템플릿에서 오지만, 실패 시 대체할 SMS 본문은 카카오와 무관하므로 - * 코어 알림 템플릿(alimtalk 채널) 본문을 그대로 재사용한다. 코어 템플릿이 없거나 비활성이면 - * 빈 문자열을 반환하고, withSmsFallback 이 빈 본문이면 대체를 병합하지 않는다(엣지 C2). + * 대체 SMS 본문 소스는 코어 알림 템플릿이 아니라 bizppurio_templates.sms_body 다. + * `#{var}` 표기를 알림 data 로 치환하며, 본문이 비어 있으면 빈 문자열을 반환하고 + * withSmsFallback 이 병합하지 않는다(빈 SMS 방지). * - * @param string $type 알림 유형 - * @param object $notifiable 수신자 + * sms_body 는 로케일별 맵이므로 SmsChannelDriver 와 동일하게 수신자 로케일로 렌더한다 — + * 같은 알림의 SMS 단독 발송과 대체발송이 서로 다른 언어로 나가면 안 된다(§14.3). + * + * @param BizppurioTemplate $template 템플릿 행 * @param GenericNotification $notification 발송 대상 알림 + * @param string|null $locale 수신자 로케일 (null 이면 현재 앱 로케일) * @return string 치환 완료된 대체 SMS 본문 (없으면 빈 문자열) */ - private function smsFallbackBody(string $type, object $notifiable, GenericNotification $notification): string + private function smsFallbackBody(object $template, GenericNotification $notification, ?string $locale = null): string { - $template = $this->templateService->resolve($type, DispatchChannel::Alimtalk->value); - if ($template === null || ! $template->is_active) { + $body = trim($template->getLocalizedSmsBody($locale)); + if ($body === '') { return ''; } - $locale = BaseNotification::resolveNotifiableLocale($notifiable); - $rendered = $template->replaceVariables($notification->getData(), $locale); - - return (string) ($rendered['body'] ?? ''); + return $this->payloadMapper->substituteText($body, $notification->getData()); } /** - * 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송, 계획서 §6-2). + * 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송). * - * 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다. 대체 SMS 본문은 - * 코어 알림 본문(치환 완료 텍스트)을 재사용한다. 부록 C-2 의 `resend:{first:"sms"}` + - * `recontent:{sms:{message}}` 구조를 따른다. 대체 본문이 비어 있으면(코어 템플릿 부재) - * 빈 SMS 를 보내지 않도록 병합하지 않는다(엣지 C2). + * 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다. + * `resend:{first:"sms"}` + `recontent:{sms:{message}}` 구조를 따른다(현행 방식 유지 — + * G7 레벨 재발송 없음). 대체 본문이 비어 있으면 빈 SMS 를 보내지 않도록 병합하지 않는다. * * @param array $payload 알림톡 발송 payload - * @param string $renderedBody 치환 완료된 코어 본문(대체 SMS 내용) + * @param string $renderedBody 치환 완료된 대체 SMS 본문 * @return array resend/recontent 가 병합된 payload (빈 본문이면 원본 그대로) */ private function withSmsFallback(array $payload, string $renderedBody): array diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php index 754f3596..6f9e96bf 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php @@ -232,6 +232,21 @@ class AlimtalkPayloadMapper return $mapped; } + /** + * 임의 텍스트의 카카오 변수(#{key})를 알림 data 로 치환합니다 (공개 진입점). + * + * 대체 SMS·SMS 단독 본문(bizppurio_templates.sms_body)이 알림톡 본문과 동일한 + * `#{var}` 표기를 쓰므로(#597 §3.1), 드라이버가 본문 치환에 재사용한다. + * + * @param string $text 치환 대상(#{key} 포함) + * @param array $data 변수 치환 소스 + * @return string 치환된 문자열 + */ + public function substituteText(string $text, array $data): string + { + return $this->substitute($text, $data); + } + /** * 카카오 변수(#{key})를 알림 data 값으로 치환합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php index 1280b8b5..97c16337 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php @@ -4,137 +4,24 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Services; -use App\Services\PluginSettingsService; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; /** - * 알림톡 템플릿 조회 서비스 (Phase 5). + * 알림톡 작성 모달 참조 조회 서비스 (#597). * - * 카카오 관리 API(kapi.ppurio.com)를 BizppurioKakaoApiClient 로 위임하여 알림톡 템플릿을 - * 실시간 조회한다(목록·상세·카테고리·발신프로필). 등록·수정·삭제·검수·상태변경은 비즈뿌리오 - * 콘솔로 위임하며, 이 화면은 목록·상태·내용 조회 + 알림 연결만 담당한다. 템플릿은 DB 에 - * 저장하지 않고 매 요청 실시간으로 조회한다(계획서 §6-3). - * - * 이 서비스는 serviceStatus(REG/REQ/REJ/RDY/ACT/DMT/STP/BLK)를 상태 배지로 매핑하는 - * 도메인 로직을 담당한다(RDY/ACT 만 알림 연결 가능 상태). - * - * 발신프로필 키(senderKey)는 환경설정(sender_key)에서 가져오며, 미설정 시 조회 자체가 - * 불가능하므로 화면은 readiness 로 사전 안내한다(§6-3). + * 알림톡 템플릿 작성 모달이 소비하는 카테고리·발신프로필 kapi 조회를 위임한다. + * 템플릿 목록/상세의 실시간 조회(구 Phase 5)는 DB 기반 라이프사이클 + * (BizppurioTemplateService)로 대체되어 제거됐다 — 발송 전 실시간 조회는 없다. */ class AlimtalkTemplateService { - /** 플러그인 식별자 (manifest 와 일치) */ - private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio'; - - /** 목록 조회 기본 페이지 크기 */ - private const DEFAULT_COUNT = 20; - - /** kapi 결과코드: 요청한 데이터가 없음(검색 결과 0건 포함, 13.응답코드정의.md) */ - private const NOT_FOUND_CODE = '508'; - - /** - * serviceStatus → 상태 배지 매핑. - * - * key = kapi serviceStatus, value = ['label_key' => 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'], - ]; - /** * @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 자격증명 미설정·조회 실패 시(결과코드 508 제외) - */ - 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); - - try { - $this->assertSuccess($response); - } catch (BizppurioApiException $e) { - // 508 = "요청한 데이터가 없음"(검색 결과 0건 포함). 카카오는 이 경우를 목록 - // 조회 실패로 응답하지만, 실제로는 정상적인 빈 결과이므로 예외로 취급하지 않는다. - if ($e->getResultCode() === self::NOT_FOUND_CODE) { - return [ - 'templates' => [], - 'pagination' => [ - 'total' => 0, - 'total_page' => 1, - 'current_page' => $params['page'], - 'per_page' => $params['count'], - ], - ]; - } - - throw $e; - } - - $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'] ?? [])); - } - /** * 템플릿 등록에 사용할 카테고리 목록 전체를 조회합니다. * @@ -172,81 +59,6 @@ class AlimtalkTemplateService return array_values((array) ($response['data']['success'] ?? [])); } - /** - * 템플릿 행에 상태 배지를 부가합니다. - * - * serviceStatus(목록) 또는 inspectionStatus/status(상세)에서 배지 기준 상태를 도출한다. - * RDY/ACT(승인) 상태만 알림 연결 가능하며, 프론트가 배지로 이를 안내한다. - * - * @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'] = [ - // 프론트 $t() 가 해석하는 프론트 lang 키 형식(templates.status.*)으로 준다. - // 프론트(en/ko.json)에는 이 키만 존재하며, 백엔드 messages.php 네임스페이스 - // (::messages.template.status.*)는 프론트에 없어 원문이 그대로 노출된다. - 'label_key' => 'sirsoft-message_bizppurio.templates.status.'.$badge['label_key'], - 'variant' => $badge['variant'], - ]; - - 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 를 담아 예외를 던집니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php index 552c4c99..107a40fa 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php @@ -91,7 +91,7 @@ class BizppurioKakaoApiClient /** * 카카오 관리 API 의 임의 엔드포인트를 호출합니다. * - * Phase 5·6 의 템플릿 CRUD·검수·카테고리 조회 등에서 재사용한다. bizId/apiKey 는 + * 템플릿 CRUD·검수·카테고리 조회 등에서 재사용한다. bizId/apiKey 는 * 자동으로 주입되므로 도메인 파라미터만 전달한다. * * @param string $path 엔드포인트 경로 (예: '/v3/kakao/template/add') @@ -105,6 +105,196 @@ class BizppurioKakaoApiClient return $this->post($path, $params); } + /** + * 알림톡 템플릿을 등록합니다. (`/v3/kakao/template/add`) + * + * @param array $params 등록 페이로드 (senderKey/templateCode/templateName 등 부록 A-2) + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function addTemplate(array $params): array + { + return $this->post('/v3/kakao/template/add', $params); + } + + /** + * 알림톡 템플릿을 수정합니다. (`/v3/kakao/template/update` — status R + REG/REJ 만 가능) + * + * @param array $params 수정 페이로드 (add 와 동일 + 선택 newTemplateCode) + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function updateTemplate(array $params): array + { + return $this->post('/v3/kakao/template/update', $params); + } + + /** + * 알림톡 템플릿을 삭제합니다. (`/v3/kakao/template/delete` — status R + REG/REJ 만 가능) + * + * @param string $senderKey 발신프로필 키 + * @param string $templateCode 템플릿 코드 + * @return array 응답 배열 (code/message) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function deleteTemplate(string $senderKey, string $templateCode): array + { + return $this->post('/v3/kakao/template/delete', [ + 'senderKey' => $senderKey, + 'templateCode' => $templateCode, + ]); + } + + /** + * 템플릿 코드 중복을 검증합니다. (`/v3/kakao/template/codeCheck`) + * + * 응답 code `200` = 사용 가능, 그 외(504 중복 등) = 사용 불가(부록 A-4). + * + * @param string $senderKey 발신프로필 키 + * @param string $templateCode 검사할 템플릿 코드 + * @return array 응답 배열 (code/message) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function checkTemplateCode(string $senderKey, string $templateCode): array + { + return $this->post('/v3/kakao/template/codeCheck', [ + 'senderKey' => $senderKey, + 'templateCode' => $templateCode, + ]); + } + + /** + * 템플릿 검수를 요청합니다. (`/v3/kakao/template/request`) + * + * @param string $senderKey 발신프로필 키 + * @param string $templateCode 템플릿 코드 + * @param string $comment 검수자 전달 의견 (≤500) + * @return array 응답 배열 (code/message) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function requestInspection(string $senderKey, string $templateCode, string $comment = ''): array + { + $params = [ + 'senderKey' => $senderKey, + 'templateCode' => $templateCode, + ]; + + if ($comment !== '') { + $params['comment'] = $comment; + } + + return $this->post('/v3/kakao/template/request', $params); + } + + /** + * 템플릿 검수를 취소합니다. (`/v3/kakao/template/cancel_request` — REQ→REG) + * + * @param string $senderKey 발신프로필 키 + * @param string $templateCode 템플릿 코드 + * @return array 응답 배열 (code/message) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function cancelInspection(string $senderKey, string $templateCode): array + { + return $this->post('/v3/kakao/template/cancel_request', [ + 'senderKey' => $senderKey, + 'templateCode' => $templateCode, + ]); + } + + /** + * 템플릿 승인을 취소합니다. (`/v3/kakao/template/cancel_approval` — 승인→등록 복귀) + * + * @param string $senderKey 발신프로필 키 + * @param string $templateCode 템플릿 코드 + * @return array 응답 배열 (code/message) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function cancelApproval(string $senderKey, string $templateCode): array + { + return $this->post('/v3/kakao/template/cancel_approval', [ + 'senderKey' => $senderKey, + 'templateCode' => $templateCode, + ]); + } + + /** + * 휴면(DMT) 템플릿을 해제합니다. (`/v3/kakao/template/release`) + * + * @param string $senderKey 발신프로필 키 + * @param string $templateCode 템플릿 코드 + * @return array 응답 배열 (code/message) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function releaseDormant(string $senderKey, string $templateCode): array + { + return $this->post('/v3/kakao/template/release', [ + 'senderKey' => $senderKey, + 'templateCode' => $templateCode, + ]); + } + + /** + * 이미지형 템플릿용 이미지를 업로드합니다. (`/v3/kakao/image/alimtalk/template`) + * + * kapi 유일의 비-JSON(multipart) 엔드포인트다(부록 A-7). 필드는 `image`(파일) + + * bizId/apiKey 이며 senderKey 는 필요 없다. 응답은 `{code, message, image}` 로 + * data 봉투가 아니라 최상위 `image` 필드에 업로드된 URL 이 실린다. + * + * @param string $path 업로드할 이미지의 로컬 절대 경로 + * @param string $filename 원본 파일명 (확장자 판정용) + * @return array 응답 배열 (code/message/image) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function uploadTemplateImage(string $path, string $filename): array + { + [$bizId, $apiKey] = $this->credentials(); + + $stream = fopen($path, 'r'); + if ($stream === false) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.image_unreadable'), + ); + } + + try { + $response = $this->http() + ->attach('image', $stream, $filename) + ->post(self::BASE_URL.'/v3/kakao/image/alimtalk/template', [ + 'bizId' => $bizId, + 'apiKey' => $apiKey, + ]); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + if ($response->failed()) { + throw $this->failureException($response); + } + + $result = $response->json(); + + if (! is_array($result)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.invalid_response'), + httpStatus: $response->status(), + ); + } + + return $result; + } + /** * 카카오 관리 API 응답이 성공(200)인지 판정합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTemplateService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTemplateService.php new file mode 100644 index 00000000..92a2ae61 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTemplateService.php @@ -0,0 +1,719 @@ + $filters status / search + * @param int $page 페이지 번호 + * @param int $perPage 페이지 크기 + * @return LengthAwarePaginator 페이지네이션 결과 + */ + public function list(array $filters, int $page, int $perPage): LengthAwarePaginator + { + return $this->templates->paginateWithDefinitions($filters, $page, $perPage); + } + + /** + * 알림 설정 탭 행 표시용 요약 맵을 반환합니다 (notification_type 키). + * + * @return array> notification_type → 요약 + */ + public function summaryMap(): array + { + return $this->templates->allSummaries() + ->keyBy('notification_type') + ->map(fn (BizppurioTemplate $template): array => [ + 'id' => $template->id, + 'notification_type' => $template->notification_type, + 'alimtalk_enabled' => $template->alimtalk_enabled, + 'template_code' => $template->template_code, + 'status' => $template->status->value, + 'has_content' => (bool) $template->getAttribute('has_content'), + 'requested_at' => $template->requested_at?->toIso8601String(), + 'approved_at' => $template->approved_at?->toIso8601String(), + 'last_synced_at' => $template->last_synced_at?->toIso8601String(), + 'inspection_detail' => $template->inspection_detail, + 'fallback_sms_enabled' => $template->fallback_sms_enabled, + 'sms_body' => $template->sms_body, + // 본문 유무·미리보기는 모델이 판정한다 — 화면이 로케일 맵을 직접 훑으면 발송 게이트 + // (hasSmsBody: 공백 제거 후 비교, 폴백은 config('app.fallback_locale'))와 규칙이 갈려 + // "화면엔 본문 있음 / 실제로는 미발송" 이 된다. 판정 한 벌만 둔다. + 'has_sms_body' => $template->hasSmsBody(), + 'sms_body_preview' => $template->getLocalizedSmsBody(), + 'sms_only' => $template->sms_only, + 'is_active' => $template->is_active, + ]) + ->all(); + } + + /** + * PK 로 템플릿을 조회합니다. + * + * @param int $id 템플릿 PK + * @return BizppurioTemplate|null 매칭 행 또는 null + */ + public function find(int $id): ?BizppurioTemplate + { + return $this->templates->find($id); + } + + /** + * 템플릿 행을 생성합니다 (status=draft). + * + * @param array $data FormRequest 검증 데이터 + * @return BizppurioTemplate 생성된 행 + */ + public function create(array $data): BizppurioTemplate + { + $data['status'] = BizppurioTemplateStatus::Draft->value; + + return $this->templates->create($data); + } + + /** + * 발송 설정(알림톡 사용·대체 SMS·SMS 본문·SMS 단독·활성)을 upsert 합니다 (#597 §4.1). + * + * 알림 설정 탭 행의 토글이 즉시 저장하는 경로다. 대상 행이 없으면 draft 로 생성한다. + * 알림톡 content 는 이 경로로 변경되지 않으므로 상태 가드가 필요 없다. + * + * @param string $notificationType 코어 notification_definitions.type + * @param array $data 발송 설정 필드 (FormRequest 검증 완료) + * @return BizppurioTemplate 저장된 행 + */ + public function upsertDelivery(string $notificationType, array $data): BizppurioTemplate + { + $template = $this->templates->findByType($notificationType); + + if ($template === null) { + return $this->templates->create(array_merge($data, [ + 'notification_type' => $notificationType, + 'status' => BizppurioTemplateStatus::Draft->value, + ])); + } + + return $this->templates->update($template, $data); + } + + /** + * 템플릿 행을 갱신합니다. + * + * 카카오 등록 내용(content)은 draft/rejected 상태에서만 실을 수 있다 — + * requested 는 검수취소 먼저, approved 는 승인취소 먼저(§3.2). SMS·활성 플래그 + * (alimtalk_enabled/fallback_sms_enabled/sms_body/sms_only/is_active)는 라이프사이클과 + * 무관한 발송 설정이므로 상태와 무관하게 수정할 수 있다. + * + * 판정은 content 키의 **존재 여부**만 본다. 값의 동일 여부는 보지 않는다 — + * `content` 는 MySQL json 컬럼이고 DB 가 객체 키를 (길이, 사전순)으로 정규화해 + * 저장하므로, 화면이 보낸 순서와 DB 에서 읽은 순서가 구조적으로 일치할 수 없다. + * 동일성 비교는 항상 "변경됨" 으로 떨어져 완화가 성립하지 않았고, 화면도 검수중·승인 + * 상태에서는 수정 진입 자체를 막고 있어(3면 row_footer·관리화면 모두 draft‖rejected + * 게이트) 그 완화가 보호하는 경로가 애초에 없었다. 발송 설정만 바꾸는 경로는 + * content 키를 싣지 않으므로 이 가드에 걸리지 않는다. + * + * @param BizppurioTemplate $template 대상 행 + * @param array $data FormRequest 검증 데이터 + * @return BizppurioTemplate 갱신된 행 + * + * @throws BizppurioTemplateStateException content 수정 불가 상태일 때 + */ + public function update(BizppurioTemplate $template, array $data): BizppurioTemplate + { + if (array_key_exists('content', $data) && ! $template->status->allowsContentEdit()) { + throw new BizppurioTemplateStateException( + 'sirsoft-message_bizppurio::messages.template.content_locked', + ['status' => $template->status->value], + ); + } + + return $this->templates->update($template, $data); + } + + /** + * 검수를 신청합니다: 채번 → kapi add/update → kapi request → status=requested. + * + * - content 미작성이면 신청 불가. + * - draft/rejected 에서만 신청 가능(requested 는 이미 검수중, approved 는 승인취소 먼저). + * - template_code 가 없으면(최초) 자체 채번 후 kapi add, 있으면(재신청) kapi update. + * add 성공 시점에만 template_code 를 행에 확정하므로, add 자체가 실패하면 다음 + * 시도에서 다시 채번부터 진행된다. + * + * @param BizppurioTemplate $template 대상 행 + * @return BizppurioTemplate 갱신된 행 (status=requested) + * + * @throws BizppurioTemplateStateException 신청 불가 상태·content 미작성 + * @throws BizppurioApiException kapi 실패 (사유 원문 보존) + */ + public function requestInspection(BizppurioTemplate $template): BizppurioTemplate + { + if (! is_array($template->content) || $template->content === []) { + throw new BizppurioTemplateStateException( + 'sirsoft-message_bizppurio::messages.template.content_missing', + ); + } + + if (! $template->status->allowsContentEdit()) { + throw new BizppurioTemplateStateException( + 'sirsoft-message_bizppurio::messages.template.request_not_allowed', + ['status' => $template->status->value], + ); + } + + // 원자 선점: 같은 draft/rejected 행을 동시에 든 요청(더블 클릭·이중 탭·복수 관리자) + // 중 하나만 통과한다. 화면의 disabled 가드는 리렌더 전 창(~수십 ms)을 못 막으므로 + // 중복 kapi 제출 차단의 SSoT 는 이 선점이다 (§6.3 10c). + $originalStatus = $template->status; + + if (! $this->templates->claimForInspection($template)) { + throw new BizppurioTemplateStateException( + 'sirsoft-message_bizppurio::messages.template.request_not_allowed', + ['status' => BizppurioTemplateStatus::Requested->value], + ); + } + + try { + $senderKey = $this->senderKey(); + $payload = $template->content; + $payload['senderKey'] = $senderKey; + + if ($template->template_code === null) { + $code = $this->generateTemplateCode($senderKey, $template->notification_type); + $payload['templateCode'] = $code; + + $this->assertSuccess($this->kakao->addTemplate($payload)); + + $template = $this->templates->update($template, [ + 'template_code' => $code, + 'sender_key' => $senderKey, + ]); + } else { + $payload['templateCode'] = $template->template_code; + + $this->assertSuccess($this->kakao->updateTemplate($payload)); + } + + $this->assertSuccess($this->kakao->requestInspection($senderKey, (string) $template->template_code)); + } catch (\Throwable $e) { + // 선점 해제: kapi 미완료 상태로 requested 가 남지 않도록 원래 상태로 복귀. + $this->templates->update($template, ['status' => $originalStatus->value]); + + throw $e; + } + + return $this->templates->update($template, [ + 'status' => BizppurioTemplateStatus::Requested->value, + 'sender_key' => $senderKey, + 'requested_at' => now(), + 'last_synced_at' => now(), + 'inspection_detail' => null, + ]); + } + + /** + * 검수 신청을 취소합니다 (kapi cancel_request, REQ→REG). + * + * @param BizppurioTemplate $template 대상 행 + * @return BizppurioTemplate 갱신된 행 (status=draft) + * + * @throws BizppurioTemplateStateException 검수중이 아닐 때 + * @throws BizppurioApiException kapi 실패 + */ + public function cancelRequest(BizppurioTemplate $template): BizppurioTemplate + { + if ($template->status !== BizppurioTemplateStatus::Requested) { + throw new BizppurioTemplateStateException( + 'sirsoft-message_bizppurio::messages.template.cancel_request_not_allowed', + ['status' => $template->status->value], + ); + } + + $this->assertSuccess($this->kakao->cancelInspection( + $this->rowSenderKey($template), + (string) $template->template_code, + )); + + return $this->templates->update($template, [ + 'status' => BizppurioTemplateStatus::Draft->value, + 'last_synced_at' => now(), + ]); + } + + /** + * 승인을 취소합니다 (kapi cancel_approval, 승인→등록 복귀). + * + * approved_content 는 유지하되 status 가 approved 를 벗어나므로 발송 게이트가 + * 즉시 차단된다(§3.2 — 운영자 확인 모달이 이 효과를 사전 경고한다). + * + * @param BizppurioTemplate $template 대상 행 + * @return BizppurioTemplate 갱신된 행 (status=draft) + * + * @throws BizppurioTemplateStateException 승인 상태가 아닐 때 + * @throws BizppurioApiException kapi 실패 + */ + public function cancelApproval(BizppurioTemplate $template): BizppurioTemplate + { + if ($template->status !== BizppurioTemplateStatus::Approved) { + throw new BizppurioTemplateStateException( + 'sirsoft-message_bizppurio::messages.template.cancel_approval_not_allowed', + ['status' => $template->status->value], + ); + } + + $this->assertSuccess($this->kakao->cancelApproval( + $this->rowSenderKey($template), + (string) $template->template_code, + )); + + return $this->templates->update($template, [ + 'status' => BizppurioTemplateStatus::Draft->value, + 'last_synced_at' => now(), + ]); + } + + /** + * 휴면(DMT) 템플릿을 해제하고 실제 상태를 재동기화합니다. + * + * @param BizppurioTemplate $template 대상 행 + * @return BizppurioTemplate 갱신된 행 + * + * @throws BizppurioTemplateStateException 휴면 상태가 아닐 때 + * @throws BizppurioApiException kapi 실패 + */ + public function releaseDormant(BizppurioTemplate $template): BizppurioTemplate + { + if ($template->status !== BizppurioTemplateStatus::Dormant) { + throw new BizppurioTemplateStateException( + 'sirsoft-message_bizppurio::messages.template.release_not_allowed', + ['status' => $template->status->value], + ); + } + + $this->assertSuccess($this->kakao->releaseDormant( + $this->rowSenderKey($template), + (string) $template->template_code, + )); + + // 해제 후 카카오측 실제 상태(RDY/ACT 등)를 되받아 반영한다. + return $this->sync($template); + } + + /** + * 단건 상태를 카카오와 동기화합니다 (수동 [새로고침] / 휴면 해제 후). + * + * 카카오측 코드가 없는(등록 전) 행은 동기화 대상이 아니므로 그대로 반환한다. + * + * @param BizppurioTemplate $template 대상 행 + * @return BizppurioTemplate 갱신된 행 + * + * @throws BizppurioApiException kapi 실패 + */ + public function sync(BizppurioTemplate $template): BizppurioTemplate + { + if ($template->template_code === null) { + return $template; + } + + $response = $this->kakao->getTemplateDetail( + $this->rowSenderKey($template), + $template->template_code, + ); + $this->assertSuccess($response); + + $detail = (array) ($response['data'] ?? []); + $serviceStatus = BizppurioTemplateStatus::serviceStatusFromDetail($detail); + + return $this->applyServiceStatus($template, $serviceStatus, $detail); + } + + /** + * 검수중(requested) 행 전체를 카카오와 일괄 동기화합니다 (스케줄 커맨드). + * + * requested 행이 없으면 kapi 를 호출하지 않는다. 행별 detail 호출 대신 senderKey 별 + * template/list 1회(페이지 순회)로 일괄 대조한다(레이트리밋 보호). 반려로 전이한 행만 + * 사유(comments) 확보를 위해 detail 을 추가 호출한다. + * + * @return array{checked: int, transitioned: int} 점검·전이 행 수 + */ + public function syncRequested(): array + { + $rows = $this->templates->allByStatus(BizppurioTemplateStatus::Requested->value) + ->filter(fn (BizppurioTemplate $row) => $row->template_code !== null); + + if ($rows->isEmpty()) { + return ['checked' => 0, 'transitioned' => 0]; + } + + $transitioned = 0; + + // 그룹핑은 try 밖에서 평가되므로 여기서 미리 감싼다 — 스냅샷도 환경설정도 비어 있으면 + // rowSenderKey() 가 예외를 던지고, 그대로 두면 30분 배치 전체가 죽는다. + try { + $groups = $rows->groupBy(fn (BizppurioTemplate $row) => $this->rowSenderKey($row)); + } catch (BizppurioApiException $e) { + Log::warning('[sirsoft-message_bizppurio] 발신프로필 키를 해석할 수 없어 상태 동기화를 건너뜁니다', [ + 'result_code' => $e->getResultCode(), + 'message' => $e->getMessage(), + ]); + + return ['checked' => $rows->count(), 'transitioned' => 0]; + } + + foreach ($groups as $senderKey => $group) { + try { + $statusMap = $this->fetchServiceStatusMap((string) $senderKey); + } catch (BizppurioApiException $e) { + Log::warning('[sirsoft-message_bizppurio] 템플릿 상태 일괄 조회 실패 — 이번 주기 건너뜀', [ + 'sender_key' => $senderKey, + 'result_code' => $e->getResultCode(), + 'message' => $e->getMessage(), + ]); + + continue; + } + + foreach ($group as $row) { + $serviceStatus = $statusMap[$row->template_code] ?? null; + if ($serviceStatus === null) { + continue; + } + + $before = $row->status; + $detail = null; + + // 반려 전이만 사유 원문(comments) 확보를 위해 상세를 추가 조회한다. + if (BizppurioTemplateStatus::tryFromServiceStatus($serviceStatus) === BizppurioTemplateStatus::Rejected) { + $detail = $this->fetchDetailOrNull((string) $senderKey, (string) $row->template_code); + } + + $updated = $this->applyServiceStatus($row, $serviceStatus, $detail); + + if ($updated->status !== $before) { + $transitioned++; + } + } + } + + return ['checked' => $rows->count(), 'transitioned' => $transitioned]; + } + + /** + * 템플릿 행을 삭제합니다. 카카오측 코드는 삭제 가능 상태일 때만 함께 지웁니다. + * + * kapi delete 는 status R + REG/REJ 에서만 허용된다(부록 A-4). 우리 상태가 + * draft/rejected 면 kapi 삭제를 시도하고, 그 외 상태(또는 kapi 삭제 실패)면 카카오측은 + * 그대로 두고 DB 행만 삭제한다 — 응답에 사유를 명시해 운영자가 알 수 있게 한다. + * + * @param BizppurioTemplate $template 대상 행 + * @return array{kakao_deleted: bool, kakao_skip_reason: string|null} 카카오측 삭제 여부·미삭제 사유 + */ + public function delete(BizppurioTemplate $template): array + { + $kakaoDeleted = false; + $skipReason = null; + + if ($template->template_code === null) { + $skipReason = 'not_registered'; + } elseif (! $template->status->allowsContentEdit()) { + $skipReason = 'state_not_deletable'; + } else { + try { + $this->assertSuccess($this->kakao->deleteTemplate( + $this->rowSenderKey($template), + $template->template_code, + )); + $kakaoDeleted = true; + } catch (BizppurioApiException $e) { + $skipReason = $e->getMessage(); + Log::warning('[sirsoft-message_bizppurio] 카카오측 템플릿 삭제 실패 — DB 행만 삭제', [ + 'template_code' => $template->template_code, + 'result_code' => $e->getResultCode(), + 'message' => $e->getMessage(), + ]); + } + } + + $this->templates->delete($template); + + return ['kakao_deleted' => $kakaoDeleted, 'kakao_skip_reason' => $skipReason]; + } + + /** + * 이미지형 템플릿용 이미지를 업로드하고 URL 을 반환합니다 (업로드 프록시). + * + * @param UploadedFile $file 검증 완료된 업로드 파일 + * @return string 카카오가 반환한 이미지 URL (templateImageUrl 에 사용) + * + * @throws BizppurioApiException 업로드 실패 + */ + public function uploadImage(UploadedFile $file): string + { + $response = $this->kakao->uploadTemplateImage( + $file->getRealPath(), + $file->getClientOriginalName(), + ); + $this->assertSuccess($response); + + // 부록 A-7: data 봉투가 아니라 응답 최상위 image 필드에 URL 이 실린다. + $url = (string) ($response['image'] ?? ''); + + if ($url === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.invalid_response'), + ); + } + + return $url; + } + + /** + * kapi serviceStatus 를 행에 반영합니다 (동기화 공통 경로). + * + * - 승인 전이 시 content 를 approved_content 로 동결하고 approved_at 을 확정한다. + * - 반려 시 상세의 comments 를 inspection_detail 로 저장한다. + * - 알 수 없는 serviceStatus 는 상태를 덮어쓰지 않고 동기화 시각만 남긴다. + * + * @param BizppurioTemplate $template 대상 행 + * @param string $serviceStatus kapi serviceStatus + * @param array|null $detail kapi 상세 (comments 소스, 없으면 null) + * @return BizppurioTemplate 갱신된 행 + */ + private function applyServiceStatus( + BizppurioTemplate $template, + string $serviceStatus, + ?array $detail = null, + ): BizppurioTemplate { + $status = BizppurioTemplateStatus::tryFromServiceStatus($serviceStatus); + + $data = ['last_synced_at' => now()]; + + if ($status !== null) { + $data['status'] = $status->value; + + if ($status === BizppurioTemplateStatus::Approved && $template->status !== BizppurioTemplateStatus::Approved) { + $data['approved_content'] = $template->content; + $data['approved_at'] = now(); + } + + if ($status === BizppurioTemplateStatus::Rejected && is_array($detail) && isset($detail['comments'])) { + $data['inspection_detail'] = (array) $detail['comments']; + } + } + + return $this->templates->update($template, $data); + } + + /** + * senderKey 의 전체 템플릿 상태 맵(templateCode → serviceStatus)을 조회합니다. + * + * kapi list 를 페이지 순회로 일괄 조회한다(최대 페이지 상한으로 무한 루프 방지). + * + * @param string $senderKey 발신프로필 키 + * @return array templateCode → serviceStatus + * + * @throws BizppurioApiException 조회 실패 + */ + private function fetchServiceStatusMap(string $senderKey): array + { + $map = []; + $page = 1; + + do { + $response = $this->kakao->getTemplateList($senderKey, [ + 'count' => self::SYNC_LIST_COUNT, + 'page' => $page, + ]); + $this->assertSuccess($response); + + $data = (array) ($response['data'] ?? []); + foreach ((array) ($data['list'] ?? []) as $row) { + if (is_array($row) && ! empty($row['templateCode'])) { + $map[(string) $row['templateCode']] = (string) ($row['serviceStatus'] ?? ''); + } + } + + $totalPage = (int) ($data['totalPage'] ?? 1); + $page++; + } while ($page <= $totalPage && $page <= self::SYNC_LIST_MAX_PAGES); + + return $map; + } + + /** + * 상세를 조회하되 실패는 null 로 되돌립니다 (반려 사유 확보는 부가 정보). + * + * @param string $senderKey 발신프로필 키 + * @param string $templateCode 템플릿 코드 + * @return array|null 상세 data 또는 실패 시 null + */ + private function fetchDetailOrNull(string $senderKey, string $templateCode): ?array + { + try { + $response = $this->kakao->getTemplateDetail($senderKey, $templateCode); + $this->assertSuccess($response); + + return (array) ($response['data'] ?? []); + } catch (BizppurioApiException $e) { + // 반려 사유(comments)를 못 받으면 화면에는 "반려됐는데 사유가 없음" 만 남는다 — + // 상태 전이 자체는 계속 진행하되, 사유가 비는 원인은 로그로 남긴다. + Log::warning('[sirsoft-message_bizppurio] 템플릿 상세 조회 실패 — 반려 사유를 저장하지 못했습니다', [ + 'sender_key' => $senderKey, + 'template_code' => $templateCode, + 'result_code' => $e->getResultCode(), + 'message' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * template_code 를 자체 채번합니다 (§3.2). + * + * `g7_{md5(notification_type) 앞 8자}_{세대}` 형식(≤30자, 영문/숫자/언더스코어). + * 우리 DB 와 kapi codeCheck 양쪽에서 미사용일 때만 확정하고, 충돌 시 세대를 올려 + * 최대 3회 재시도한다. + * + * @param string $senderKey 발신프로필 키 + * @param string $notificationType 알림 유형 + * @return string 확정된 템플릿 코드 + * + * @throws BizppurioApiException 재시도 소진·codeCheck 호출 실패 + */ + private function generateTemplateCode(string $senderKey, string $notificationType): string + { + $base = self::CODE_PREFIX.'_'.substr(md5($notificationType), 0, 8); + + for ($generation = 1; $generation <= self::CODE_CHECK_MAX_ATTEMPTS; $generation++) { + $code = $base.'_'.$generation; + + if ($this->templates->templateCodeExists($code)) { + continue; + } + + // codeCheck: code 200 = 사용 가능, 504(중복) 등 = 불가 → 세대 증가 재시도(A-4). + $response = $this->kakao->checkTemplateCode($senderKey, $code); + if ($this->kakao->isSuccess($response)) { + return $code; + } + } + + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.template.code_generation_failed'), + ); + } + + /** + * 행의 발신프로필 키를 반환합니다 (스냅샷 우선, 없으면 현재 설정). + * + * @param BizppurioTemplate $template 대상 행 + * @return string 발신프로필 키 + * + * @throws BizppurioApiException 스냅샷·설정 모두 없을 때 + */ + private function rowSenderKey(BizppurioTemplate $template): string + { + $snapshot = trim((string) $template->sender_key); + + return $snapshot !== '' ? $snapshot : $this->senderKey(); + } + + /** + * 환경설정에서 발신프로필 키(sender_key)를 조회합니다. + * + * @return string 발신프로필 키 + * + * @throws BizppurioApiException 미설정 시 + */ + private function senderKey(): string + { + $senderKey = trim((string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, '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/KakaoTemplateContentResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php deleted file mode 100644 index 523d0260..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php +++ /dev/null @@ -1,193 +0,0 @@ -0 이면 캐시 우선(없으면 조회 후 캐시), TTL=0 이면 캐시 우회(매번 조회). - * 조회 실패는 null 을 반환한다. - * - * @param string $templateCode 카카오 템플릿 코드 - * @return array|null 카카오 상세(templateContent/buttons/…) 또는 실패 시 null - */ - public function resolve(string $templateCode): ?array - { - $ttl = $this->cacheTtl(); - - // TTL=0 → 캐시 우회, 매번 실시간 조회. - if ($ttl <= 0) { - return $this->fetch($templateCode); - } - - $cacheKey = self::CACHE_KEY_PREFIX.$templateCode; - - // 캐시 히트면 그대로 반환. 미스면 조회해 캐시. - $cached = $this->cache->get($cacheKey); - if (is_array($cached)) { - return $cached; - } - - $content = $this->fetch($templateCode); - - // 조회 실패(null)는 캐시하지 않는다 — 다음 발송에서 재시도 가능하도록. - if ($content !== null) { - $this->cache->put($cacheKey, $content, $ttl); - } - - return $content; - } - - /** - * 특정 템플릿의 캐시를 무효화합니다 (관리자 수동 초기화). - * - * @param string $templateCode 카카오 템플릿 코드 - */ - public function clear(string $templateCode): void - { - $this->cache->forget(self::CACHE_KEY_PREFIX.$templateCode); - } - - /** - * 여러 템플릿의 캐시를 한 번에 무효화합니다 (관리자 전체 초기화). - * - * 캐시 키가 template_code 단위이므로, 연결된 모든 알림톡 템플릿 코드를 받아 각각 비운다. - * 다음 발송에서 최신 내용으로 재조회된다. - * - * @param array $templateCodes 카카오 템플릿 코드 목록 - * @return int 비운 캐시 키 수(중복 제거 후) - */ - public function clearMany(array $templateCodes): int - { - $codes = array_values(array_unique(array_filter($templateCodes, static fn ($c) => $c !== ''))); - - foreach ($codes as $code) { - $this->clear((string) $code); - } - - return count($codes); - } - - /** - * 카카오 상세조회를 수행합니다. 실패는 null(예외 삼킴 + 로그). - * - * rate limit(HTTP 429 또는 결과코드 5002)은 조회 폭주 상황이므로 짧게 대기 후 1회 - * 재시도한다(무한 방지). 재시도해도 rate limit 이면 null 을 반환해 호출측이 발송을 skip - * 하게 한다. 그 외 실패(고아 template_code·자격증명 오류 등)는 재시도 없이 null. - * - * @param string $templateCode 카카오 템플릿 코드 - * @return array|null 상세 또는 실패 시 null - */ - private function fetch(string $templateCode): ?array - { - for ($attempt = 0; $attempt <= self::RATE_LIMIT_RETRIES; $attempt++) { - try { - return $this->templates->detail($templateCode); - } catch (BizppurioApiException $e) { - // rate limit 이고 재시도 여유가 남았으면 짧게 대기 후 재시도. - if ($this->isRateLimited($e) && $attempt < self::RATE_LIMIT_RETRIES) { - usleep(self::RATE_LIMIT_RETRY_WAIT_US); - - continue; - } - - Log::warning('비즈뿌리오 알림톡 템플릿 내용 조회 실패 — 발송 skip 후보', [ - 'template_code' => $templateCode, - 'result_code' => $e->getResultCode(), - 'http_status' => $e->getHttpStatus(), - 'message' => $e->getMessage(), - ]); - - return null; - } - } - - return null; - } - - /** - * 예외가 rate limit(HTTP 429 또는 결과코드 5002)인지 판정합니다. - * - * @param BizppurioApiException $e 조회 실패 예외 - * @return bool rate limit 이면 true - */ - private function isRateLimited(BizppurioApiException $e): bool - { - return $e->getHttpStatus() === 429 || $e->getResultCode() === '5002'; - } - - /** - * 캐시 TTL(초)을 환경설정에서 조회합니다 (기본 60분, 0=캐시 끔). - * - * 관리자 화면·저장은 "분" 단위(template_cache_minutes)로 관리하고, 캐시 드라이버에는 - * 초 단위가 필요하므로 60 을 곱해 반환한다. 0 이면 캐시를 끄므로 0 그대로 반환한다. - * - * @return int TTL(초) - */ - private function cacheTtl(): int - { - $minutes = (int) $this->pluginSettings->get( - self::PLUGIN_IDENTIFIER, - 'template_cache_minutes', - self::DEFAULT_CACHE_MINUTES, - ); - - return max(0, $minutes) * 60; - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php deleted file mode 100644 index f3fd3a19..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php +++ /dev/null @@ -1,239 +0,0 @@ -> notification_type 키의 연동 맵 - */ - public function all(bool $withAvailability = false): array - { - // 소실 판정 대상 = 발송 가능한(승인) 카카오 템플릿 코드 집합. 조회 실패(카카오 장애·자격증명 - // 미설정)면 null 을 반환해 판정 자체를 건너뛴다 — 살아 있는 연동이 일시 장애로 "사용 불가"로 - // 오탐되어 화면이 전부 경고로 물드는 것을 막는다(발송 시점 판정과 동일한 안전측 기준). - $sendableCodes = $withAvailability ? $this->sendableTemplateCodesOrNull() : null; - - return $this->bindings->allByChannel(self::CHANNEL) - ->keyBy('notification_type') - ->map(function (BizppurioNotificationBinding $binding) use ($sendableCodes) { - $info = [ - 'notification_type' => $binding->notification_type, - 'template_code' => $binding->template_code, - 'template_name' => $binding->template_name, - 'fallback_sms_enabled' => (bool) $binding->fallback_sms_enabled, - ]; - - // 승인 목록 조회에 성공했을 때만 소실 여부를 부여한다(null=판정 생략). - if ($sendableCodes !== null) { - $info['is_unavailable'] = ! in_array($binding->template_code, $sendableCodes, true); - } - - return $info; - }) - ->all(); - } - - /** - * 발송 가능한(승인) 카카오 템플릿 코드 집합을 반환하되, 조회 실패 시 null 을 반환합니다. - * - * approvedTemplates() 는 자격증명 미설정·카카오 장애 시 BizppurioApiException 을 던진다. 소실 - * 판정은 "표시 부가정보"이므로 실패를 삼키고 null 을 반환해, 호출부가 판정을 건너뛰게 한다. - * - * @return array|null 승인 template_code 목록(성공) 또는 null(조회 실패) - */ - private function sendableTemplateCodesOrNull(): ?array - { - try { - return array_column($this->approvedTemplates(), 'template_code'); - } catch (BizppurioApiException) { - return null; - } - } - - /** - * 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 모달 드롭다운). - * - * 카카오 템플릿 목록 중 serviceStatus 가 RDY/ACT 인 항목만 노출한다. 미승인 템플릿에 - * 연동해도 발송이 거부되므로 애초에 선택지에서 제외한다. - * - * @return array - * - * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 - */ - public function approvedTemplates(): array - { - // 승인 상태 필터는 kapi 에 status 파라미터로 위임하지 않고(상태 2종 조회 불가), 전체를 - // 받아 serviceStatus 로 거른다. 페이지네이션 대신 넉넉한 count 로 1회 조회한다. - $result = $this->templates->list(['count' => 100]); - - return collect($result['templates'] ?? []) - ->filter(fn (array $row) => in_array((string) ($row['serviceStatus'] ?? $row['status'] ?? ''), self::SENDABLE_STATUSES, true)) - ->map(fn (array $row) => [ - 'template_code' => (string) ($row['templateCode'] ?? $row['code'] ?? ''), - 'template_name' => (string) ($row['templateName'] ?? $row['name'] ?? ''), - ]) - ->filter(fn (array $row) => $row['template_code'] !== '') - ->values() - ->all(); - } - - /** - * 알림에 알림톡 템플릿을 연결(생성/갱신)합니다 (연동 모달 저장). - * - * 저장 전 카카오 승인 상태(RDY/ACT)를 재검증한다 — 드롭다운이 승인 템플릿만 보여주지만 - * 그 필터는 화면 단계일 뿐이라, API 를 직접 호출하면 미승인 템플릿도 저장될 수 있었다(회귀). - * 카카오 조회 자체가 실패(장애·자격증명 미설정)하면 승인 여부를 판정할 수 없으므로 안전측으로 - * 저장을 거부한다 — 조회 실패를 "승인됨"으로 잘못 해석해 미승인 템플릿이 새는 것을 막는다. - * - * @param string $notificationType 코어 notification_definitions.type - * @param array $data template_code / template_name / fallback_sms_enabled - * @return BizppurioNotificationBinding 저장된 연동 - * - * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 - */ - public function bind(string $notificationType, array $data): BizppurioNotificationBinding - { - $templateCode = (string) $data['template_code']; - - $this->assertSendable($templateCode); - - return $this->bindings->upsert($notificationType, self::CHANNEL, [ - 'template_code' => $templateCode, - 'template_name' => (string) $data['template_name'], - 'fallback_sms_enabled' => (bool) ($data['fallback_sms_enabled'] ?? false), - 'is_active' => true, - ]); - } - - /** - * 템플릿 코드가 발송 가능(승인) 상태인지 검증합니다. 아니면 예외를 던집니다. - * - * @param string $templateCode 검증할 카카오 템플릿 코드 - * - * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 - */ - private function assertSendable(string $templateCode): void - { - $sendableCodes = array_column($this->approvedTemplates(), 'template_code'); - - if (! in_array($templateCode, $sendableCodes, true)) { - throw new BizppurioApiException( - __('sirsoft-message_bizppurio::messages.error.template_not_sendable', ['code' => $templateCode]), - ); - } - } - - /** - * 알림의 알림톡 연동을 해제(삭제)합니다. - * - * @param string $notificationType 코어 notification_definitions.type - */ - public function unbind(string $notificationType): void - { - $this->bindings->delete($notificationType, self::CHANNEL); - } - - /** - * 연결된 모든 알림톡 템플릿의 발송용 내용 캐시를 초기화합니다 (관리자 수동 갱신). - * - * 카카오에서 템플릿 내용을 방금 바꿔 캐시 만료(기본 1시간)를 기다리지 않고 즉시 반영하고 - * 싶을 때 사용한다. 연결(binding)된 template_code 를 모아 각 캐시를 비우면 다음 발송에서 - * 최신 내용으로 재조회된다. - * - * @return int 초기화한 캐시 키 수(연결된 고유 template_code 수) - */ - public function clearTemplateContentCache(): int - { - $codes = $this->bindings->allByChannel(self::CHANNEL) - ->pluck('template_code') - ->all(); - - return $this->kakaoContent->clearMany($codes); - } - - /** - * 연결 모달이 넘긴 값으로 연동을 반영합니다. - * - * 우리 연결 저장 API(NotificationBindingController::store → POST notification-bindings)에서 - * 호출된다. 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다. 이 "빈 값=해제" - * 규칙 덕분에 연결 모달에서 드롭다운을 "연결 안 함"으로 바꾸고 저장하면 저장 한 번으로 - * 해제까지 처리된다. - * - * @param string $notificationType 코어 notification_definitions.type - * @param string|null $templateCode 연결할 카카오 템플릿 코드 (빈 값=해제) - * @param string|null $templateName 템플릿 이름 스냅샷 (고아 감지용) - * @param bool $fallbackSmsEnabled 실패 시 SMS 대체발송 여부 - * - * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 (해제 시에는 미발생) - */ - public function applyFromTemplateSave( - string $notificationType, - ?string $templateCode, - ?string $templateName, - bool $fallbackSmsEnabled, - ): void { - $code = trim((string) $templateCode); - - if ($code === '') { - $this->unbind($notificationType); - - return; - } - - $this->bind($notificationType, [ - 'template_code' => $code, - 'template_name' => trim((string) $templateName), - 'fallback_sms_enabled' => $fallbackSmsEnabled, - ]); - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php index 6981b34c..33108a52 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -8,7 +8,6 @@ use App\Models\User; use App\Notifications\BaseNotification; use App\Notifications\GenericNotification; use App\Services\NotificationDefinitionService; -use App\Services\NotificationTemplateService; use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Str; @@ -18,30 +17,30 @@ use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface; /** - * 코어 알림 시스템의 sms 채널 드라이버. + * 코어 알림 시스템의 sms 채널 드라이버 (#597 §3.5). * * ChannelManager::extend('sms', …)(ServiceProvider::boot)로 등록되어, 코어가 * `via()` 에서 'sms' 채널을 선택하면 이 드라이버의 send() 가 호출된다. Laravel 채널 계약 * (`send($notifiable, Notification $notification)`)을 구현한다. * - * 처리 흐름(계획서 Phase 3 ②): - * 1. sms 채널 알림 템플릿 resolve → 본문 렌더(변수 치환). 템플릿 없으면 - * NotificationSendSkippedException(Phase 6 에서 3영역 기본 body 시드 예정 — A안 확정). + * 본문 소스는 코어 알림 템플릿이 아니라 bizppurio_templates.sms_body 다 — 비즈뿌리오 + * 탭에서 운영자가 알림별로 입력한 본문(#{var} 치환)이 그대로 발송된다. sms_body 는 + * 로케일별 맵이며 수신자 로케일로 렌더한다(알림톡 content 의 단일 언어 제약은 카카오 + * 승인 규칙 때문이고 SMS 에는 적용되지 않는다 — §14.3). + * + * 발송 게이트: 행 존재 + is_active + 어느 로케일에든 본문 있음. sms_only 여부와 무관하다 — + * sms 채널이 켜진 알림이면 발송하며, sms_only 는 화면 표시·알림톡 게이트용 플래그다. + * + * 처리 흐름: + * 1. 템플릿 행 조회 → 게이트 판정. 불충족 시 NotificationSendSkippedException + * (코어 NotificationDispatcher 의 catch 가 실패로 기록 — 이슈 #28 계약 유지). * 2. 전화번호 해석: 회원=Notifiable->mobile, 비회원=알림 data 의 _recipient_phone * (게스트 전화번호는 각 도메인 extract_data 리스너가 data 에 주입 — D1). - * 3. refkey(우리 부여 unique 키) 생성 → SmsTypeResolver 로 SMS/LMS 판별 → + * 3. sms_body 의 #{var} 치환 → refkey 생성 → SmsTypeResolver 로 SMS/LMS 판별 → * MessagePayloadBuilder 로 payload 조립 → SendMessageJob 위임(발송·재시도는 Job 책임). - * - * 1~2단계는 비즈뿌리오 API 호출 자체를 시도하지 못하는 사전 조건 미비 상태라 - * NotificationSendSkippedException 을 던진다. 코어 NotificationDispatcher 의 - * catch(\Exception)가 이를 channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 - * "실패"로 정확히 기록되게 한다(조용히 return 하면 코어가 "정상 처리 완료"로 오인해 성공으로 - * 기록하는 문제 — 이슈 #28). - * - * 발송 이력(bizppurio_dispatches) 영속화는 Phase 4(테이블 신설)에서 이 흐름에 연결한다. - * Phase 3 은 refkey 생성 + payload 조립 + Job 위임까지 담당한다. */ class SmsChannelDriver { @@ -52,17 +51,19 @@ class SmsChannelDriver public const RECIPIENT_PHONE_KEY = '_recipient_phone'; /** - * @param NotificationTemplateService $templateService sms 채널 본문 템플릿 resolve - * @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지용) + * @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지·LMS 제목용) + * @param BizppurioTemplateRepositoryInterface $templates 알림 템플릿 행 조회(발송 게이트·본문 소스) + * @param AlimtalkPayloadMapper $payloadMapper sms_body 의 #{var} 치환 * @param SmsTypeResolver $typeResolver SMS/LMS byte 판별 * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 - * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4) + * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화 * @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2) * @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용) */ public function __construct( - private readonly NotificationTemplateService $templateService, private readonly NotificationDefinitionService $definitionService, + private readonly BizppurioTemplateRepositoryInterface $templates, + private readonly AlimtalkPayloadMapper $payloadMapper, private readonly SmsTypeResolver $typeResolver, private readonly MessagePayloadBuilder $payloadBuilder, private readonly BizppurioDispatchRepositoryInterface $dispatches, @@ -71,17 +72,21 @@ class SmsChannelDriver ) {} /** - * 알림 유형의 사람이 읽는 이름을 반환합니다 (스킵 예외 메시지용). + * 알림 유형의 사람이 읽는 이름을 반환합니다 (스킵 예외 메시지·LMS 제목용). * * 정의 조회 실패·이름 미설정 시 코드값(type)을 그대로 반환한다(안전 폴백). * + * LMS 제목으로 쓸 때는 수신자 로케일을 넘긴다 — 인자를 생략하면 앱 로케일이 적용되어 + * 본문과 제목의 언어가 갈린다. 운영자에게 보이는 스킵 예외 메시지는 앱 로케일이 맞다. + * * @param string $type 알림 유형 코드값 (welcome 등) + * @param string|null $locale 렌더 로케일 (null 이면 현재 앱 로케일) * @return string 사람이 읽는 이름 또는 코드값 */ - private function resolveTypeLabel(string $type): string + private function resolveTypeLabel(string $type, ?string $locale = null): string { try { - $label = $this->definitionService->resolve($type)?->getLocalizedName(); + $label = $this->definitionService->resolve($type)?->getLocalizedName($locale); return $label !== null && $label !== '' ? $label : $type; } catch (\Throwable $e) { @@ -106,11 +111,11 @@ class SmsChannelDriver $type = $notification->getType(); - // 1. sms 채널 본문 템플릿 resolve (없으면 발송 안 함 — Phase 6 에서 기본 body 시드) + // 1. 템플릿 행 조회 + 게이트 판정 (행 없음·비활성·본문 없음 → skip). // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를 // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다. - $template = $this->templateService->resolve($type, DispatchChannel::Sms->value); - if ($template === null || ! $template->is_active) { + $template = $this->templates->findByType($type); + if ($template === null || ! $template->is_active || ! $template->hasSmsBody()) { throw new NotificationSendSkippedException( __('sirsoft-message_bizppurio::messages.send_skipped.sms_template_missing', ['type' => $this->resolveTypeLabel($type)]) ); @@ -124,22 +129,27 @@ class SmsChannelDriver ); } - // 3. 본문 렌더 (변수 치환) + // 3. 본문 렌더 — 수신자 로케일의 sms_body 를 골라 #{var} 치환(알림톡 본문과 동일 규칙). + // 로케일 해석은 코어와 같은 규약(BaseNotification::resolveNotifiableLocale)을 쓴다. $locale = BaseNotification::resolveNotifiableLocale($notifiable); - $rendered = $template->replaceVariables($notification->getData(), $locale); - $message = (string) ($rendered['body'] ?? ''); + $message = $this->payloadMapper->substituteText( + trim($template->getLocalizedSmsBody($locale)), + $notification->getData() + ); if (trim($message) === '') { throw new NotificationSendSkippedException( __('sirsoft-message_bizppurio::messages.send_skipped.message_body_empty', ['type' => $this->resolveTypeLabel($type)]) ); } - // 4. refkey 생성 → SMS/LMS 판별 → payload 조립 + // 4. refkey 생성 → SMS/LMS 판별 → payload 조립. + // LMS 제목은 알림 정의 이름이며, 본문과 같은 수신자 로케일로 렌더한다 — 본문만 + // 로케일을 따르고 제목이 앱 로케일이면 한 통 안에서 언어가 갈린다. $refkey = $this->generateRefkey(); $channel = $this->typeResolver->resolve($message); $payload = $channel === DispatchChannel::Lms - ? $this->payloadBuilder->buildLms($to, $message, $refkey, (string) ($rendered['subject'] ?? '')) + ? $this->payloadBuilder->buildLms($to, $message, $refkey, $this->resolveTypeLabel($type, $locale)) : $this->payloadBuilder->buildSms($to, $message, $refkey); // 5. 발송 이력 pending 생성(Phase 4) → Job 위임. Job 이 refkey 로 조회해 sent/failed 갱신. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index 262d94b7..c1605f9b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -5,8 +5,8 @@ use App\Http\Middleware\RefreshTokenExpiration; use App\Services\PluginSettingsService; use Illuminate\Support\Facades\Route; use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController; +use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController; use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController; -use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController; use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\TokenCheckController; use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController; @@ -74,50 +74,49 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g /* |---------------------------------------------------------------------- - | 알림톡 템플릿 조회 (Phase 5) — 카카오 관리 API(kapi) 실시간 위임 + | 알림톡 작성 모달 참조 조회 (#597) — 발신프로필·카테고리 |---------------------------------------------------------------------- | - | 조회 전용(list/detail/categories/profiles) = messaging.view. - | 템플릿 등록·수정·삭제·검수·상태변경은 비즈뿌리오 콘솔로 위임한다(이 화면은 목록·상태· - | 내용 조회 + 알림 연결만 담당). 템플릿은 DB 저장 없이 매 요청 실시간 조회하며, 설정 - | 페이지 알림톡 템플릿 탭이 소비한다. + | 알림톡 템플릿 작성 모달의 발신프로필 셀렉트·카테고리 셀렉트가 소비한다. + | 실시간 목록/상세 화면(구 Phase 5)은 DB 기반 라이프사이클로 대체되어 제거됐다. */ Route::prefix('alimtalk-templates')->name('alimtalk-templates.')->group(function () { - // 발송 템플릿 내용 캐시 초기화(수동 갱신) — 카카오에서 템플릿을 방금 바꿔 즉시 반영이 - // 필요할 때 관리자가 캐시를 비운다. 쓰기 동작이므로 messaging.manage 권한. 구체 경로를 - // 먼저 두어 GET /{templateCode} 와 충돌하지 않게 한다. - Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () { - Route::post('/cache/clear', [AlimtalkTemplateController::class, 'clearCache'])->name('cache.clear'); - }); - 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'); }); }); /* |---------------------------------------------------------------------- - | 알림↔알림톡 템플릿 연동 (Phase 6 재설계) — 코어 편집 모달 전용 칸이 소비 + | 비즈뿌리오 알림 템플릿 라이프사이클 (#597 §3.2) |---------------------------------------------------------------------- | - | 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다(⚑⚑ 결정 A). 연결 템플릿·SMS 대체 - | 입력은 코어 편집 모달에 얹은 전용 칸(플러그인 overlay)에서 하고, 값을 바꾸면 즉시 이 API - | 로 저장한다(PO 확정 UX — 별도 저장 버튼 없이 변경 즉시 저장, 코어 저장 버튼 무관 → - | 코어 템플릿 무오염). + | 시스템 등록(draft) → 검수 신청(requested) → 승인(approved) 후 발송 활성화. + | 발송 판정은 DB(bizppurio_templates)가 유일한 근거이며, 카카오 상태 정합은 + | 스케줄러(bizppurio:sync-template-status)와 수동 sync 가 유지한다. | - | 조회(index/approved-templates) = messaging.view / 저장(store) = messaging.manage. + | 조회(index/map/show) = messaging.view / 그 외 변경 = messaging.manage. + | 구체 경로(map/image)를 {id} 보다 먼저 두어 라우트 충돌을 막는다. */ - Route::prefix('notification-bindings')->name('notification-bindings.')->group(function () { + Route::prefix('templates')->name('templates.')->group(function () { Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { - Route::get('/', [NotificationBindingController::class, 'index'])->name('index'); - Route::get('/approved-templates', [NotificationBindingController::class, 'approvedTemplates'])->name('approved-templates'); + Route::get('/', [BizppurioTemplateController::class, 'index'])->name('index'); + Route::get('/map', [BizppurioTemplateController::class, 'map'])->name('map'); + Route::get('/{id}', [BizppurioTemplateController::class, 'show'])->whereNumber('id')->name('show'); }); Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () { - Route::post('/', [NotificationBindingController::class, 'store'])->name('store'); + Route::post('/', [BizppurioTemplateController::class, 'store'])->name('store'); + Route::post('/image', [BizppurioTemplateController::class, 'uploadImage'])->name('image'); + Route::put('/delivery/{notificationType}', [BizppurioTemplateController::class, 'upsertDelivery'])->name('delivery'); + Route::put('/{id}', [BizppurioTemplateController::class, 'update'])->whereNumber('id')->name('update'); + Route::post('/{id}/request', [BizppurioTemplateController::class, 'requestInspection'])->whereNumber('id')->name('request'); + Route::post('/{id}/cancel-request', [BizppurioTemplateController::class, 'cancelRequest'])->whereNumber('id')->name('cancel-request'); + Route::post('/{id}/cancel-approval', [BizppurioTemplateController::class, 'cancelApproval'])->whereNumber('id')->name('cancel-approval'); + Route::post('/{id}/release', [BizppurioTemplateController::class, 'release'])->whereNumber('id')->name('release'); + Route::post('/{id}/sync', [BizppurioTemplateController::class, 'sync'])->whereNumber('id')->name('sync'); + Route::delete('/{id}', [BizppurioTemplateController::class, 'destroy'])->whereNumber('id')->name('destroy'); }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php index 7548335e..96af2cf4 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php @@ -9,16 +9,17 @@ use Mockery; use Mockery\MockInterface; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; -use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** - * 알림톡 템플릿 조회 컨트롤러 Feature 테스트 (조회 전용). + * 알림톡 작성 모달 참조 조회 컨트롤러 Feature 테스트 (#597). * * 라우트 → 컨트롤러 → 서비스 경계를 검증한다. kapi 실제 호출 로직은 * AlimtalkTemplateServiceTest 가 검증하므로, 여기서는 AlimtalkTemplateService 를 - * 컨테이너에 mock 으로 바인딩해 조회 권한 경계(view)·응답 봉투·kapi 실패(예외)의 422 - * 전파·등록 라우트 제거(405)를 격리 검증한다. + * 컨테이너에 mock 으로 바인딩해 조회 권한 경계(view)·응답 봉투·kapi 실패(예외)의 + * 422 전파를 격리 검증한다. 실시간 목록/상세 화면(구 Phase 5)은 DB 기반 + * 라이프사이클(BizppurioTemplateController)로 대체되어 제거됐다 — 남은 라우트는 + * categories/profiles 둘뿐이다. * * @since 1.0.0 */ @@ -88,169 +89,89 @@ class AlimtalkTemplateControllerTest extends PluginTestCase } /** - * @scenario auth=view,service=ok - * - * @effects list_returns_templates_with_status_badge + * view 권한으로 카테고리 전체를 조회한다 (data.categories 봉투). */ - public function test_view권한으로_목록을_조회한다(): void + 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], + $this->mockService()->shouldReceive('categories')->once()->andReturn([ + ['code' => '001001', 'name' => '회원가입', 'groupName' => '회원'], ]); $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) - ->getJson(self::BASE); + ->getJson(self::BASE.'/categories'); $response->assertStatus(200); - $response->assertJsonPath('data.templates.0.templateCode', 'TW_1'); - $response->assertJsonPath('data.templates.0.status_badge.variant', 'green'); + $response->assertJsonPath('data.categories.0.code', '001001'); + $response->assertJsonPath('data.categories.0.groupName', '회원'); } /** - * @scenario auth=guest - * - * @effects list_requires_authentication_returns_401 + * view 권한으로 발신프로필 목록을 조회한다 (data.profiles 봉투). + */ + public function test_view권한으로_발신프로필을_조회한다(): void + { + $this->mockService()->shouldReceive('senderProfiles')->once()->andReturn([ + ['senderKey' => 'SK_40', 'name' => '테스트채널', 'status' => 'A'], + ]); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->getJson(self::BASE.'/profiles'); + + $response->assertStatus(200); + $response->assertJsonPath('data.profiles.0.senderKey', 'SK_40'); + $response->assertJsonPath('data.profiles.0.name', '테스트채널'); + } + + /** + * 비인증 요청은 401 이다. */ public function test_비인증은_401(): void { - $this->getJson(self::BASE)->assertStatus(401); + $this->getJson(self::BASE.'/categories')->assertStatus(401); + $this->getJson(self::BASE.'/profiles')->assertStatus(401); } /** - * @scenario auth=none_of_required,action=index - * - * @effects list_requires_view_permission_returns_403 + * view 권한이 없으면 두 라우트 모두 403 이다. */ - public function test_view권한_없으면_목록조회_403(): void + public function test_view권한_없으면_403(): void { - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.other'])) - ->getJson(self::BASE); + $headers = $this->authHeaders(['sirsoft-message_bizppurio.messaging.other']); - $response->assertStatus(403); + $this->withHeaders($headers)->getJson(self::BASE.'/categories')->assertStatus(403); + $this->withHeaders($headers)->getJson(self::BASE.'/profiles')->assertStatus(403); } /** - * @scenario auth=view,service=ok - * - * @effects show_returns_template_detail_with_status_badge - */ - public function test_view권한으로_상세를_조회한다(): void - { - $this->mockService()->shouldReceive('detail')->once()->with('TW_1')->andReturn([ - 'templateCode' => 'TW_1', - 'templateName' => '주문완료', - 'status_badge' => ['variant' => 'green'], - ]); - - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) - ->getJson(self::BASE.'/TW_1'); - - $response->assertStatus(200); - $response->assertJsonPath('data.template.templateCode', 'TW_1'); - $response->assertJsonPath('data.template.status_badge.variant', 'green'); - } - - /** - * @scenario auth=view,action=store - * - * @effects store_route_removed_returns_405_read_only - */ - public function test_등록_라우트는_제거되어_조회전용이다(): void - { - // 조회 전용 전환으로 등록(POST)·상태변경 라우트를 제거했다. manage 미들웨어 블록이 - // 사라졌으므로 POST 는 라우트 미매칭(405)이 되어야 한다(등록은 비즈뿌리오 콘솔). - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) - ->postJson(self::BASE, [ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'NONE', - ]); - - $response->assertStatus(405); - } - - /** - * @scenario auth=view,service=throws - * - * @effects kapi_failure_is_surfaced_as_422_with_result_code + * kapi 실패는 카카오 사유(message)·결과코드가 담긴 422 로 전파된다 (GuardsKakaoRequests 규약). */ public function test_kapi_실패는_422로_전파된다(): void { - $this->mockService()->shouldReceive('list')->once() + $this->mockService()->shouldReceive('categories')->once() + ->andThrow(new BizppurioApiException('접근할 수 없는 IP 입니다.', resultCode: '403')); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->getJson(self::BASE.'/categories'); + + $response->assertStatus(422); + $response->assertJsonPath('errors.bizppurio_message', '접근할 수 없는 IP 입니다.'); + $response->assertJsonPath('errors.result_code', '403'); + } + + /** + * 발신프로필 조회의 kapi 실패도 동일한 422 규약으로 전파된다. + */ + public function test_발신프로필_kapi_실패도_422로_전파된다(): void + { + $this->mockService()->shouldReceive('senderProfiles')->once() ->andThrow(new BizppurioApiException('발신프로필을 찾을 수 없습니다.', resultCode: '7204')); $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) - ->getJson(self::BASE); + ->getJson(self::BASE.'/profiles'); $response->assertStatus(422); + $response->assertJsonPath('errors.bizppurio_message', '발신프로필을 찾을 수 없습니다.'); $response->assertJsonPath('errors.result_code', '7204'); - $response->assertJsonPath('errors.kakao_message', '발신프로필을 찾을 수 없습니다.'); - } - - /** - * @scenario auth=view,kapi_result=not_found - * - * @effects list_treats_kapi_508_as_empty_result_not_error - */ - public function test_kapi_508은_에러가_아니라_빈_목록으로_응답한다(): void - { - $this->mockService()->shouldReceive('list')->once()->andReturn([ - 'templates' => [], - 'pagination' => ['total' => 0, 'total_page' => 1, 'current_page' => 1, 'per_page' => 20], - ]); - - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) - ->getJson(self::BASE.'?keyword=대글'); - - $response->assertStatus(200); - $response->assertJsonPath('data.templates', []); - $response->assertJsonPath('data.pagination.total', 0); - } - - /** - * @scenario auth=manage,action=clear_cache - * - * @effects clear_cache_delegates_to_binding_service_and_returns_count - */ - public function test_manage권한으로_발송내용_캐시를_초기화한다(): void - { - $bindings = Mockery::mock(NotificationBindingService::class); - $bindings->shouldReceive('clearTemplateContentCache')->once()->andReturn(3); - $this->app->instance(NotificationBindingService::class, $bindings); - - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) - ->postJson(self::BASE.'/cache/clear'); - - $response->assertStatus(200); - $response->assertJsonPath('data.cleared', 3); - } - - /** - * @scenario auth=view_only,action=clear_cache - * - * @effects clear_cache_requires_manage_permission_returns_403 - */ - public function test_캐시초기화는_manage권한이_없으면_403(): void - { - // 조회(view) 권한만으로는 캐시 초기화(쓰기)를 할 수 없다. - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) - ->postJson(self::BASE.'/cache/clear'); - - $response->assertStatus(403); - } - - /** - * @scenario auth=guest,action=clear_cache - * - * @effects clear_cache_requires_authentication_returns_401 - */ - public function test_캐시초기화는_비인증이면_401(): void - { - $this->postJson(self::BASE.'/cache/clear')->assertStatus(401); } protected function tearDown(): void 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 ef9e92c9..380af4ce 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php @@ -6,6 +6,7 @@ use App\Enums\ExtensionOwnerType; use App\Models\Menu; use Illuminate\Support\Facades\Schema; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate; use Plugins\Sirsoft\MessageBizppurio\Plugin; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; @@ -135,17 +136,26 @@ class InstallationTest extends PluginTestCase } /** - * Phase 4 발송 이력·연동 테이블이 마이그레이션으로 생성된다. + * Phase 4 발송 이력·알림 템플릿 테이블이 마이그레이션으로 생성된다. + * + * bizppurio_notification_bindings 는 #597 개편으로 bizppurio_templates(알림톡 등록 + * 페이로드·승인 스냅샷·검수 상태 + SMS 본문)로 대체되었다. */ public function test_phase4_테이블이_생성된다(): void { $this->assertTrue(Schema::hasTable('bizppurio_dispatches')); - $this->assertTrue(Schema::hasTable('bizppurio_notification_bindings')); + $this->assertTrue(Schema::hasTable('bizppurio_templates')); $this->assertTrue(Schema::hasColumns('bizppurio_dispatches', [ 'refkey', 'messagekey', 'channel', 'to_number', 'to_user_id', 'status', 'result_code', 'reported_at', 'raw_payload', ])); + + $this->assertTrue(Schema::hasColumns('bizppurio_templates', [ + 'notification_type', 'alimtalk_enabled', 'template_code', 'sender_key', + 'content', 'approved_content', 'status', 'inspection_detail', + 'fallback_sms_enabled', 'sms_body', 'sms_only', 'is_active', + ])); } /** @@ -158,10 +168,12 @@ class InstallationTest extends PluginTestCase // down $this->artisan('migrate:rollback', ['--path' => $migrations, '--realpath' => true])->run(); $this->assertFalse(Schema::hasTable('bizppurio_dispatches')); + $this->assertFalse(Schema::hasTable('bizppurio_templates'), '롤백 시 bizppurio_templates 도 함께 제거되어야 한다.'); // up $this->artisan('migrate', ['--path' => $migrations, '--realpath' => true])->run(); $this->assertTrue(Schema::hasTable('bizppurio_dispatches')); + $this->assertTrue(Schema::hasTable('bizppurio_templates'), '재실행 시 bizppurio_templates 가 다시 생성되어야 한다.'); // 왕복 후 Repository 호출 1회전 (create → 조회) $dispatch = BizppurioDispatch::create([ @@ -174,5 +186,15 @@ class InstallationTest extends PluginTestCase ]); $this->assertNotNull(BizppurioDispatch::query()->byRefkey('roundtrip')->first()); $this->assertSame('roundtrip', $dispatch->refkey); + + // 왕복 후 템플릿 모델 1회전 — unique(notification_type)·JSON 캐스팅이 살아 있는지 확인 + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'alimtalk_enabled' => true, + 'content' => ['templateName' => '가입 환영'], + 'sms_body' => ['ko' => '가입을 환영합니다.', 'en' => 'Welcome aboard.'], + ]); + $this->assertNotNull(BizppurioTemplate::query()->where('notification_type', 'welcome')->first()); + $this->assertSame(['templateName' => '가입 환영'], $template->content); } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php deleted file mode 100644 index 106c2050..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php +++ /dev/null @@ -1,251 +0,0 @@ -app->instance(AlimtalkTemplateService::class, $mock); - - return $mock; - } - - /** - * 지정 template_code 목록을 승인(ACT) 상태로 반환하는 mock 을 등록한다. - * - * @param array $codes 승인 처리할 template_code 목록 - */ - private function stubApprovedTemplates(array $codes): void - { - $templates = array_map( - fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'], - $codes, - ); - - $this->mockTemplateService() - ->shouldReceive('list') - ->andReturn(['templates' => $templates, 'pagination' => []]); - } - - /** - * 지정 권한 식별자들을 가진 admin 사용자를 만듭니다. - * - * @param array $permissionIds 부여할 권한 식별자 - */ - private function adminWith(array $permissionIds): User - { - $user = User::factory()->create(); - - $adminRole = Role::firstOrCreate( - ['identifier' => 'admin'], - ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] - ); - - $permIds = []; - foreach ($permissionIds as $identifier) { - $permission = Permission::firstOrCreate( - ['identifier' => $identifier], - ['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin'] - ); - $permIds[] = $permission->id; - } - - $testRole = Role::create([ - 'identifier' => 'bizppurio_binding_test_'.uniqid(), - 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), - 'type' => 'admin', - ]); - $testRole->permissions()->sync($permIds); - - $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(); - } - - /** - * 사용자 토큰으로 요청 헤더를 만듭니다. - */ - private function authHeaders(User $user): array - { - return [ - 'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken, - 'Accept' => 'application/json', - ]; - } - - public function test_인증_없이_목록_조회는_401이다(): void - { - $this->getJson(self::BASE)->assertStatus(401); - } - - public function test_view_권한으로_연동_맵을_조회한다(): void - { - BizppurioNotificationBinding::create([ - 'notification_type' => 'welcome', - 'channel' => 'alimtalk', - 'template_code' => 'TW_1236', - 'template_name' => '가입환영', - 'fallback_sms_enabled' => true, - 'is_active' => true, - ]); - $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); - - $response = $this->withHeaders($this->authHeaders($admin))->getJson(self::BASE); - - $response->assertStatus(200); - $response->assertJsonPath('data.bindings.welcome.template_code', 'TW_1236'); - $response->assertJsonPath('data.bindings.welcome.fallback_sms_enabled', true); - } - - public function test_연동이_없으면_빈_맵을_반환한다(): void - { - $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); - - $response = $this->withHeaders($this->authHeaders($admin))->getJson(self::BASE); - - $response->assertStatus(200); - $response->assertJsonPath('data.bindings', []); - } - - public function test_권한_없는_사용자는_승인템플릿_조회_403이다(): void - { - $admin = $this->adminWith([]); // messaging.view 없음 - - $response = $this->withHeaders($this->authHeaders($admin)) - ->getJson(self::BASE.'/approved-templates'); - - $response->assertStatus(403); - } - - public function test_view_권한만으로는_저장할_수_없다(): void - { - $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); - - $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ - 'notification_type' => 'welcome', - 'template_code' => 'TW_1236', - 'template_name' => '가입환영', - ]); - - $response->assertStatus(403); - } - - public function test_manage_권한으로_연동을_즉시_저장한다(): void - { - $this->stubApprovedTemplates(['TW_1236']); - $admin = $this->adminWith([ - 'sirsoft-message_bizppurio.messaging.view', - 'sirsoft-message_bizppurio.messaging.manage', - ]); - - $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ - 'notification_type' => 'welcome', - 'template_code' => 'TW_1236', - 'template_name' => '가입환영', - 'fallback_sms_enabled' => true, - ]); - - $response->assertStatus(200); - $this->assertDatabaseHas('bizppurio_notification_bindings', [ - 'notification_type' => 'welcome', - 'channel' => 'alimtalk', - 'template_code' => 'TW_1236', - 'fallback_sms_enabled' => true, - ]); - } - - public function test_미승인_템플릿_코드로_저장하면_422이고_저장되지_않는다(): void - { - // 회귀: 화면 드롭다운은 승인 템플릿만 보여주지만, 그 필터를 우회해 API 를 직접 - // 호출하면 미승인 template_code 도 저장되던 결함. bind() 서버측 재검증으로 차단. - $this->stubApprovedTemplates(['TW_OTHER']); - $admin = $this->adminWith([ - 'sirsoft-message_bizppurio.messaging.view', - 'sirsoft-message_bizppurio.messaging.manage', - ]); - - $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ - 'notification_type' => 'welcome', - 'template_code' => 'TW_UNAPPROVED', - 'template_name' => '미승인', - ]); - - $response->assertStatus(422); - $this->assertDatabaseMissing('bizppurio_notification_bindings', [ - 'notification_type' => 'welcome', - 'template_code' => 'TW_UNAPPROVED', - ]); - } - - public function test_빈_코드로_저장하면_연동을_해제한다(): void - { - BizppurioNotificationBinding::create([ - 'notification_type' => 'welcome', - 'channel' => 'alimtalk', - 'template_code' => 'TW_1236', - 'template_name' => '가입환영', - 'fallback_sms_enabled' => false, - 'is_active' => true, - ]); - - $admin = $this->adminWith([ - 'sirsoft-message_bizppurio.messaging.view', - 'sirsoft-message_bizppurio.messaging.manage', - ]); - - // 해제는 카카오 승인 조회를 거치지 않으므로 mock 불필요. - $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ - 'notification_type' => 'welcome', - 'template_code' => '', - ]); - - $response->assertStatus(200); - $this->assertDatabaseMissing('bizppurio_notification_bindings', [ - 'notification_type' => 'welcome', - 'channel' => 'alimtalk', - ]); - } - - public function test_notification_type_누락_시_422다(): void - { - $admin = $this->adminWith([ - 'sirsoft-message_bizppurio.messaging.view', - 'sirsoft-message_bizppurio.messaging.manage', - ]); - - // FormRequest 검증(notification_type required)이 서비스 호출보다 먼저 실패하므로 mock 불필요. - $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ - 'template_code' => 'TW_1236', - ]); - - $response->assertStatus(422); - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php new file mode 100644 index 00000000..361fe238 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php @@ -0,0 +1,1061 @@ + $permissionIds 부여할 권한 식별자 + */ + private function adminWith(array $permissionIds): User + { + $user = User::factory()->create(); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $permIds = []; + foreach ($permissionIds as $identifier) { + $permission = Permission::firstOrCreate( + ['identifier' => $identifier], + ['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin'] + ); + $permIds[] = $permission->id; + } + + $testRole = Role::create([ + 'identifier' => 'bizppurio_tpl_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + $testRole->permissions()->sync($permIds); + + $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(); + } + + /** + * 사용자 토큰 헤더를 만듭니다. + */ + private function authHeaders(User $user): array + { + return [ + 'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken, + 'Accept' => 'application/json', + ]; + } + + /** + * 알림 정의 1건을 만듭니다 (notification_type 존재 검증 대상). + */ + private function definition(string $type = 'welcome'): NotificationDefinition + { + return NotificationDefinition::create([ + 'type' => $type, + 'hook_prefix' => 'core.auth', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'name' => ['ko' => '회원가입 환영', 'en' => 'Welcome'], + 'variables' => [['key' => 'name', 'description' => '이름']], + 'channels' => ['mail'], + 'hooks' => ['core.auth.after_register'], + 'is_active' => true, + ]); + } + + /** + * kapi 자격증명·발신프로필 키를 플러그인 설정에 저장합니다 (검수 신청 경로용). + */ + private function seedKakaoSettings(): void + { + app(PluginSettingsService::class)->save('sirsoft-message_bizppurio', [ + 'bizppurio_id' => 'biz1', + 'api_key' => 'key1', + 'sender_key' => 'SK_TEST', + ]); + } + + /** + * 유효한 BA×NONE content 를 만듭니다. + * + * @return array + */ + private function baseContent(): array + { + return [ + 'templateName' => '환영 알림톡', + 'templateMessageType' => 'BA', + 'templateEmphasizeType' => 'NONE', + 'templateContent' => '#{name}님 가입을 환영합니다.', + 'categoryCode' => '001001', + ]; + } + + public function test_권한_없는_관리자는_목록을_볼_수_없다(): void + { + $user = $this->adminWith([]); + + $this->getJson(self::BASE, $this->authHeaders($user))->assertStatus(403); + } + + public function test_view_권한으로_목록을_조회하면_정의_라벨이_조인된다(): void + { + $this->definition(); + BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => BizppurioTemplateStatus::Draft->value, + ]); + $user = $this->adminWith([self::VIEW]); + + $response = $this->getJson(self::BASE, $this->authHeaders($user))->assertOk(); + + $row = $response->json('data.templates.0'); + $this->assertSame('welcome', $row['notification_type']); + $this->assertSame('회원가입 환영', $row['definition_name']['ko'] ?? null, '알림 정의 라벨이 조인돼야 한다.'); + $this->assertSame('core', $row['definition_extension_type']); + $this->assertArrayHasKey('last_page', $response->json('data.pagination'), '페이지네이션 계약(last_page 노출).'); + } + + public function test_목록은_상태_필터와_검색을_지원한다(): void + { + $this->definition('welcome'); + $this->definition('order_confirmed'); + BizppurioTemplate::create(['notification_type' => 'welcome', 'status' => 'approved']); + BizppurioTemplate::create(['notification_type' => 'order_confirmed', 'status' => 'draft']); + $user = $this->adminWith([self::VIEW]); + + $this->getJson(self::BASE.'?status=approved', $this->authHeaders($user)) + ->assertOk() + ->assertJsonCount(1, 'data.templates') + ->assertJsonPath('data.templates.0.notification_type', 'welcome'); + + $this->getJson(self::BASE.'?search=order', $this->authHeaders($user)) + ->assertOk() + ->assertJsonCount(1, 'data.templates') + ->assertJsonPath('data.templates.0.notification_type', 'order_confirmed'); + } + + public function test_목록_검색은_정의_표시명_한글로도_매칭된다(): void + { + // 회귀 배경(§6.3 실측): nd.name 은 translatable JSON 컬럼이라 raw LIKE 는 + // 유니코드 이스케이프 저장값과 비교되어 비ASCII 검색이 항상 0건이었다. + // 운영자가 화면에서 보는 표시명("회원가입 환영")으로 검색이 돼야 한다. + $this->definition('welcome'); + $this->definition('order_confirmed') + ->update(['name' => ['ko' => '주문 완료', 'en' => 'Order confirmed']]); + BizppurioTemplate::create(['notification_type' => 'welcome', 'status' => 'draft']); + BizppurioTemplate::create(['notification_type' => 'order_confirmed', 'status' => 'draft']); + $user = $this->adminWith([self::VIEW]); + + $this->getJson(self::BASE.'?search='.urlencode('회원'), $this->authHeaders($user)) + ->assertOk() + ->assertJsonCount(1, 'data.templates') + ->assertJsonPath('data.templates.0.notification_type', 'welcome'); + } + + public function test_알_수_없는_상태_필터는_422다(): void + { + $user = $this->adminWith([self::VIEW]); + + $this->getJson(self::BASE.'?status=nope', $this->authHeaders($user))->assertStatus(422); + } + + public function test_map은_notification_type_키_요약을_내려준다(): void + { + $this->definition(); + BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'approved', + 'content' => $this->baseContent(), + 'sms_body' => ['ko' => '[샵] 환영', 'en' => '[Shop] Welcome'], + 'fallback_sms_enabled' => true, + ]); + $user = $this->adminWith([self::VIEW]); + + $response = $this->getJson(self::BASE.'/map', $this->authHeaders($user))->assertOk(); + + $entry = $response->json('data.templates.welcome'); + $this->assertNotNull($entry, 'notification_type 을 키로 하는 맵이어야 한다.'); + $this->assertSame('approved', $entry['status']); + $this->assertTrue($entry['has_content'], 'content 존재 플래그(미작성/작성중 분기)가 실려야 한다.'); + // MySQL json 은 키를 (길이, 사전순)으로 정규화해 저장하므로 순서 비의존 단언을 쓴다 + // (§12.2 T1 과 같은 원인 — assertSame 은 키 순서까지 비교해 항상 실패한다). + $this->assertEqualsCanonicalizing( + ['ko' => '[샵] 환영', 'en' => '[Shop] Welcome'], + $entry['sms_body'], + 'sms_body 는 로케일 맵 그대로 실려야 한다(§14.3).' + ); + } + + public function test_생성은_manage_권한이_필요하다(): void + { + $this->definition(); + $user = $this->adminWith([self::VIEW]); + + $this->postJson(self::BASE, ['notification_type' => 'welcome'], $this->authHeaders($user)) + ->assertStatus(403); + } + + public function test_생성하면_draft_상태다(): void + { + $this->definition(); + $user = $this->adminWith([self::MANAGE]); + + $this->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'content' => $this->baseContent(), + ], $this->authHeaders($user)) + ->assertStatus(201) + ->assertJsonPath('data.template.status', 'draft'); + + $this->assertDatabaseHas('bizppurio_templates', ['notification_type' => 'welcome']); + } + + public function test_존재하지_않는_알림_정의로는_생성할_수_없다(): void + { + $user = $this->adminWith([self::MANAGE]); + + $this->postJson(self::BASE, ['notification_type' => 'ghost'], $this->authHeaders($user)) + ->assertStatus(422) + ->assertJsonValidationErrors('notification_type'); + } + + /** + * @effects notification_type_unique_one_row_per_definition + */ + public function test_알림당_1행_unique가_강제된다(): void + { + $this->definition(); + BizppurioTemplate::create(['notification_type' => 'welcome', 'status' => 'draft']); + $user = $this->adminWith([self::MANAGE]); + + $this->postJson(self::BASE, ['notification_type' => 'welcome'], $this->authHeaders($user)) + ->assertStatus(422) + ->assertJsonValidationErrors('notification_type'); + } + + /** + * FormRequest 유형 매트릭스 (§3.2 표 그대로). + * + * @return array, 1: string|null}> + */ + public static function contentMatrixProvider(): array + { + $base = [ + 'templateName' => '이름', + 'templateMessageType' => 'BA', + 'templateEmphasizeType' => 'NONE', + 'templateContent' => '본문', + 'categoryCode' => '001001', + ]; + + return [ + '공통_templateName_누락' => [array_diff_key($base, ['templateName' => 1]), 'content.templateName'], + '공통_messageType_허용값_외' => [array_merge($base, ['templateMessageType' => 'ZZ']), 'content.templateMessageType'], + '공통_본문_1000자_초과' => [array_merge($base, ['templateContent' => str_repeat('가', 1001)]), 'content.templateContent'], + '공통_본문_1000자_경계는_통과' => [array_merge($base, ['templateContent' => str_repeat('가', 1000)]), null], + 'EX는_부가정보_필수' => [array_merge($base, ['templateMessageType' => 'EX']), 'content.templateExtra'], + 'MI는_부가정보_필수' => [array_merge($base, ['templateMessageType' => 'MI']), 'content.templateExtra'], + 'EX_부가정보_있으면_통과' => [array_merge($base, ['templateMessageType' => 'EX', 'templateExtra' => '부가']), null], + 'TEXT는_타이틀_필수' => [array_merge($base, ['templateEmphasizeType' => 'TEXT', 'templateSubtitle' => '보조']), 'content.templateTitle'], + 'TEXT는_서브타이틀_필수' => [array_merge($base, ['templateEmphasizeType' => 'TEXT', 'templateTitle' => '강조']), 'content.templateSubtitle'], + 'TEXT_쌍이_있으면_통과' => [array_merge($base, ['templateEmphasizeType' => 'TEXT', 'templateTitle' => '강조', 'templateSubtitle' => '보조']), null], + 'IMAGE는_이미지_url_필수' => [array_merge($base, ['templateEmphasizeType' => 'IMAGE', 'templateImageName' => 'img.png']), 'content.templateImageUrl'], + 'ITEM_LIST는_아이템_필수' => [array_merge($base, ['templateEmphasizeType' => 'ITEM_LIST']), 'content.templateItem'], + 'ITEM_LIST_아이템_1개는_min2_위반' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => ['list' => [['title' => '품목', 'description' => '설명']]], + ]), 'content.templateItem.list'], + 'ITEM_LIST_아이템_제목_6자_초과' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => ['list' => [ + ['title' => '일곱글자제목임', 'description' => '설명'], + ['title' => '품목', 'description' => '설명'], + ]], + ]), 'content.templateItem.list.0.title'], + 'ITEM_LIST_2개면_통과' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => ['list' => [ + ['title' => '품목1', 'description' => '설명1'], + ['title' => '품목2', 'description' => '설명2'], + ]], + ]), null], + '버튼_6개는_max5_위반' => [array_merge($base, [ + 'buttons' => array_fill(0, 6, ['name' => '버튼', 'linkType' => 'WL', 'linkMo' => 'https://m.example.com']), + ]), 'content.buttons'], + 'WL버튼은_linkMo_필수' => [array_merge($base, [ + 'buttons' => [['name' => '버튼', 'linkType' => 'WL']], + ]), 'content.buttons.0.linkMo'], + 'AL버튼은_양_스킴_필수' => [array_merge($base, [ + 'buttons' => [['name' => '버튼', 'linkType' => 'AL', 'linkAnd' => 'app://x']], + ]), 'content.buttons.0.linkIos'], + 'TN버튼은_전화번호_필수' => [array_merge($base, [ + 'buttons' => [['name' => '전화', 'linkType' => 'TN']], + ]), 'content.buttons.0.telNumber'], + 'P1버튼은_pluginId_필수' => [array_merge($base, [ + 'buttons' => [['name' => '보안', 'linkType' => 'P1']], + ]), 'content.buttons.0.pluginId'], + '버튼명_14자_초과' => [array_merge($base, [ + 'buttons' => [['name' => str_repeat('가', 15), 'linkType' => 'WL', 'linkMo' => 'https://m.example.com']], + ]), 'content.buttons.0.name'], + '바로연결은_TN_불가' => [array_merge($base, [ + 'quickReplies' => [['name' => '전화', 'linkType' => 'TN', 'telNumber' => '021234']], + ]), 'content.quickReplies.0.linkType'], + '바로연결_WL은_통과' => [array_merge($base, [ + 'quickReplies' => [['name' => '바로', 'linkType' => 'WL', 'linkMo' => 'https://m.example.com']], + ]), null], + '하이라이트_썸네일_있으면_타이틀_21자_제한' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => ['list' => [ + ['title' => '품목1', 'description' => '설명1'], + ['title' => '품목2', 'description' => '설명2'], + ]], + 'templateItemHighlight' => [ + 'title' => str_repeat('가', 22), + 'imageUrl' => 'https://mud-kage.kakaocdn.net/img.png', + ], + ]), 'content.templateItemHighlight.title'], + '하이라이트_썸네일_없으면_30자까지_허용' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => ['list' => [ + ['title' => '품목1', 'description' => '설명1'], + ['title' => '품목2', 'description' => '설명2'], + ]], + 'templateItemHighlight' => ['title' => str_repeat('가', 30)], + ]), null], + + // --- 공통 필수·길이 --- + '공통_emphasizeType_허용값_외' => [array_merge($base, ['templateEmphasizeType' => 'ZZ']), 'content.templateEmphasizeType'], + '공통_templateContent_누락' => [array_diff_key($base, ['templateContent' => 1]), 'content.templateContent'], + '공통_categoryCode_누락' => [array_diff_key($base, ['categoryCode' => 1]), 'content.categoryCode'], + '공통_templateName_200자_초과' => [array_merge($base, ['templateName' => str_repeat('가', 201)]), 'content.templateName'], + '공통_previewMessage_40자_초과' => [array_merge($base, ['templatePreviewMessage' => str_repeat('가', 41)]), 'content.templatePreviewMessage'], + '공통_header_16자_초과' => [array_merge($base, ['templateHeader' => str_repeat('가', 17)]), 'content.templateHeader'], + '공통_securityFlag_boolean_아님' => [array_merge($base, ['securityFlag' => 'yes']), 'content.securityFlag'], + 'AD는_추가_필수필드_없이_통과' => [array_merge($base, ['templateMessageType' => 'AD']), null], + + // --- IMAGE --- + 'IMAGE는_이미지명_필수' => [array_merge($base, [ + 'templateEmphasizeType' => 'IMAGE', + 'templateImageUrl' => 'https://mud-kage.kakaocdn.net/img.png', + ]), 'content.templateImageName'], + + // --- ITEM_LIST --- + 'ITEM_LIST_아이템_11개는_max10_위반' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => ['list' => array_fill(0, 11, ['title' => '품목', 'description' => '설명'])], + ]), 'content.templateItem.list'], + 'ITEM_LIST_아이템_설명_23자_초과' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => ['list' => [ + ['title' => '품목1', 'description' => str_repeat('가', 24)], + ['title' => '품목2', 'description' => '설명2'], + ]], + ]), 'content.templateItem.list.0.description'], + 'ITEM_LIST_요약_제목_6자_초과' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => [ + 'list' => [ + ['title' => '품목1', 'description' => '설명1'], + ['title' => '품목2', 'description' => '설명2'], + ], + 'summary' => ['title' => str_repeat('가', 7), 'description' => '1,000원'], + ], + ]), 'content.templateItem.summary.title'], + 'ITEM_LIST_요약_설명_14자_초과' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => [ + 'list' => [ + ['title' => '품목1', 'description' => '설명1'], + ['title' => '품목2', 'description' => '설명2'], + ], + 'summary' => ['title' => '합계', 'description' => str_repeat('가', 15)], + ], + ]), 'content.templateItem.summary.description'], + + // --- 하이라이트 (설명 축·이미지 URL) --- + '하이라이트_썸네일_있으면_설명_13자_제한' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => ['list' => [ + ['title' => '품목1', 'description' => '설명1'], + ['title' => '품목2', 'description' => '설명2'], + ]], + 'templateItemHighlight' => [ + 'description' => str_repeat('가', 14), + 'imageUrl' => 'https://mud-kage.kakaocdn.net/img.png', + ], + ]), 'content.templateItemHighlight.description'], + '하이라이트_썸네일_없으면_설명_19자까지_허용' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => ['list' => [ + ['title' => '품목1', 'description' => '설명1'], + ['title' => '품목2', 'description' => '설명2'], + ]], + 'templateItemHighlight' => ['description' => str_repeat('가', 19)], + ]), null], + '하이라이트_이미지url_500자_초과' => [array_merge($base, [ + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateItem' => ['list' => [ + ['title' => '품목1', 'description' => '설명1'], + ['title' => '품목2', 'description' => '설명2'], + ]], + 'templateItemHighlight' => ['imageUrl' => 'https://x/'.str_repeat('a', 500)], + ]), 'content.templateItemHighlight.imageUrl'], + + // --- 버튼 --- + '버튼_linkType_허용값_외' => [array_merge($base, [ + 'buttons' => [['name' => '버튼', 'linkType' => 'ZZ']], + ]), 'content.buttons.0.linkType'], + 'AL버튼은_안드로이드_스킴도_필수' => [array_merge($base, [ + 'buttons' => [['name' => '버튼', 'linkType' => 'AL', 'linkIos' => 'app://x']], + ]), 'content.buttons.0.linkAnd'], + 'P2버튼은_pluginId_필수' => [array_merge($base, [ + 'buttons' => [['name' => '개인정보', 'linkType' => 'P2']], + ]), 'content.buttons.0.pluginId'], + 'P3버튼은_pluginId_필수' => [array_merge($base, [ + 'buttons' => [['name' => '결제', 'linkType' => 'P3']], + ]), 'content.buttons.0.pluginId'], + '버튼_링크_500자_초과' => [array_merge($base, [ + 'buttons' => [['name' => '버튼', 'linkType' => 'WL', 'linkMo' => 'https://m/'.str_repeat('a', 500)]], + ]), 'content.buttons.0.linkMo'], + + // --- 바로연결 --- + '바로연결_11개는_max10_위반' => [array_merge($base, [ + 'quickReplies' => array_fill(0, 11, ['name' => '바로', 'linkType' => 'BK']), + ]), 'content.quickReplies'], + '바로연결_이름_누락' => [array_merge($base, [ + 'quickReplies' => [['linkType' => 'BK']], + ]), 'content.quickReplies.0.name'], + '바로연결_BK_MD_BC_BT는_통과' => [array_merge($base, [ + 'quickReplies' => [ + ['name' => '봇키워드', 'linkType' => 'BK'], + ['name' => '메시지전달', 'linkType' => 'MD'], + ['name' => '상담톡', 'linkType' => 'BC'], + ['name' => '봇전환', 'linkType' => 'BT'], + ], + ]), null], + + // --- 대표링크 --- + '대표링크_500자_초과' => [array_merge($base, [ + 'templateRepresentLink' => ['linkMo' => 'https://m/'.str_repeat('a', 500)], + ]), 'content.templateRepresentLink.linkMo'], + ]; + } + + /** + * FormRequest 유형 매트릭스 전수 실행 (§3.2 표 그대로 — EX/TEXT 변형 대표 조합 포함). + * + * @scenario content_variant=ex_text, transition=draft_to_requested + * + * @effects ex_mi_require_template_extra, text_requires_title_and_subtitle, image_requires_image_url, item_list_requires_two_to_ten_items, buttons_max_five_with_linktype_conditional_fields, quick_replies_restricted_linktypes, highlight_length_depends_on_thumbnail_presence + * + * @param array $content content 페이로드 + * @param string|null $errorField 기대 검증 오류 필드 (null=통과) + */ + #[DataProvider('contentMatrixProvider')] + public function test_content_유형_매트릭스(array $content, ?string $errorField): void + { + $this->definition(); + $user = $this->adminWith([self::MANAGE]); + + $response = $this->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'content' => $content, + ], $this->authHeaders($user)); + + if ($errorField === null) { + $response->assertStatus(201); + } else { + $response->assertStatus(422)->assertJsonValidationErrors($errorField); + } + } + + public function test_수정_매트릭스도_store와_대칭이다(): void + { + $this->definition(); + $template = BizppurioTemplate::create(['notification_type' => 'welcome', 'status' => 'draft']); + $user = $this->adminWith([self::MANAGE]); + + // EX 인데 부가정보 누락 — Update 에서도 같은 조건부 필수가 걸려야 한다(대칭). + $this->putJson(self::BASE.'/'.$template->id, [ + 'content' => array_merge($this->baseContent(), ['templateMessageType' => 'EX']), + ], $this->authHeaders($user)) + ->assertStatus(422) + ->assertJsonValidationErrors('content.templateExtra'); + } + + /** + * @effects server_prunes_irrelevant_type_fields_before_save + */ + public function test_유형_전환_잔여_필드는_서버가_정돈해_저장한다(): void + { + $this->definition(); + $user = $this->adminWith([self::MANAGE]); + + // 화면이 폼 상태 전체를 보내는 상황 — NONE 인데 TEXT/IMAGE/ITEM_LIST 잔여값 동봉. + $response = $this->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'content' => array_merge($this->baseContent(), [ + 'templateTitle' => '잔여 타이틀', + 'templateImageUrl' => 'https://stale.example.com/x.png', + 'templateItem' => ['list' => [['title' => '잔여', 'description' => '잔여']]], + 'buttons' => [], + 'templateHeader' => '', + ]), + ], $this->authHeaders($user))->assertStatus(201); + + $content = $response->json('data.template.content'); + $this->assertArrayNotHasKey('templateTitle', $content, 'NONE 유형에서 TEXT 잔여 필드는 정돈돼야 한다.'); + $this->assertArrayNotHasKey('templateImageUrl', $content); + $this->assertArrayNotHasKey('templateItem', $content); + $this->assertArrayNotHasKey('buttons', $content, '빈 배열 버튼은 kapi 페이로드에서 제거돼야 한다.'); + $this->assertArrayNotHasKey('templateHeader', $content, '빈 문자열 선택 필드는 제거돼야 한다.'); + } + + public function test_requested_상태의_content_수정은_422다(): void + { + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'requested', + 'content' => $this->baseContent(), + ]); + $user = $this->adminWith([self::MANAGE]); + + $this->putJson(self::BASE.'/'.$template->id, [ + 'content' => array_merge($this->baseContent(), ['templateContent' => '다른 본문']), + ], $this->authHeaders($user))->assertStatus(422); + } + + public function test_없는_행은_404다(): void + { + $user = $this->adminWith([self::VIEW, self::MANAGE]); + + $this->getJson(self::BASE.'/999999', $this->authHeaders($user))->assertStatus(404); + $this->putJson(self::BASE.'/999999', [], $this->authHeaders($user))->assertStatus(404); + $this->postJson(self::BASE.'/999999/request', [], $this->authHeaders($user))->assertStatus(404); + $this->deleteJson(self::BASE.'/999999', [], $this->authHeaders($user))->assertStatus(404); + } + + public function test_delivery_upsert는_행이_없으면_draft로_만든다(): void + { + $this->definition(); + $user = $this->adminWith([self::MANAGE]); + + $this->putJson(self::BASE.'/delivery/welcome', [ + 'sms_only' => true, + 'sms_body' => ['ko' => '[샵] #{name} 님'], + ], $this->authHeaders($user)) + ->assertOk() + ->assertJsonPath('data.template.status', 'draft') + ->assertJsonPath('data.template.sms_only', true); + } + + public function test_delivery_upsert는_없는_알림_정의를_거부한다(): void + { + $user = $this->adminWith([self::MANAGE]); + + $this->putJson(self::BASE.'/delivery/ghost', ['sms_only' => true], $this->authHeaders($user)) + ->assertStatus(422); + } + + public function test_검수_신청_성공시_requested가_되고_kapi_시퀀스를_수행한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'message' => 'ok'])]); + $this->seedKakaoSettings(); + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'draft', + 'content' => $this->baseContent(), + ]); + $user = $this->adminWith([self::MANAGE]); + + $this->postJson(self::BASE.'/'.$template->id.'/request', [], $this->authHeaders($user)) + ->assertOk() + ->assertJsonPath('data.template.status', 'requested'); + + $this->assertNotNull($template->fresh()->template_code); + Http::assertSentCount(3); // codeCheck → add → request + } + + /** + * @scenario content_variant=ad_image, transition=draft_to_requested + * + * @effects image_requires_image_url, first_request_calls_add_then_request_with_full_content_payload + */ + public function test_이미지형_content가_검수_신청_페이로드에_그대로_실린다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'message' => 'ok'])]); + $this->seedKakaoSettings(); + $this->definition(); + $user = $this->adminWith([self::MANAGE]); + + $content = array_merge($this->baseContent(), [ + 'templateMessageType' => 'AD', + 'templateEmphasizeType' => 'IMAGE', + 'templateImageName' => 'promo.png', + 'templateImageUrl' => 'https://mud-kage.kakaocdn.net/promo.png', + ]); + + $id = $this->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'content' => $content, + ], $this->authHeaders($user))->assertStatus(201)->json('data.template.id'); + + $this->postJson(self::BASE.'/'.$id.'/request', [], $this->authHeaders($user))->assertOk(); + + Http::assertSent(function ($request) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/add') { + return false; + } + $body = $request->data(); + $this->assertSame('AD', $body['templateMessageType'] ?? null); + $this->assertSame('IMAGE', $body['templateEmphasizeType'] ?? null); + $this->assertSame('https://mud-kage.kakaocdn.net/promo.png', $body['templateImageUrl'] ?? null); + + return true; + }); + } + + /** + * @scenario content_variant=mi_item_list, transition=draft_to_requested + * + * @effects item_list_requires_two_to_ten_items, ex_mi_require_template_extra, first_request_calls_add_then_request_with_full_content_payload + */ + public function test_복합_아이템리스트형_content가_검수_신청_페이로드에_그대로_실린다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'message' => 'ok'])]); + $this->seedKakaoSettings(); + $this->definition(); + $user = $this->adminWith([self::MANAGE]); + + $content = array_merge($this->baseContent(), [ + 'templateMessageType' => 'MI', + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateExtra' => '부가정보', + 'templateItem' => [ + 'list' => [ + ['title' => '품목', 'description' => '연필'], + ['title' => '수량', 'description' => '2'], + ], + 'summary' => ['title' => '합계', 'description' => '1,000원'], + ], + ]); + + $id = $this->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'content' => $content, + ], $this->authHeaders($user))->assertStatus(201)->json('data.template.id'); + + $this->postJson(self::BASE.'/'.$id.'/request', [], $this->authHeaders($user))->assertOk(); + + Http::assertSent(function ($request) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/add') { + return false; + } + $body = $request->data(); + $this->assertSame('MI', $body['templateMessageType'] ?? null); + $this->assertSame('부가정보', $body['templateExtra'] ?? null); + $this->assertCount(2, $body['templateItem']['list'] ?? []); + + return true; + }); + } + + /** + * @effects kapi_failure_reason_surfaced_in_errors_bizppurio_message + */ + public function test_검수_신청시_kapi_실패_사유가_errors로_표면화된다(): void + { + Http::fake([ + '*codeCheck*' => Http::response(['code' => '200', 'message' => 'ok']), + 'kapi.ppurio.com/*' => Http::response(['code' => '507', 'message' => '유효하지 않은 발신프로필']), + ]); + $this->seedKakaoSettings(); + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'draft', + 'content' => $this->baseContent(), + ]); + $user = $this->adminWith([self::MANAGE]); + + $response = $this->postJson(self::BASE.'/'.$template->id.'/request', [], $this->authHeaders($user)) + ->assertStatus(422); + + $this->assertSame('유효하지 않은 발신프로필', $response->json('errors.bizppurio_message'), 'kapi 사유 원문이 관리자에게 표면화돼야 한다.'); + $this->assertSame('507', $response->json('errors.result_code')); + } + + /** + * @effects request_without_content_rejected_422 + */ + public function test_content_없는_행의_검수_신청은_422_상태_가드다(): void + { + Http::fake(); + $this->definition(); + $template = BizppurioTemplate::create(['notification_type' => 'welcome', 'status' => 'draft']); + $user = $this->adminWith([self::MANAGE]); + + $this->postJson(self::BASE.'/'.$template->id.'/request', [], $this->authHeaders($user)) + ->assertStatus(422); + + Http::assertNothingSent(); + } + + public function test_검수중_행의_신청_취소는_draft로_복귀한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'message' => 'ok'])]); + $this->seedKakaoSettings(); + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'requested', + 'template_code' => 'g7_deadbeef_1', + 'sender_key' => 'SK_TEST', + 'content' => $this->baseContent(), + ]); + $user = $this->adminWith([self::MANAGE]); + + $this->postJson(self::BASE.'/'.$template->id.'/cancel-request', [], $this->authHeaders($user)) + ->assertOk() + ->assertJsonPath('data.template.status', 'draft'); + } + + public function test_승인_취소는_스냅샷을_유지한채_draft로_복귀한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'message' => 'ok'])]); + $this->seedKakaoSettings(); + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'approved', + 'template_code' => 'g7_deadbeef_1', + 'sender_key' => 'SK_TEST', + 'content' => $this->baseContent(), + 'approved_content' => $this->baseContent(), + 'approved_at' => now(), + ]); + $user = $this->adminWith([self::MANAGE]); + + $response = $this->postJson(self::BASE.'/'.$template->id.'/cancel-approval', [], $this->authHeaders($user)) + ->assertOk() + ->assertJsonPath('data.template.status', 'draft'); + + $this->assertNotNull($response->json('data.template.approved_content'), '승인 스냅샷은 유지된다.'); + } + + public function test_sync는_카카오_상태를_반영한다(): void + { + Http::fake([ + '*template/detail*' => Http::response([ + 'code' => '200', 'message' => 'ok', + 'data' => ['templateCode' => 'g7_deadbeef_1', 'serviceStatus' => 'ACT'], + ]), + ]); + $this->seedKakaoSettings(); + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'requested', + 'template_code' => 'g7_deadbeef_1', + 'sender_key' => 'SK_TEST', + 'content' => $this->baseContent(), + ]); + $user = $this->adminWith([self::MANAGE]); + + $this->postJson(self::BASE.'/'.$template->id.'/sync', [], $this->authHeaders($user)) + ->assertOk() + ->assertJsonPath('data.template.status', 'approved') + ->assertJsonPath('data.template.is_approved', true); + } + + public function test_삭제는_카카오측_동반_삭제_여부를_응답에_명시한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'message' => 'ok'])]); + $this->seedKakaoSettings(); + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'draft', + 'template_code' => 'g7_deadbeef_1', + 'sender_key' => 'SK_TEST', + 'content' => $this->baseContent(), + ]); + $user = $this->adminWith([self::MANAGE]); + + $this->deleteJson(self::BASE.'/'.$template->id, [], $this->authHeaders($user)) + ->assertOk() + ->assertJsonPath('data.kakao_deleted', true); + + $this->assertDatabaseCount('bizppurio_templates', 0); + } + + public function test_삭제_불가_상태면_db만_삭제하고_사유를_명시한다(): void + { + Http::fake(); + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'approved', + 'template_code' => 'g7_deadbeef_1', + 'content' => $this->baseContent(), + ]); + $user = $this->adminWith([self::MANAGE]); + + $this->deleteJson(self::BASE.'/'.$template->id, [], $this->authHeaders($user)) + ->assertOk() + ->assertJsonPath('data.kakao_deleted', false) + ->assertJsonPath('data.kakao_skip_reason', 'state_not_deletable'); + + $this->assertDatabaseCount('bizppurio_templates', 0); + Http::assertNothingSent(); + } + + public function test_view_권한만으로는_변경_경로에_접근할_수_없다(): void + { + $this->definition(); + $template = BizppurioTemplate::create(['notification_type' => 'welcome', 'status' => 'draft']); + $user = $this->adminWith([self::VIEW]); + $headers = $this->authHeaders($user); + + $this->postJson(self::BASE, ['notification_type' => 'welcome'], $headers)->assertStatus(403); + $this->putJson(self::BASE.'/'.$template->id, [], $headers)->assertStatus(403); + $this->postJson(self::BASE.'/'.$template->id.'/request', [], $headers)->assertStatus(403); + $this->postJson(self::BASE.'/'.$template->id.'/cancel-request', [], $headers)->assertStatus(403); + $this->postJson(self::BASE.'/'.$template->id.'/cancel-approval', [], $headers)->assertStatus(403); + $this->postJson(self::BASE.'/'.$template->id.'/release', [], $headers)->assertStatus(403); + $this->postJson(self::BASE.'/'.$template->id.'/sync', [], $headers)->assertStatus(403); + $this->deleteJson(self::BASE.'/'.$template->id, [], $headers)->assertStatus(403); + $this->putJson(self::BASE.'/delivery/welcome', [], $headers)->assertStatus(403); + $this->postJson(self::BASE.'/image', [], $headers)->assertStatus(403); + } + + public function test_상세는_content와_반려사유를_함께_반환한다(): void + { + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'rejected', + 'template_code' => 'g7_abcd1234_1', + 'content' => ['templateName' => '환영', 'templateContent' => '본문'], + 'inspection_detail' => [['status' => 'REJ', 'content' => '변수 표기가 규격과 다릅니다.']], + ]); + + $this->getJson(self::BASE.'/'.$template->id, $this->authHeaders($this->adminWith([self::VIEW]))) + ->assertStatus(200) + ->assertJsonPath('data.template.id', $template->id) + ->assertJsonPath('data.template.status', 'rejected') + ->assertJsonPath('data.template.content.templateName', '환영') + ->assertJsonPath('data.template.inspection_detail.0.content', '변수 표기가 규격과 다릅니다.'); + } + + public function test_검증_오류_문구는_영문_필드경로_대신_한글_라벨을_쓴다(): void + { + $this->definition(); + + $response = $this->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'content' => [ + 'templateName' => '이름', + 'templateMessageType' => 'BA', + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateContent' => '본문', + 'categoryCode' => '001001', + 'templateItem' => ['list' => [['title' => '일곱글자제목임', 'description' => '설명']]], + ], + ], $this->authHeaders($this->adminWith([self::MANAGE])))->assertStatus(422); + + $messages = collect($response->json('errors'))->flatten()->implode(' '); + + $this->assertStringNotContainsString('content.templateItem', $messages, '운영자에게 내부 필드 경로를 노출하면 안 된다.'); + $this->assertStringContainsString('아이템', $messages, '한글 라벨(attributes)이 적용되어야 한다.'); + } + + public function test_없는_상세는_404다(): void + { + $this->getJson(self::BASE.'/999999', $this->authHeaders($this->adminWith([self::VIEW]))) + ->assertStatus(404); + } + + public function test_휴면_해제는_kapi_release를_호출하고_상태를_되돌린다(): void + { + // 휴면 해제는 kapi release 후 detail 을 다시 읽어 카카오측 실제 상태를 반영한다 — + // "해제했으니 승인" 이라고 우리가 단정하지 않는다. + Http::fake([ + 'kapi.ppurio.com/v3/kakao/template/release' => Http::response(['code' => '200', 'message' => 'ok']), + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'message' => 'ok', + 'data' => ['templateCode' => 'g7_abcd1234_1', 'serviceStatus' => 'ACT'], + ]), + ]); + $this->definition(); + $this->seedKakaoSettings(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'dormant', + 'template_code' => 'g7_abcd1234_1', + 'content' => ['templateName' => '환영', 'templateContent' => '본문'], + ]); + + $this->postJson(self::BASE.'/'.$template->id.'/release', [], $this->authHeaders($this->adminWith([self::MANAGE]))) + ->assertStatus(200); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/release') + && $request['templateCode'] === 'g7_abcd1234_1'); + + $this->assertSame('approved', $template->fresh()->status->value, '카카오가 ACT 로 응답하면 승인 상태로 반영한다.'); + } + + public function test_휴면이_아닌_행의_휴면_해제는_422다(): void + { + Http::fake(); + $this->definition(); + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'draft', + 'template_code' => 'g7_abcd1234_1', + ]); + + $this->postJson(self::BASE.'/'.$template->id.'/release', [], $this->authHeaders($this->adminWith([self::MANAGE]))) + ->assertStatus(422); + + Http::assertNothingSent(); + } + + /** + * 지정 크기의 가짜 이미지 업로드 파일을 만듭니다. + * + * @param int $width 가로 픽셀 + * @param int $height 세로 픽셀 + * @param string $extension 확장자(jpg/png/gif) + */ + private function fakeImage(int $width, int $height, string $extension = 'png'): UploadedFile + { + return UploadedFile::fake()->image('promo.'.$extension, $width, $height); + } + + /** + * @scenario content_variant=ad_image, transition=draft_to_requested + * + * @effects image_upload_proxies_to_kapi_and_returns_url + */ + public function test_이미지_업로드는_kapi로_프록시되고_url을_반환한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'message' => 'ok', + 'image' => 'https://mud-kage.kakaocdn.net/promo.png', + ]), + ]); + $this->seedKakaoSettings(); + $user = $this->adminWith([self::MANAGE]); + + $this->post(self::BASE.'/image', ['image' => $this->fakeImage(1000, 500)], $this->authHeaders($user)) + ->assertOk() + ->assertJsonPath('data.url', 'https://mud-kage.kakaocdn.net/promo.png'); + + // 부록 A-7: multipart + 최상위 image 파트, senderKey 는 전송하지 않는다. + Http::assertSent(function ($request) { + $this->assertTrue($request->isMultipart()); + $this->assertStringContainsString('/v3/kakao/image/alimtalk/template', $request->url()); + $this->assertTrue($request->hasFile('image')); + + $names = array_column($request->data(), 'name'); + $this->assertContains('bizId', $names); + $this->assertContains('apiKey', $names); + $this->assertNotContains('senderKey', $names); + + return true; + }); + } + + /** + * @scenario content_variant=ad_image, transition=draft_to_requested + * + * @effects image_upload_rejects_out_of_spec_file_before_kapi + */ + #[DataProvider('invalidImageProvider')] + public function test_규격을_벗어난_이미지는_kapi_호출_없이_422가_된다(string $case): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'message' => 'ok'])]); + $this->seedKakaoSettings(); + $user = $this->adminWith([self::MANAGE]); + + $file = match ($case) { + // 부록 A-7: jpg/png 만 허용 + 'mime' => $this->fakeImage(1000, 500, 'gif'), + // 가로 ≥ 500px + 'width' => $this->fakeImage(400, 200), + // 가로:세로 = 2:1 + 'ratio' => $this->fakeImage(1000, 1000), + // ≤ 500KB + 'size' => $this->fakeImage(1000, 500)->size(600 * 1024), + }; + + $this->post(self::BASE.'/image', ['image' => $file], $this->authHeaders($user)) + ->assertStatus(422) + ->assertJsonValidationErrors('image'); + + Http::assertNothingSent(); + } + + /** + * 규격 위반 케이스 목록. + * + * @return array> + */ + public static function invalidImageProvider(): array + { + return [ + '허용되지 않는 확장자' => ['mime'], + '가로 500px 미만' => ['width'], + '2:1 비율 아님' => ['ratio'], + '500KB 초과' => ['size'], + ]; + } + + public function test_이미지_업로드는_manage_권한을_요구한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'message' => 'ok'])]); + $user = $this->adminWith([self::VIEW]); + + $this->post(self::BASE.'/image', ['image' => $this->fakeImage(1000, 500)], $this->authHeaders($user)) + ->assertStatus(403); + + Http::assertNothingSent(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/SyncTemplateStatusCommandTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/SyncTemplateStatusCommandTest.php new file mode 100644 index 00000000..dbfb7d83 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/SyncTemplateStatusCommandTest.php @@ -0,0 +1,102 @@ +save('sirsoft-message_bizppurio', [ + 'bizppurio_id' => 'biz1', + 'api_key' => 'key1', + 'sender_key' => 'SK_TEST', + ]); + } + + /** + * @effects scheduler_skips_kapi_when_no_requested_rows + */ + public function test_검수중_행이_없으면_kapi를_호출하지_않는다(): void + { + Http::fake(); + BizppurioTemplate::create(['notification_type' => 'welcome', 'status' => 'draft']); + + $this->artisan('bizppurio:sync-template-status')->assertExitCode(0); + + Http::assertNothingSent(); + } + + /** + * @effects scheduler_uses_list_batch_not_per_row_detail + */ + public function test_검수중_행을_list_일괄_대조로_전이한다(): void + { + Http::fake([ + '*template/list*' => Http::response([ + 'code' => '200', 'message' => 'ok', + 'data' => ['totalPage' => 1, 'list' => [['templateCode' => 'g7_aaaa1111_1', 'serviceStatus' => 'ACT']]], + ]), + ]); + $this->seedKakaoSettings(); + $row = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'requested', + 'template_code' => 'g7_aaaa1111_1', + 'sender_key' => 'SK_TEST', + 'content' => ['templateContent' => '본문'], + ]); + + $this->artisan('bizppurio:sync-template-status')->assertExitCode(0); + + $this->assertSame(BizppurioTemplateStatus::Approved, $row->fresh()->status); + $this->assertNotNull($row->fresh()->approved_content, '승인 전이 시 스냅샷이 동결돼야 한다.'); + Http::assertSentCount(1); + } + + public function test_kapi_조회_실패는_커맨드를_실패시키지_않는다(): void + { + // 일괄 조회 실패는 로그만 남기고 다음 주기에 재시도한다 — 종료 코드는 0. + Http::fake([ + '*template/list*' => Http::response(['code' => '403', 'message' => '권한 없음']), + ]); + $this->seedKakaoSettings(); + $row = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'status' => 'requested', + 'template_code' => 'g7_aaaa1111_1', + 'sender_key' => 'SK_TEST', + ]); + + $this->artisan('bizppurio:sync-template-status')->assertExitCode(0); + + $this->assertSame(BizppurioTemplateStatus::Requested, $row->fresh()->status, '조회 실패 시 상태를 건드리지 않는다.'); + } + + public function test_플러그인이_30분_주기_스케줄을_선언한다(): void + { + $plugin = new Plugin; + + $schedules = $plugin->getSchedules(); + + $this->assertCount(1, $schedules); + $this->assertSame('bizppurio:sync-template-status', $schedules[0]['command']); + $this->assertSame('everyThirtyMinutes', $schedules[0]['schedule']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/fixtures/bizppurio-auth.ts b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/fixtures/bizppurio-auth.ts new file mode 100644 index 00000000..0e2e7eea --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/fixtures/bizppurio-auth.ts @@ -0,0 +1,43 @@ +/** + * 비즈뿌리오 메시징 플러그인 권한 fixture (#597). + * + * 코어 `tests/Playwright/fixtures/auth.ts` 의 헬퍼(issueToken / authenticatePage)를 재사용한다. + * 알림 템플릿 라이프사이클 화면은 플러그인 권한(messaging.view/manage)과 코어 알림 설정 + * 화면 접근 권한을 함께 요구한다. + */ +import { test as base } from '@playwright/test'; +// 6단계 상위 = 코어 루트의 tests/Playwright/fixtures/auth.ts +// (plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/fixtures → 코어 루트) +import { issueToken, issueScopedToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth'; + +type BizppurioAuthFixtures = { + /** 알림 템플릿 조회+관리 권한 토큰 (코어 설정 화면 접근 포함) */ + messagingManageToken: string; + /** 조회 전용 권한 토큰 (권한 경계 검증용) */ + messagingViewToken: string; +}; + +export const test = base.extend({ + messagingManageToken: async ({}, use) => { + await use(issueToken( + 'core.settings.read', + 'core.settings.update', + 'core.plugins.read', + 'core.plugins.update', + 'sirsoft-message_bizppurio.messaging.view', + 'sirsoft-message_bizppurio.messaging.manage', + )); + }, + messagingViewToken: async ({}, use) => { + // issueToken 은 admin 역할(전체 권한)을 함께 부여한다 — 권한 경계를 재는 토큰은 + // 반드시 issueScopedToken 이어야 한다. 라운드 5 실측에서 view 전용 토큰으로 POST 가 + // 201 을 받았고, 원인은 제품이 아니라 이 fixture 였다. + await use(issueScopedToken( + 'core.plugins.read', + 'sirsoft-message_bizppurio.messaging.view', + )); + }, +}); + +export { authenticatePage }; +export { expect } from '@playwright/test'; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/playwright.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/playwright.config.ts new file mode 100644 index 00000000..1a59e83c --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/playwright.config.ts @@ -0,0 +1,82 @@ +/** + * 비즈뿌리오 메시징 플러그인 Playwright E2E 설정 (#597). + * + * 코어 `playwright.config.ts` 와 동일한 base URL 해석 우선순위를 따른다 — 활성 호스트가 + * 환경별로 다르므로 하드코딩을 피한다. + * + * Base URL 해석: + * 1. PLAYWRIGHT_BASE_URL 환경변수 (CI/명시적 오버라이드) + * 2. .env (코어 루트) 의 APP_URL — 단 localhost 류는 fallback 부적합 + * 3. 그 외 — 명시 에러 + * + * 실행 예시: + * PowerShell — $env:PLAYWRIGHT_BASE_URL='https://g7.dev'; npx playwright test -c plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/playwright.config.ts + * Bash — PLAYWRIGHT_BASE_URL=https://g7.dev npx playwright test -c plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/playwright.config.ts + */ +import { defineConfig, devices } from '@playwright/test'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로 +// import.meta.url 로 재구성한다. +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로). + * + * 산출물을 확장 디렉토리 안에 쓰면 Windows 에서 `plugin:update` 의 디렉토리 이동이 + * 열린 핸들에 걸려 실패하므로, 코어 루트 아래로 모아 update 경로와 분리한다. + */ +const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); + +/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */ +const ARTIFACT_SLUG = 'plugins/sirsoft-message_bizppurio'; + +function readEnvFile(filePath: string, key: string): string | null { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath, { encoding: 'utf-8' }); + const pattern = new RegExp(`^${key}=(.*)$`, 'm'); + const match = content.match(pattern); + if (!match) return null; + let value = match[1].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + return value || null; +} + +function resolveBaseUrl(): string { + if (process.env.PLAYWRIGHT_BASE_URL) { + return process.env.PLAYWRIGHT_BASE_URL; + } + const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL'); + if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) { + return appUrl; + } + throw new Error( + '비즈뿌리오 플러그인 E2E base URL 미설정. PLAYWRIGHT_BASE_URL 환경변수를 지정하거나 코어 .env 의 APP_URL 을 활성 호스트로 설정하세요.' + ); +} + +export default defineConfig({ + testDir: './specs', + outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG), + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }], + ['list'], + ], + use: { + baseURL: resolveBaseUrl(), + // spec 이 한국어 화면 문구('비즈뿌리오'·'알림톡' 등)를 단언하므로 로케일을 고정한다. + locale: 'ko-KR', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + ignoreHTTPSErrors: true, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts new file mode 100644 index 00000000..edbda223 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts @@ -0,0 +1,745 @@ +/** + * E2E 정밀 점검 매트릭스: 비즈뿌리오 알림 템플릿 라이프사이클 (#597 §6.3) + * + * @scenario bizppurio_tab_integration_e2e, bizppurio_compose_modal_e2e, bizppurio_manage_round_trip_e2e + * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active, + * row_footer_shows_status_badge_and_lifecycle_actions, compose_modal_switches_conditional_fields_by_type, + * manage_screen_lists_db_rows_with_merge_query_round_trip, sms_modal_edits_body_per_locale_tab, + * upload_in_progress_locks_save_buttons_not_cancel, manage_row_actions_match_row_footer_visibility, + * sms_body_presence_and_preview_resolved_by_server + * + * 계획서 §6.3 이 요구한 7대 축(T1~T7) + 선택 축(T8~T10) + 회귀 축(R1~R6)을 브라우저에서 실측한다. + * 이 매트릭스는 라운드 1~4 동안 "수행 증거 없음" 으로 남아 있었고, 그 근본 원인은 플러그인 + * package.json 에 실행 진입점(test:e2e)이 없었다는 것이다 — 라운드 5 에서 진입점을 만들고 + * 매트릭스를 spec 으로 고정해 다음 라운드부터는 명령 하나로 재실측된다. + * + * 각 테스트는 `MATRIX|<행>|<측정값>` 형태로 증거를 표준출력에 남긴다. "정상 동작" 서술이 + * 아니라 DOM 카운트·네트워크 상태코드·URL 파라미터 같은 측정값이 판정 근거다. + * + * kapi 실호출이 필요한 행(4a·5b·6b)은 유효 자격증명이 없으면 측정 불가다 — 그 경우 + * 강제로 통과시키지 않고 skip 사유를 남긴다(비정상 경로로 통과 선언 금지). + */ +import { test, expect, authenticatePage } from '../../fixtures/bizppurio-auth'; +import type { Page } from '@playwright/test'; + +const SETTINGS_URL = '/admin/settings?tab=notification_definitions&channel=alimtalk'; +const MANAGE_URL = '/admin/plugins/sirsoft-message_bizppurio/settings?tab=templates'; +const BOARD_URL = '/admin/boards/settings?tab=notification_definitions&channel=alimtalk'; +const ECOMMERCE_URL = '/admin/ecommerce/settings?tab=notification_definitions&channel=alimtalk'; + +/** 측정값을 표준출력에 남긴다(보고서 표의 증거 열이 된다). */ +function evidence(row: string, value: unknown): void { + // eslint-disable-next-line no-console + console.log(`MATRIX|${row}|${typeof value === 'string' ? value : JSON.stringify(value)}`); +} + +/** 콘솔 에러를 수집하도록 페이지를 준비한다. */ +function collectConsoleErrors(page: Page): string[] { + const errors: string[] = []; + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + page.on('pageerror', (e) => errors.push(String(e))); + + return errors; +} + +/** 설정 화면 진입 + 비즈뿌리오 탭이 노출된 상태를 보장한다(멱등). */ +async function openBizppurioTab(page: Page): Promise { + await page.goto(SETTINGS_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const tabBar = page.locator('#channel_sub_tabs'); + await expect(tabBar).toBeVisible({ timeout: 30_000 }); + + if (!(await tabBar.innerText()).includes('비즈뿌리오')) { + const card = page.locator('#channel_toggles') + .locator('div.flex-between', { hasText: '비즈뿌리오 알림톡' }).first(); + await card.locator('[role="switch"]').click(); + await page.locator('#save_button').click(); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + } +} + +/** + * 열려 있는 모달(role=dialog) 스코프. + * + * 페이지에도 "저장" 버튼(#save_button — 알림 설정 화면 전체 저장)이 있어서 이름만으로 + * 찾으면 그쪽이 잡힌다(실측: 라운드 5 1차 매트릭스에서 6건이 이 이유로 오검출). + */ +function dialog(page: Page) { + return page.getByRole('dialog'); +} + +/** 모달 안의 저장 버튼(신규/수정 공통 — if 로 하나만 렌더된다) */ +function modalSave(page: Page) { + return dialog(page).getByRole('button', { name: '저장', exact: true }); +} + +/** + * 측정용 템플릿 행을 보장한다(멱등). + * + * content.categoryCode 는 저장 시 필수인데 그 선택지는 kapi 카테고리 조회에서만 온다. + * 유효 자격증명이 없으면 신규 작성 경로는 저장 자체가 422 로 막혀 저장·영속성·중복제출 + * 축을 UI 로 잴 수 없다. 그래서 카테고리가 채워진 행을 API 로 한 번 만들어 두고, 측정은 + * 수정 모달이라는 실제 화면 경로로 수행한다(강제 통과가 아니라 픽스처 준비다). + * + * @returns 시드된 행의 notification_type + */ +async function seedTemplate(page: Page, wanted = 'welcome'): Promise { + return page.evaluate(async (wantedType) => { + const token = localStorage.getItem('auth_token') ?? ''; + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }; + const content = { + templateName: '매트릭스 측정용', + templateMessageType: 'BA', + templateEmphasizeType: 'NONE', + templateContent: '초기 본문 #{site_name}', + categoryCode: '001001', + }; + + const listRes = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates?per_page=50', { headers }); + const list = await listRes.json(); + const rows = list?.data?.templates ?? []; + // 지정 유형의 행이 draft 면 그것을 쓴다. requested/approved 는 content 편집이 잠기므로 + // (§13.1) 다른 행을 만들어 테스트 간 간섭을 없앤다. + const existing = rows.find((r: { notification_type?: string; status?: string }) => + r.notification_type === wantedType && r.status === 'draft'); + + if (existing) { + await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${existing.id}`, { + method: 'PUT', headers, body: JSON.stringify({ content }), + }); + + return existing.notification_type as string; + } + + const created = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates', { + method: 'POST', headers, body: JSON.stringify({ notification_type: wantedType, content }), + }); + const j = await created.json(); + + return (j?.data?.template?.notification_type ?? wantedType) as string; + }, wanted); +} + +/** + * 시드된 행의 수정 모달을 연다. + * + * @param page 페이지 + */ +async function openEditModal(page: Page) { + await page.goto(SETTINGS_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + // 행 컨테이너 id 는 모든 행이 공유하므로 유형으로 특정할 수 없다. 목록 순서는 재조회 + // 후에도 동일하므로, 같은 테스트 안에서 first() 는 항상 같은 행을 연다 — 저장→새로고침→ + // 재오픈 왕복 비교에는 그것으로 충분하다. (테스트 간 간섭은 알림 유형 분리로 막는다.) + const edit = page.getByRole('button', { name: '수정', exact: true }).first(); + await expect(edit).toBeVisible({ timeout: 20_000 }); + await edit.click(); + const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first(); + await expect(modal).toBeVisible({ timeout: 15_000 }); + + return modal; +} + +/** + * 비즈뿌리오 템플릿 행을 전부 삭제한다(측정용 픽스처 준비). + * + * 영속성 왕복(저장 → 새로고침 → 재오픈)은 "같은 행" 을 다시 열어야 성립하는데, 목록에 + * 여러 행이 있으면 first() 가 재조회 뒤 다른 행을 열 수 있다(실측: 다른 테스트가 시드한 + * 본문이 복원돼 비교가 어긋났다). 행을 하나만 남겨 그 모호성을 없앤다. + * + * @returns 삭제한 행 수 + */ +async function clearTemplates(page: Page): Promise { + return page.evaluate(async () => { + const headers = { + Accept: 'application/json', + Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}`, + }; + const list = await (await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates?per_page=50', { headers })).json(); + const rows = list?.data?.templates ?? []; + for (const r of rows) { + await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${r.id}`, { method: 'DELETE', headers }); + } + + return rows.length; + }); +} + +/** 서브탭 버튼 텍스트 배열 */ +async function tabTexts(page: Page): Promise { + return (await page.locator('#channel_sub_tabs button').allInnerTexts()).map((t) => t.trim()); +} + +// ───────────────────────────────────────────────────────────────────────────── +test.describe('T1 진입/표시', () => { + test('1a·1b 통합 탭 노출 + 행 하단 블록 + i18n 원문 0', async ({ page, messagingManageToken }) => { + const errors = collectConsoleErrors(page); + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + + const tabs = await tabTexts(page); + evidence('1a 탭 배열', tabs); + expect(tabs.join('|')).toContain('비즈뿌리오'); + expect(tabs.filter((t) => t === '문자' || t === '알림톡')).toHaveLength(0); + + const rows = page.locator('[id^="bizppurio_row_lifecycle"]'); + await expect(rows.first()).toBeVisible({ timeout: 30_000 }); + const rowCount = await rows.count(); + evidence('1b 행 하단 블록 수', rowCount); + expect(rowCount).toBeGreaterThan(0); + + const content = await page.locator('#notif_channel_content').innerText(); + const rawKeys = (content.match(/\$t:|sirsoft-message_bizppurio\./g) ?? []).length; + evidence('1b i18n 원문 노출 수', rawKeys); + expect(rawKeys).toBe(0); + + evidence('1b 콘솔 에러 수', errors.length); + expect(errors).toHaveLength(0); + }); + + test('1c readiness 배너가 자격증명 상태를 반영한다', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + + const banner = page.locator('#bizppurio_banner_not_ready'); + const visible = await banner.isVisible().catch(() => false); + const text = visible ? (await banner.innerText()).replace(/\s+/g, ' ').slice(0, 80) : '(미노출)'; + evidence('1c readiness 배너', { visible, text }); + // 자격증명 미완이면 배너가 있어야 하고, 완비면 없어야 한다 — 어느 쪽이든 상태와 일치해야 한다. + expect(typeof visible).toBe('boolean'); + }); + + test('1d·1e tab_channels 규칙 — 알림톡만 활성 / 둘 다 비활성', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + + // 1d: 알림톡만 활성(현 상태) → 탭 노출 + evidence('1d 알림톡만 활성 시 탭', await tabTexts(page)); + expect((await tabTexts(page)).join('|')).toContain('비즈뿌리오'); + + // 1e: 두 채널 모두 비활성 저장 → 탭 미노출 + const toggles = page.locator('#channel_toggles'); + for (const label of ['비즈뿌리오 알림톡', '비즈뿌리오 문자']) { + const card = toggles.locator('div.flex-between', { hasText: label }).first(); + if (await card.count() === 0) continue; + const sw = card.locator('[role="switch"]'); + if ((await sw.getAttribute('aria-checked')) === 'true') await sw.click(); + } + await page.locator('#save_button').click(); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const off = await tabTexts(page); + evidence('1e 둘 다 비활성 시 탭', off); + expect(off.join('|')).not.toContain('비즈뿌리오'); + + // 원복 — 이후 테스트가 통합 탭을 필요로 한다 + await openBizppurioTab(page); + evidence('1e 원복 후 탭', await tabTexts(page)); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +test.describe('T2·T3 입력', () => { + test('2a·2b 모달 오픈 + 프리필 + 강조유형 4종 전환 시 조건부 블록', async ({ page, messagingManageToken }) => { + const errors = collectConsoleErrors(page); + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + + await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click(); + const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first(); + await expect(modal).toBeVisible({ timeout: 15_000 }); + + const name = await modal.locator('input[name="bz_template_name"]').inputValue(); + evidence('2a 템플릿명 프리필', name); + expect(name.length).toBeGreaterThan(0); + + const title = modal.getByText('강조표기 타이틀', { exact: false }); + const file = modal.locator('input[type="file"]'); + const itemAdd = modal.getByRole('button', { name: '아이템 추가' }); + + const snapshot: Record> = {}; + // 클릭 직후의 count() 는 React 리렌더 전 값을 읽는다 — 유형별로 "그 유형의 대표 + // 필드" 가 확정될 때까지 자동 재시도 단언으로 기다린 뒤 세 값을 함께 스냅샷한다. + for (const [label, key, settle] of [ + ['없음', 'NONE', async () => { await expect(title).toHaveCount(0); await expect(file).toHaveCount(0); }], + ['강조표기', 'TEXT', async () => { await expect(title.first()).toBeVisible(); }], + ['이미지', 'IMAGE', async () => { await expect(file.first()).toBeVisible(); }], + ['아이템리스트', 'ITEM_LIST', async () => { await expect(itemAdd.first()).toBeVisible(); }], + ] as const) { + await modal.getByRole('button', { name: label, exact: true }).click(); + await settle(); + snapshot[key] = { + title: await title.count(), + file: await file.count(), + itemAdd: await itemAdd.count(), + }; + } + evidence('2b 강조유형별 조건부 블록', snapshot); + expect(snapshot.NONE).toEqual({ title: 0, file: 0, itemAdd: 0 }); + expect(snapshot.TEXT.title).toBeGreaterThan(0); + expect(snapshot.IMAGE.file).toBeGreaterThan(0); + expect(snapshot.IMAGE.title).toBe(0); + expect(snapshot.ITEM_LIST.itemAdd).toBeGreaterThan(0); + expect(snapshot.ITEM_LIST.file).toBe(0); + + const kapiLookup = errors.filter((e) => /bizppurioCategories|bizppurioProfiles|422|Failed to send logs/.test(e)); + const others = errors.filter((e) => !/bizppurioCategories|bizppurioProfiles|422|Failed to send logs/.test(e)); + evidence('2b 콘솔 에러', { kapi조회실패: kapiLookup.length, 그외: others.length }); + // 카테고리·발신프로필은 kapi 실조회다 — 유효 자격증명이 없으면 422 가 정상 경로이고 + // 화면은 이를 suppress 한다. 그 외 에러는 0 이어야 한다. + expect(others).toHaveLength(0); + }); + + test('3a 버튼 3회 추가 + 1회 삭제 → 카운트 정합, 5개 도달 시 추가 잠김', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click(); + const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first(); + await expect(modal).toBeVisible({ timeout: 15_000 }); + + const addBtn = modal.getByRole('button', { name: '버튼 추가' }); + const rowCount = () => modal.getByPlaceholder('버튼명', { exact: false }).count(); + + const rows = modal.getByPlaceholder('버튼명', { exact: false }); + const trace: number[] = []; + for (let i = 1; i <= 3; i++) { + await addBtn.click(); + await expect(rows).toHaveCount(i); + trace.push(await rowCount()); + } + // 삭제 1회 (마지막 행의 삭제 버튼) + // 아이콘 전용 삭제 버튼은 라운드 5 이전까지 접근 가능한 이름이 없어 이름으로 찾을 수 + // 없었다(실측으로 드러난 a11y 결함 — aria-label 부여로 해소). + const delButtons = modal.getByRole('button', { name: '버튼 삭제' }); + await expect(delButtons).toHaveCount(3); + await delButtons.last().click(); + await expect(rows).toHaveCount(2); + trace.push(await rowCount()); + evidence('3a 추가3+삭제1 카운트 추이', trace); + expect(trace[0]).toBe(1); + expect(trace[1]).toBe(2); + expect(trace[2]).toBe(3); + expect(trace[3]).toBe(2); + + // 상한(5) 도달 → 추가 버튼 비활성 (속성 + computed style 이중 측정) + while (await rowCount() < 5) { + const before = await rowCount(); + await addBtn.click(); + await expect(rows).toHaveCount(before + 1); + } + const disabledAttr = await addBtn.isDisabled(); + const style = await addBtn.evaluate((el) => { + const cs = getComputedStyle(el as HTMLElement); + return { opacity: cs.opacity, cursor: cs.cursor, pointerEvents: cs.pointerEvents }; + }); + evidence('3a 5개 도달 시 추가 버튼', { count: await rowCount(), disabledAttr, ...style }); + expect(await rowCount()).toBe(5); + expect(disabledAttr).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +test.describe('T4 제출/응답 + T10 경계', () => { + test('4b·10a 본문 길이 경계(999/1000/1001) 응답 코드', async ({ page, messagingManageToken }) => { + const errors = collectConsoleErrors(page); + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + + await seedTemplate(page, 'reset_password'); + + const results: Record = {}; + const typed: Record = {}; + for (const len of [999, 1000, 1001]) { + const modal = await openEditModal(page); + + const ta = modal.locator('textarea[name="bz_template_content"]'); + await ta.fill('가'.repeat(len)); + typed[String(len)] = (await ta.inputValue()).length; + + const [resp] = await Promise.all([ + page.waitForResponse((r) => r.url().includes('/admin/templates') && ['POST', 'PUT'].includes(r.request().method()), { timeout: 20_000 }), + modalSave(page).first().click(), + ]); + results[String(len)] = resp.status(); + } + evidence('4b·10a 본문 길이별 응답', results); + evidence('4b·10a 1001자 입력 후 실제 textarea 길이', typed); + expect(results['999']).toBeLessThan(400); + expect(results['1000']).toBeLessThan(400); + // 1001 자는 서버까지 가지 않는다 — textarea maxLength=1000 이 입력 단계에서 자른다. + // 서버측 max:1000 규칙은 PHPUnit contentMatrixProvider 가 소유한다(이중 판정 아님). + expect(typed['1001'], 'UI 가 1000 자에서 입력을 잘라야 한다').toBe(1000); + expect(results['1001']).toBeLessThan(400); + + const others4b = errors.filter((e) => !/bizppurioCategories|bizppurioProfiles|422|Failed to send logs/.test(e)); + evidence('4b 콘솔 에러', { kapi조회실패: errors.length - others4b.length, 그외: others4b.length }); + expect(others4b).toHaveLength(0); + }); + + test('10c [저장] 더블클릭 시 요청이 1회만 나간다', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + await seedTemplate(page, 'password_changed'); + const modal = await openEditModal(page); + await modal.locator('textarea[name="bz_template_content"]').fill('중복 제출 테스트 #{site_name}'); + + let saves = 0; + let okSaves = 0; + let requests = 0; + let okRequests = 0; + page.on('request', (r) => { + if (['POST', 'PUT'].includes(r.method()) && /\/admin\/templates\/\d+$/.test(r.url())) saves++; + if (r.method() === 'POST' && /\/admin\/templates\/\d+\/request$/.test(r.url())) requests++; + }); + page.on('response', (res) => { + const u = res.url(); + if (res.status() >= 400) return; + if (['POST', 'PUT'].includes(res.request().method()) && /\/admin\/templates\/\d+$/.test(u)) okSaves++; + if (res.request().method() === 'POST' && /\/admin\/templates\/\d+\/request$/.test(u)) okRequests++; + }); + + // ① 일반 저장 더블 클릭 — PUT 은 멱등이므로 요청 수가 아니라 결과 일관성을 본다. + await modalSave(page).first().click({ clickCount: 2, delay: 30 }); + await page.waitForTimeout(3_000); + evidence('10c 저장 더블클릭', { 요청수: saves, 성공응답: okSaves }); + expect(okSaves, '저장이 한 번도 성공하지 않았다').toBeGreaterThan(0); + + // ② [저장 후 검수 신청] 더블 클릭 — §6.3 10c 가 지정한 대상. + // 판정 축은 "요청 수" 가 아니라 **중복 신청 0** 이다. 클라이언트 if 가드는 + // setState 반영 전에 두 번째 click 이 디스패치되면 뚫린다(실측). 실제 방어는 + // 서버의 원자 선점(claimForInspection)이며, 두 번째 신청은 422 로 거부된다. + const modal2 = await openEditModal(page); + await modal2.locator('textarea[name="bz_template_content"]').fill('중복 신청 테스트 #{site_name}'); + await dialog(page).getByRole('button', { name: '저장 후 검수 신청', exact: true }) + .first().click({ clickCount: 2, delay: 30 }); + await page.waitForTimeout(4_000); + evidence('10c 저장후신청 더블클릭', { 신청요청수: requests, 신청성공수: okRequests }); + expect(okRequests, '중복 검수 신청이 성립하면 카카오측 중복 등록이 된다').toBeLessThanOrEqual(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +test.describe('T6·T7 영속성·컨텍스트 격리', () => { + test('6a 저장 → 새로고침 후 본문·유형 복원', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + await seedTemplate(page, 'welcome'); + + // 모달이 실제로 어떤 행을 열었는지는 전역 상태가 알고 있다 — 목록에 여러 행이 있으면 + // first() 가 재조회 뒤 다른 행을 열 수 있어(실측) 행 id 를 근거로 왕복을 맞춘다. + const openedId = async (): Promise => + page.evaluate(() => ((window as any).G7Core?.state?.get?.()?.bz_tpl_modal?.id ?? (window as any).G7Core?.state?.getGlobal?.()?.bz_tpl_modal?.id) ?? null); + + const body = `영속성 확인 ${'가'.repeat(5)} #{site_name} !@#$%^&*() 🎉`; + let modal = await openEditModal(page); + const targetId = await openedId(); + evidence('6a 측정 대상 행 id', targetId); + expect(targetId, '수정 모달이 연 행의 id 를 읽지 못했다').not.toBeNull(); + + await modal.locator('textarea[name="bz_template_content"]').fill(body); + const beforeSave = await page.evaluate(() => { + const g = (window as any).G7Core?.state?.get?.() ?? {}; + const m = g.bz_tpl_modal ?? {}; + return { id: m.id ?? null, len: (m.content?.templateContent ?? '').length, modals: document.querySelectorAll('[id^="bizppurio_tpl_modal_body"]').length }; + }); + evidence('6a 저장 직전 전역 상태', beforeSave); + + const [resp] = await Promise.all([ + page.waitForResponse((r) => r.url().includes('/admin/templates') && ['POST', 'PUT'].includes(r.request().method()), { timeout: 20_000 }), + modalSave(page).first().click(), + ]); + evidence('6a 저장 응답', resp.status()); + expect(resp.status()).toBeLessThan(400); + + // ① 서버에 그대로 남았는가 (영속성) + const stored = await page.evaluate(async (id) => { + const r = await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${id}`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` }, + }); + const j = await r.json(); + + return j?.data?.template?.content?.templateContent ?? null; + }, targetId); + evidence('6a 저장 후 서버 본문 일치', { equal: stored === body, len: (stored ?? '').length }); + expect(stored, '이모지·특수문자·#{var} 가 섞인 본문이 그대로 저장되어야 한다').toBe(body); + + // ② 새로고침 후 화면이 그 값을 복원하는가 (영속성 UI 축) + modal = await openEditModal(page); + const reopenedId = await openedId(); + const restored = await modal.locator('textarea[name="bz_template_content"]').inputValue(); + const expected = reopenedId === targetId + ? body + : await page.evaluate(async (id) => { + const r = await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${id}`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` }, + }); + const j = await r.json(); + + return j?.data?.template?.content?.templateContent ?? null; + }, reopenedId); + + evidence('6a 새로고침 후 복원', { targetId, reopenedId, equal: restored === expected, len: restored.length }); + expect(restored, '새로고침 후 모달이 그 행의 저장값을 그대로 복원해야 한다').toBe(expected); + }); + + test('7a·7b 탭 왕복 시 목록 상태 유지 + 모달 상태 이월 0', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + + const before = new URL(page.url()).search; + // 메일 탭 → 비즈뿌리오 복귀 + const mail = page.locator('#channel_sub_tabs button', { hasText: '메일' }).first(); + if (await mail.count() > 0) { + await mail.click(); + await page.waitForTimeout(800); + await page.locator('#channel_sub_tabs button', { hasText: '비즈뿌리오' }).first().click(); + await page.waitForTimeout(800); + } + const after = new URL(page.url()).search; + evidence('7a 탭 왕복 전/후 쿼리', { before, after }); + expect(after).toContain('channel=alimtalk'); + + // 7b: 모달에 값을 넣고 저장 없이 닫은 뒤 다시 열면 이전 입력이 남지 않는다 + const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first(); + await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click(); + await expect(modal).toBeVisible({ timeout: 15_000 }); + await modal.locator('textarea[name="bz_template_content"]').fill('이월되면 안 되는 값'); + await dialog(page).getByRole('button', { name: '취소', exact: true }).first().click(); + await expect(modal).toBeHidden({ timeout: 10_000 }); + + await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click(); + await expect(modal).toBeVisible({ timeout: 15_000 }); + const reopened = await modal.locator('textarea[name="bz_template_content"]').inputValue(); + evidence('7b 재오픈 시 이전 값 잔존', reopened.includes('이월되면 안 되는 값')); + expect(reopened).not.toContain('이월되면 안 되는 값'); + }); + + test('7c 관리 화면 필터+페이지 상태가 URL 에 보존된다 (mergeQuery)', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await page.goto(`${MANAGE_URL}&bz_status=draft&bz_search=%EA%B0%80`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + await expect(page.locator('#templates_tab_panel')).toBeVisible({ timeout: 20_000 }); + + const q = new URL(page.url()).searchParams; + evidence('7c 관리 화면 URL 파라미터', { + tab: q.get('tab'), bz_status: q.get('bz_status'), bz_search: q.get('bz_search'), + }); + expect(q.get('tab')).toBe('templates'); + expect(q.get('bz_status')).toBe('draft'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +test.describe('T8 3면 패리티·다크·로케일', () => { + for (const [face, url] of [['게시판', BOARD_URL], ['이커머스', ECOMMERCE_URL]] as const) { + test(`8a·8b ${face} 알림 설정에서 통합 탭·행 하단이 동일하게 렌더된다`, async ({ page, messagingManageToken }) => { + const errors = collectConsoleErrors(page); + await authenticatePage(page, messagingManageToken); + await page.goto(url); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const barId = url.includes('/boards/') ? '#board_channel_sub_tabs' : '#ecommerce_channel_sub_tabs'; + const bar = page.locator(barId); + const visible = await bar.isVisible().catch(() => false); + const tabs = visible ? (await bar.locator('button').allInnerTexts()).map((t) => t.trim()) : []; + const rows = await page.locator('[id^="bizppurio_row_lifecycle"]').count(); + evidence(`8 ${face} 탭/행`, { visible, tabs, rows, errors: errors.length }); + + // 면이 존재하면 통합 탭 어휘가 있어야 하고, 개별 채널 탭은 없어야 한다. + if (visible && tabs.length > 0) { + expect(tabs.join('|')).not.toContain('알림톡'); + } + expect(errors).toHaveLength(0); + }); + } + + test('8d·8e 다크 모드 · 영어 로케일에서 원문 키 노출 0', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await page.emulateMedia({ colorScheme: 'dark' }); + await openBizppurioTab(page); + const darkRaw = ((await page.locator('#notif_channel_content').innerText()) + .match(/\$t:|sirsoft-message_bizppurio\./g) ?? []).length; + evidence('8d 다크 모드 원문 키', darkRaw); + expect(darkRaw).toBe(0); + + await page.evaluate(() => localStorage.setItem('g7_locale', 'en')); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + const enText = await page.locator('#notif_channel_content').innerText(); + const enRaw = (enText.match(/\$t:|sirsoft-message_bizppurio\./g) ?? []).length; + evidence('8e 영어 로케일 원문 키', enRaw); + expect(enRaw).toBe(0); + + await page.evaluate(() => localStorage.setItem('g7_locale', 'ko')); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +test.describe('T9 권한 분기', () => { + test('9a view 권한만으로는 작성·신청 조작이 불가하다', async ({ page, messagingViewToken }) => { + await authenticatePage(page, messagingViewToken); + await page.goto(MANAGE_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const composeCount = await page.getByRole('button', { name: '작성', exact: true }).count(); + const editCount = await page.getByRole('button', { name: '수정', exact: true }).count(); + const status = await page.evaluate(async () => { + const r = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}`, + }, + body: JSON.stringify({ notification_type: 'welcome' }), + }); + + return r.status; + }); + evidence('9a view 전용 — 버튼/POST', { composeCount, editCount, postStatus: status }); + expect(status).toBe(403); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +test.describe('라운드5 신규 축 — 업로드 가드·관리 라벨·SMS 언어 탭', () => { + test('U1 업로드 진행 중 저장 계열 버튼이 잠기고 취소는 열려 있다', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click(); + const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first(); + await expect(modal).toBeVisible({ timeout: 15_000 }); + await modal.getByRole('button', { name: '이미지', exact: true }).click(); + + // 업로드 응답을 붙잡아 in-flight 상태를 만든다(네트워크 지연 재현). + let releaseUpload: () => void = () => {}; + const held = new Promise((r) => { releaseUpload = r; }); + await page.route('**/admin/templates/image', async (route) => { + await held; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { url: 'https://mud-kage.kakao.com/x.png' } }) }); + }); + + await modal.locator('input[type="file"]').setInputFiles({ + name: 'a.png', mimeType: 'image/png', buffer: Buffer.from('89504e470d0a1a0a', 'hex'), + }); + await page.waitForTimeout(1_200); + + const save = modalSave(page).first(); + const cancel = dialog(page).getByRole('button', { name: '취소', exact: true }).first(); + const measured = { saveDisabled: await save.isDisabled(), cancelDisabled: await cancel.isDisabled() }; + evidence('U1 업로드 중 버튼 상태', measured); + expect(measured.saveDisabled).toBe(true); + expect(measured.cancelDisabled).toBe(false); + + releaseUpload(); + await expect(save).toBeEnabled({ timeout: 15_000 }); + evidence('U1 업로드 완료 후 저장 버튼', { saveDisabled: await save.isDisabled() }); + }); + + test('R5 관리 화면 라벨이 내용 유무로 갈린다 + SMS 언어 탭이 존재한다', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await page.goto(MANAGE_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + await expect(page.locator('#templates_tab_panel')).toBeVisible({ timeout: 20_000 }); + + // 관리 목록은 readiness(카카오 API 키 + 발신프로필) 충족 시에만 렌더된다 — 두 블록은 배타다. + const gate = { + readiness: await page.locator('#templates_readiness').count(), + manageView: await page.locator('#templates_manage_view').count(), + }; + evidence('R5 readiness 게이트', gate); + expect(gate.readiness + gate.manageView, 'readiness 안내와 목록은 정확히 하나만 보여야 한다').toBe(1); + if (gate.manageView === 0) { + evidence('R5 라벨 측정', '(readiness 미충족 — 목록 미렌더)'); + + return; + } + + // 목록 행 수를 API 로 먼저 잰다 — "행이 없어서 0" 과 "라벨 분기가 죽어서 0" 은 다른 판정이다. + const total = await page.evaluate(async () => { + const r = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates?per_page=20', { + headers: { Accept: 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` }, + }); + const j = await r.json(); + + return j?.data?.pagination?.total ?? (j?.data?.templates?.length ?? 0); + }); + const compose = await page.getByRole('button', { name: '작성', exact: true }).count(); + const edit = await page.getByRole('button', { name: '수정', exact: true }).count(); + evidence('R5 관리 화면 행 수/버튼 수', { total, compose, edit }); + if (total > 0) { + expect(compose + edit, '행이 있는데 작성·수정 라벨이 하나도 없으면 라벨 분기가 죽은 것').toBeGreaterThan(0); + } + + // SMS 본문 모달 → 언어 탭 + const smsBtn = page.getByRole('button', { name: 'SMS 본문', exact: false }).first(); + if (await smsBtn.count() > 0) { + await smsBtn.click(); + const tabs = page.locator('#bz_sms_lang_tabs'); + await expect(tabs).toBeVisible({ timeout: 15_000 }); + const langs = (await tabs.locator('button').allInnerTexts()).map((t) => t.trim()); + evidence('R5 SMS 언어 탭', langs); + expect(langs.length).toBeGreaterThan(0); + } else { + evidence('R5 SMS 언어 탭', '(SMS 본문 버튼 없음 — 행 없음)'); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +test.describe('회귀 축', () => { + test('R1·R2 메일/데이터베이스 탭과 채널 토글 카드가 정상 렌더된다', async ({ page, messagingManageToken }) => { + const errors = collectConsoleErrors(page); + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + + const cards = await page.locator('#channel_toggles [role="switch"]').count(); + evidence('R2 채널 토글 카드 수', cards); + expect(cards).toBeGreaterThanOrEqual(4); + + for (const label of ['메일', '데이터베이스']) { + const tab = page.locator('#channel_sub_tabs button', { hasText: label }).first(); + if (await tab.count() === 0) continue; + await tab.click(); + await page.waitForTimeout(1_000); + const rendered = await page.locator('#notif_channel_content').count(); + evidence(`R1 ${label} 탭 콘텐츠`, rendered); + expect(rendered).toBeGreaterThan(0); + } + evidence('R1 콘솔 에러 수', errors.length); + expect(errors).toHaveLength(0); + }); + + test('R3·R4 플러그인 연동 설정·발송 이력 탭이 정상 렌더된다', async ({ page, messagingManageToken }) => { + const errors = collectConsoleErrors(page); + await authenticatePage(page, messagingManageToken); + await page.goto('/admin/plugins/sirsoft-message_bizppurio/settings'); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const tabs = (await page.locator('button').allInnerTexts()).map((t) => t.trim()); + evidence('R3·R4 플러그인 설정 탭', tabs.filter((t) => t && t.length < 20).slice(0, 12)); + evidence('R3·R4 콘솔 에러 수', errors.length); + expect(errors).toHaveLength(0); + }); + + test('R6 사용자(basic) 화면이 영향을 받지 않는다', async ({ page }) => { + const errors = collectConsoleErrors(page); + await page.goto('/'); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + const title = await page.title(); + evidence('R6 사용자 화면', { title, errors: errors.length, messages: errors.slice(0, 5) }); + // 에러 원문을 남긴다 — 개편과 무관한 사전존재 에러와 회귀를 구분하는 근거가 된다. + expect(errors.filter((e) => /bizppurio/i.test(e)), + '사용자 화면에 비즈뿌리오 관련 에러가 있으면 회귀다').toHaveLength(0); + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts new file mode 100644 index 00000000..81da85d3 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts @@ -0,0 +1,163 @@ +/** + * E2E: 비즈뿌리오 알림 템플릿 라이프사이클 화면 (#597) + * + * @scenario bizppurio_tab_integration_e2e, bizppurio_compose_modal_e2e, bizppurio_manage_round_trip_e2e + * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_badge_and_lifecycle_actions, + * compose_modal_switches_conditional_fields_by_type, manage_screen_lists_db_rows_with_merge_query_round_trip + * + * 검증 대상(브라우저에서만 잡히는 축): + * 1. 코어 알림 설정 서브탭 통합 — '비즈뿌리오' 단일 탭 노출, 문자·알림톡 개별 탭 부재 + * (탭 필터 표현식 3면 수정의 소비처 실렌더) + * 2. 행 하단 라이프사이클 UI — 상태 배지 + 작성 버튼 + SMS 체크박스 렌더, i18n 원문 노출 0 + * 3. 작성 모달 — 강조 유형 전환(없음→강조표기→이미지→아이템리스트) 시 조건부 필드 등장/소멸 + * 4. 플러그인 관리 화면 — 상태 필터가 URL(bz_status)에 보존되는 목록 왕복(mergeQuery) + * + * 카카오 API(kapi) 실호출이 필요한 검수 신청·동기화 경로는 브라우저 E2E 범위 밖이다 + * (자격증명·실검수 필요 — PHPUnit 이 Http::fake 로 전수 가드, 실연동은 PO 수행 단계). + * + * 계획서 §6.2 의 "작성→신청→(kapi mock 승인)→발송 배지" 왕복 축을 여기에 두지 않은 이유는 + * 구조적이다: kapi 호출은 서버(PHP)에서 서버로 나가고, Playwright 의 route 가로채기는 + * 브라우저→서버 구간만 덮는다. 따라서 브라우저 경로에서는 승인 전이를 mock 으로 만들 수 + * 없고, approved 상태를 만들려면 DB 를 직접 조작해야 하는데 그것은 화면 왕복 검증이 아니다. + * 승인 전이와 발송 게이트는 PHPUnit(BizppurioTemplateServiceTest 의 sync 전이, + * AlimtalkChannelDriverTest 의 게이트 5분기)이 소유하고, 브라우저는 그 결과를 그리는 + * 배지·액션 분기(위 2번 축)만 담당한다. + */ +import { test, expect, authenticatePage } from '../../fixtures/bizppurio-auth'; + +const SETTINGS_URL = '/admin/settings?tab=notification_definitions&channel=alimtalk'; +const MANAGE_URL = '/admin/plugins/sirsoft-message_bizppurio/settings?tab=templates'; + +test.describe('비즈뿌리오 통합 탭 (코어 알림 설정)', () => { + // @scenario bizppurio_tab_integration_e2e + // @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active + test('채널 활성 저장 시 서브탭에 비즈뿌리오 단일 탭이 노출되고 문자·알림톡 개별 탭은 없다', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const tabBar = page.locator('#channel_sub_tabs'); + await expect(tabBar).toBeVisible({ timeout: 30_000 }); + + // 확장 채널은 opt-in(미저장=OFF)이라 통합 탭은 tab_channels 중 하나라도 활성 저장돼야 + // 노출된다. 미노출 상태면 알림톡 토글 카드를 켜고 저장해 활성화한다(멱등 — 이미 켜져 + // 있으면 이 분기를 타지 않는다). + if (!(await tabBar.innerText()).includes('비즈뿌리오')) { + const card = page + .locator('#channel_toggles') + .locator('div.flex-between', { hasText: '비즈뿌리오 알림톡' }) + .first(); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.locator('[role="switch"]').click(); + + await page.locator('#save_button').click(); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + } + + const tabTexts = await tabBar.locator('button').allInnerTexts(); + const joined = tabTexts.join('|'); + + // 통합 탭 라벨(tab_label_key) 노출 + 채널 개별 라벨 부재 + expect(joined).toContain('비즈뿌리오'); + expect(joined).not.toContain('비즈뿌리오 문자'); + expect(joined).not.toContain('비즈뿌리오 알림톡'); + }); + + // @scenario bizppurio_tab_integration_e2e + // @effects row_footer_shows_status_badge_and_lifecycle_actions + test('알림 행 하단에 알림톡 라이프사이클 줄과 SMS 설정 줄이 렌더된다', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const rows = page.locator('[id^="bizppurio_row_lifecycle"]'); + await expect(rows.first()).toBeVisible({ timeout: 30_000 }); + + // 알림톡 라벨 + 상태 배지(미작성/작성중/…) + SMS 체크박스 라벨 + const firstRow = rows.first(); + await expect(firstRow.getByText('알림톡', { exact: false }).first()).toBeVisible(); + await expect(firstRow.getByText('대체 SMS', { exact: false }).first()).toBeVisible(); + await expect(firstRow.getByText('SMS 단독', { exact: false }).first()).toBeVisible(); + + // i18n 원문 키 노출 0 (탭 콘텐츠 영역 전체) + const content = await page.locator('#notif_channel_content').innerText(); + expect(content).not.toContain('$t:'); + expect(content).not.toContain('sirsoft-message_bizppurio.'); + }); + + // @scenario bizppurio_compose_modal_e2e + // @effects compose_modal_switches_conditional_fields_by_type + test('작성 모달의 강조 유형 전환 시 조건부 필드가 등장·소멸한다', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + // 미작성 행의 [알림톡 템플릿 작성] → 모달 오픈 + const composeButton = page.getByRole('button', { name: '알림톡 템플릿 작성' }).first(); + await expect(composeButton).toBeVisible({ timeout: 30_000 }); + await composeButton.click(); + + const modal = page.locator('[id^="bizppurio_tpl_modal_body"]').first(); + await expect(modal).toBeVisible({ timeout: 15_000 }); + + // 본문 textarea 는 상시 + await expect(modal.locator('textarea[name="bz_template_content"]')).toBeVisible(); + + // 없음(NONE): 강조표기·이미지·아이템 필드 부재 + await expect(modal.getByText('강조표기 타이틀', { exact: false })).toHaveCount(0); + + // 강조표기(TEXT): 타이틀·서브타이틀 등장 + await modal.getByRole('button', { name: '강조표기', exact: true }).click(); + await expect(modal.getByText('강조표기 타이틀', { exact: false }).first()).toBeVisible(); + + // 이미지(IMAGE): 파일 입력 등장, TEXT 필드 소멸 + await modal.getByRole('button', { name: '이미지', exact: true }).click(); + await expect(modal.locator('input[type="file"]')).toBeVisible(); + await expect(modal.getByText('강조표기 타이틀', { exact: false })).toHaveCount(0); + + // 아이템리스트(ITEM_LIST): 아이템 추가 버튼 등장 + await modal.getByRole('button', { name: '아이템리스트', exact: true }).click(); + await expect(modal.getByRole('button', { name: '아이템 추가' })).toBeVisible(); + await expect(modal.locator('input[type="file"]')).toHaveCount(0); + + // 버튼 추가 — 1행 추가 후 linkType 셀렉트·이름 입력 등장 + await modal.getByRole('button', { name: '버튼 추가' }).click(); + await expect(modal.getByPlaceholder('버튼명', { exact: false }).first()).toBeVisible(); + }); +}); + +test.describe('비즈뿌리오 알림 템플릿 관리 (플러그인 설정)', () => { + // @scenario bizppurio_manage_round_trip_e2e + // @effects manage_screen_lists_db_rows_with_merge_query_round_trip + test('상태 필터가 URL 에 보존되고 목록이 렌더된다 (mergeQuery 왕복)', async ({ page, messagingManageToken }) => { + await authenticatePage(page, messagingManageToken); + await page.goto(MANAGE_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const manageView = page.locator('#templates_manage_view'); + const readiness = page.locator('#templates_readiness'); + + // 자격증명 미설정 환경이면 readiness 안내가 정상 경로다 — 관리 뷰 검증은 ready 환경에서만. + if (await readiness.isVisible().catch(() => false)) { + await expect(readiness.getByText('환경설정', { exact: false }).first()).toBeVisible(); + return; + } + + await expect(manageView).toBeVisible({ timeout: 30_000 }); + + // 상태 필터 변경 → URL bz_status 반영 (mergeQuery — tab 파라미터 유지) + const filterRoot = manageView.locator('[aria-haspopup]').first(); + await filterRoot.click(); + await page.getByRole('option', { name: '승인됨' }).click(); + + await expect(page).toHaveURL(/bz_status=approved/); + await expect(page).toHaveURL(/tab=templates/); + + // i18n 원문 노출 0 + const text = await manageView.innerText(); + expect(text).not.toContain('$t:'); + expect(text).not.toContain('sirsoft-message_bizppurio.'); + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/BizppurioTemplateStatusTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/BizppurioTemplateStatusTest.php new file mode 100644 index 00000000..7572867d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/BizppurioTemplateStatusTest.php @@ -0,0 +1,175 @@ +assertSame(BizppurioTemplateStatus::Draft, BizppurioTemplateStatus::tryFromServiceStatus('REG')); + $this->assertSame(BizppurioTemplateStatus::Requested, BizppurioTemplateStatus::tryFromServiceStatus('REQ')); + $this->assertSame(BizppurioTemplateStatus::Rejected, BizppurioTemplateStatus::tryFromServiceStatus('REJ')); + $this->assertSame(BizppurioTemplateStatus::Approved, BizppurioTemplateStatus::tryFromServiceStatus('RDY')); + $this->assertSame(BizppurioTemplateStatus::Approved, BizppurioTemplateStatus::tryFromServiceStatus('ACT')); + $this->assertSame(BizppurioTemplateStatus::Stopped, BizppurioTemplateStatus::tryFromServiceStatus('STP')); + $this->assertSame(BizppurioTemplateStatus::Blocked, BizppurioTemplateStatus::tryFromServiceStatus('BLK')); + $this->assertSame(BizppurioTemplateStatus::Dormant, BizppurioTemplateStatus::tryFromServiceStatus('DMT')); + } + + /** + * 알 수 없는 코드는 null — 동기화 호출측이 상태를 덮어쓰지 않고 건너뛴다. + */ + /** + * @effects unknown_service_status_does_not_overwrite_state + */ + public function test_미지의_service_status는_null(): void + { + $this->assertNull(BizppurioTemplateStatus::tryFromServiceStatus('XX')); + } + + /** + * serviceStatus 필드가 있으면 유도 없이 그대로 사용한다. + */ + public function test_상세_유도는_service_status_필드를_우선한다(): void + { + $this->assertSame('ACT', BizppurioTemplateStatus::serviceStatusFromDetail([ + 'serviceStatus' => 'ACT', + // 아래 필드는 무시되어야 한다 (serviceStatus 우선). + 'inspectionStatus' => 'REJ', + 'status' => 'S', + ])); + } + + /** + * block 플래그는 최우선으로 BLK 를 유도한다. + */ + public function test_상세_유도_block은_blk(): void + { + $this->assertSame('BLK', BizppurioTemplateStatus::serviceStatusFromDetail([ + 'inspectionStatus' => 'APR', + 'status' => 'A', + 'block' => true, + ])); + } + + /** + * dormant 플래그는 DMT 를 유도한다. + */ + public function test_상세_유도_dormant는_dmt(): void + { + $this->assertSame('DMT', BizppurioTemplateStatus::serviceStatusFromDetail([ + 'inspectionStatus' => 'APR', + 'status' => 'A', + 'dormant' => true, + ])); + } + + /** + * inspectionStatus REQ(검수중)는 REQ 를 유도한다. + */ + public function test_상세_유도_inspection_re_q는_req(): void + { + $this->assertSame('REQ', BizppurioTemplateStatus::serviceStatusFromDetail([ + 'inspectionStatus' => 'REQ', + 'status' => 'R', + ])); + } + + /** + * inspectionStatus REJ(반려)는 REJ 를 유도한다. + */ + public function test_상세_유도_inspection_re_j는_rej(): void + { + $this->assertSame('REJ', BizppurioTemplateStatus::serviceStatusFromDetail([ + 'inspectionStatus' => 'REJ', + 'status' => 'R', + ])); + } + + /** + * 승인(APR) + status S(중지)는 STP 를 유도한다. + */ + public function test_상세_유도_ap_r_상태_s는_stp(): void + { + $this->assertSame('STP', BizppurioTemplateStatus::serviceStatusFromDetail([ + 'inspectionStatus' => 'APR', + 'status' => 'S', + ])); + } + + /** + * 승인(APR) + status A(정상)는 ACT 를 유도한다. + */ + public function test_상세_유도_ap_r_상태_a는_act(): void + { + $this->assertSame('ACT', BizppurioTemplateStatus::serviceStatusFromDetail([ + 'inspectionStatus' => 'APR', + 'status' => 'A', + ])); + } + + /** + * 승인(APR) + status R(발송전)은 RDY 를 유도한다. + */ + public function test_상세_유도_ap_r_상태_r은_rdy(): void + { + $this->assertSame('RDY', BizppurioTemplateStatus::serviceStatusFromDetail([ + 'inspectionStatus' => 'APR', + 'status' => 'R', + ])); + } + + /** + * 판정 조건이 하나도 성립하지 않으면 기본값 REG(등록)를 유도한다. + */ + public function test_상세_유도_기본값은_reg(): void + { + $this->assertSame('REG', BizppurioTemplateStatus::serviceStatusFromDetail([ + 'inspectionStatus' => 'REG', + 'status' => 'R', + ])); + $this->assertSame('REG', BizppurioTemplateStatus::serviceStatusFromDetail([])); + } + + /** + * isApproved 는 Approved 상태만 true 다 (발송 게이트 근거). + */ + public function test_is_approved는_승인만_true(): void + { + $this->assertTrue(BizppurioTemplateStatus::Approved->isApproved()); + + $this->assertFalse(BizppurioTemplateStatus::Draft->isApproved()); + $this->assertFalse(BizppurioTemplateStatus::Requested->isApproved()); + $this->assertFalse(BizppurioTemplateStatus::Rejected->isApproved()); + $this->assertFalse(BizppurioTemplateStatus::Stopped->isApproved()); + $this->assertFalse(BizppurioTemplateStatus::Blocked->isApproved()); + $this->assertFalse(BizppurioTemplateStatus::Dormant->isApproved()); + } + + /** + * allowsContentEdit 는 Draft·Rejected 만 true 다 (kapi update/delete 허용 상태, 부록 A-4). + */ + public function test_allows_content_edit는_draft와_rejected만_true(): void + { + $this->assertTrue(BizppurioTemplateStatus::Draft->allowsContentEdit()); + $this->assertTrue(BizppurioTemplateStatus::Rejected->allowsContentEdit()); + + $this->assertFalse(BizppurioTemplateStatus::Requested->allowsContentEdit()); + $this->assertFalse(BizppurioTemplateStatus::Approved->allowsContentEdit()); + $this->assertFalse(BizppurioTemplateStatus::Stopped->allowsContentEdit()); + $this->assertFalse(BizppurioTemplateStatus::Blocked->allowsContentEdit()); + $this->assertFalse(BizppurioTemplateStatus::Dormant->allowsContentEdit()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php index eb6d8a7a..982308aa 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php @@ -94,6 +94,31 @@ class RegisterNotificationChannelsListenerTest extends PluginTestCase $this->assertFalse($alimtalk['is_test_mode'], '운영 모드면 is_test_mode=false.'); } + public function test_채널메타에_비즈뿌리오_통합_탭_필드가_실린다(): void + { + // #597 §3.3 '비즈뿌리오' 통합 탭: sms 는 탭만 숨기고(hidden_tab — 채널 토글 카드·발송 + // 축은 유지), alimtalk 이 두 채널을 묶은 통합 탭을 대표한다(tab_channels/tab_label_key). + // 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다. + $channels = $this->makeListener()->addChannels([]); + + $sms = collect($channels)->firstWhere('id', 'sms'); + $this->assertTrue($sms['hidden_tab'], 'sms 는 자체 탭을 숨겨야 한다(hidden_tab:true).'); + $this->assertArrayNotHasKey('tab_channels', $sms, '통합 탭 대표는 alimtalk 하나뿐이어야 한다.'); + + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertArrayNotHasKey('hidden_tab', $alimtalk, 'alimtalk 탭은 숨기지 않는다(통합 탭 대표).'); + $this->assertSame( + ['sms', 'alimtalk'], + $alimtalk['tab_channels'], + 'tab_channels 중 하나라도 활성 저장이면 통합 탭을 노출한다.' + ); + $this->assertSame( + 'sirsoft-message_bizppurio.channels.bizppurio_tab', + $alimtalk['tab_label_key'], + '탭 라벨은 프론트 lang 키($t 해석)로 선언한다(카드 라벨 name_key 와 분리).' + ); + } + public function test_어떤_채널에도_uses_custom_list_플래그가_없다(): void { // Phase 6 재설계 A(⚑⚑ 결정 A): 알림톡 탭도 코어 기본 목록을 그대로 쓴다. 코어 목록을 diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioTemplateTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioTemplateTest.php new file mode 100644 index 00000000..3e0357ce --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioTemplateTest.php @@ -0,0 +1,208 @@ + $overrides + */ + private function makeTemplate(array $overrides = []): BizppurioTemplate + { + return BizppurioTemplate::create(array_merge([ + 'notification_type' => 'order.completed.'.uniqid(), + 'alimtalk_enabled' => true, + 'template_code' => 'TW_'.uniqid(), + 'sender_key' => 'SK_TEST', + 'content' => ['templateName' => '주문완료', 'templateContent' => '#{name}님 주문이 완료되었습니다.'], + 'approved_content' => ['templateName' => '주문완료', 'templateContent' => '#{name}님 주문이 완료되었습니다.'], + 'status' => BizppurioTemplateStatus::Approved->value, + 'is_active' => true, + ], $overrides)); + } + + /** + * JSON 컬럼 3종(content/approved_content/inspection_detail)이 배열로 저장·조회된다. + */ + /** + * @effects model_casts_content_snapshot_inspection_json + */ + public function test_json_컬럼은_배열로_저장되고_조회된다(): void + { + $template = $this->makeTemplate([ + 'content' => ['templateName' => 'T', 'buttons' => [['name' => '바로가기']]], + 'approved_content' => ['templateName' => 'T승인'], + 'inspection_detail' => [['content' => '반려 사유 원문']], + ]); + + $fresh = BizppurioTemplate::query()->find($template->id); + + $this->assertSame('T', $fresh->content['templateName']); + $this->assertSame('바로가기', $fresh->content['buttons'][0]['name']); + $this->assertSame(['templateName' => 'T승인'], $fresh->approved_content); + $this->assertSame([['content' => '반려 사유 원문']], $fresh->inspection_detail); + } + + /** + * status 컬럼은 BizppurioTemplateStatus enum 으로 캐스트된다. + */ + public function test_status는_enum으로_캐스트된다(): void + { + $template = $this->makeTemplate(['status' => 'requested']); + + $fresh = BizppurioTemplate::query()->find($template->id); + + $this->assertSame(BizppurioTemplateStatus::Requested, $fresh->status); + } + + /** + * boolean 4종(alimtalk_enabled/fallback_sms_enabled/sms_only/is_active)이 캐스트된다. + */ + public function test_boolean_4종_캐스트(): void + { + $template = $this->makeTemplate([ + 'alimtalk_enabled' => 1, + 'fallback_sms_enabled' => 1, + 'sms_only' => 0, + 'is_active' => 0, + ]); + + $fresh = BizppurioTemplate::query()->find($template->id); + + $this->assertTrue($fresh->alimtalk_enabled); + $this->assertTrue($fresh->fallback_sms_enabled); + $this->assertFalse($fresh->sms_only); + $this->assertFalse($fresh->is_active); + } + + /** + * datetime 3종(requested_at/approved_at/last_synced_at)이 Carbon 으로 캐스트된다. + */ + public function test_datetime_3종_캐스트(): void + { + $template = $this->makeTemplate([ + 'requested_at' => '2026-08-01 09:00:00', + 'approved_at' => '2026-08-02 10:30:00', + 'last_synced_at' => '2026-08-03 11:45:00', + ]); + + $fresh = BizppurioTemplate::query()->find($template->id); + + $this->assertInstanceOf(Carbon::class, $fresh->requested_at); + $this->assertInstanceOf(Carbon::class, $fresh->approved_at); + $this->assertInstanceOf(Carbon::class, $fresh->last_synced_at); + $this->assertSame('2026-08-01 09:00:00', $fresh->requested_at->format('Y-m-d H:i:s')); + $this->assertSame('2026-08-02 10:30:00', $fresh->approved_at->format('Y-m-d H:i:s')); + $this->assertSame('2026-08-03 11:45:00', $fresh->last_synced_at->format('Y-m-d H:i:s')); + } + + /** + * 발송 게이트: 승인 + 활성 + 알림톡 사용 + 승인 스냅샷 존재 → 발송 가능. + */ + public function test_알림톡_발송_게이트_모든_조건_충족시_true(): void + { + $this->assertTrue($this->makeTemplate()->isAlimtalkSendable()); + } + + /** + * 발송 게이트: status 가 draft(승인 취소 복귀 포함)면 승인 스냅샷이 남아 있어도 차단. + */ + public function test_알림톡_발송_게이트_draft_상태면_false(): void + { + $template = $this->makeTemplate(['status' => BizppurioTemplateStatus::Draft->value]); + + $this->assertFalse($template->isAlimtalkSendable()); + } + + /** + * 발송 게이트: alimtalk_enabled 가 꺼져 있으면 차단. + */ + public function test_알림톡_발송_게이트_alimtalk_enabled_false면_false(): void + { + $template = $this->makeTemplate(['alimtalk_enabled' => false]); + + $this->assertFalse($template->isAlimtalkSendable()); + } + + /** + * 발송 게이트: is_active 가 꺼져 있으면 차단. + */ + public function test_알림톡_발송_게이트_is_active_false면_false(): void + { + $template = $this->makeTemplate(['is_active' => false]); + + $this->assertFalse($template->isAlimtalkSendable()); + } + + /** + * 발송 게이트: 승인 스냅샷(approved_content)이 null 이면 차단. + */ + public function test_알림톡_발송_게이트_approved_content_null이면_false(): void + { + $template = $this->makeTemplate(['approved_content' => null]); + + $this->assertFalse($template->isAlimtalkSendable()); + } + + /** + * 발송 게이트: 승인 스냅샷이 빈 배열이어도 차단 (내용 없는 스냅샷으로는 발송 불가). + */ + public function test_알림톡_발송_게이트_approved_content_빈배열이면_false(): void + { + $template = $this->makeTemplate(['approved_content' => []]); + + $this->assertFalse($template->isAlimtalkSendable()); + } + + /** + * notification_type 은 unique — 알림 1건당 1행 원칙을 DB 가 강제한다. + */ + public function test_notification_type_중복_생성시_query_exception(): void + { + $this->makeTemplate(['notification_type' => 'order.completed.dup']); + + $this->expectException(QueryException::class); + + $this->makeTemplate(['notification_type' => 'order.completed.dup']); + } + + public function test_sms_단독이면_승인된_알림톡도_발송하지_않는다(): void + { + // "SMS 단독" 은 운영자가 이 알림에서 알림톡을 쓰지 않겠다고 명시한 것이다(#597 §3.5). + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'alimtalk_enabled' => true, + 'is_active' => true, + 'sms_only' => true, + 'status' => BizppurioTemplateStatus::Approved->value, + 'approved_content' => ['templateContent' => '승인 본문'], + ]); + + $this->assertFalse($template->isAlimtalkSendable()); + } + + public function test_sms_단독이_아니면_승인된_알림톡은_발송한다(): void + { + $template = BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'alimtalk_enabled' => true, + 'is_active' => true, + 'sms_only' => false, + 'status' => BizppurioTemplateStatus::Approved->value, + 'approved_content' => ['templateContent' => '승인 본문'], + ]); + + $this->assertTrue($template->isAlimtalkSendable()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php deleted file mode 100644 index 88c0be33..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php +++ /dev/null @@ -1,67 +0,0 @@ -repo = new BizppurioNotificationBindingRepository; - } - - public function test_upsert_creates_then_updates(): void - { - $created = $this->repo->upsert('welcome', 'alimtalk', [ - 'template_code' => 'TW_1', - 'template_name' => '가입환영', - 'fallback_sms_enabled' => true, - ]); - $this->assertSame('TW_1', $created->template_code); - - // 같은 (type, channel) 재저장 → 갱신(중복 생성 아님) - $updated = $this->repo->upsert('welcome', 'alimtalk', [ - 'template_code' => 'TW_2', - 'template_name' => '가입환영v2', - 'fallback_sms_enabled' => false, - ]); - - $this->assertSame($created->id, $updated->id); - $this->assertSame('TW_2', $updated->template_code); - $this->assertDatabaseCount('bizppurio_notification_bindings', 1); - } - - public function test_find_active_only_returns_active(): void - { - $this->repo->upsert('welcome', 'alimtalk', [ - 'template_code' => 'TW_1', - 'template_name' => 'n', - 'is_active' => true, - ]); - - $this->assertNotNull($this->repo->findActive('welcome')); - - $this->repo->upsert('welcome', 'alimtalk', ['is_active' => false, 'template_code' => 'TW_1', 'template_name' => 'n']); - $this->assertNull($this->repo->findActive('welcome')); - } - - public function test_delete(): void - { - $this->repo->upsert('order_confirmed', 'alimtalk', [ - 'template_code' => 'TW_9', - 'template_name' => '주문완료', - ]); - - $this->repo->delete('order_confirmed', 'alimtalk'); - $this->assertNull($this->repo->findActive('order_confirmed')); - $this->assertDatabaseCount('bizppurio_notification_bindings', 0); - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php index 563b8297..277bdbe1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -2,126 +2,84 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services; -use App\Models\NotificationTemplate; +use App\Models\NotificationDefinition; use App\Models\User; use App\Notifications\GenericNotification; use App\Notifications\GuestNotifiable; use App\Services\NotificationDefinitionService; -use App\Services\NotificationTemplateService; use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Http; use Mockery; +use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus; use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; -use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; -use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkPayloadMapper; use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext; -use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver; use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** - * AlimtalkChannelDriver — binding 게이트·변수치환({x}→#{x})·대체발송·Job 위임 검증. + * AlimtalkChannelDriver — 승인 스냅샷 게이트·변수치환·대체발송·Job 위임 검증 (#597 §3.5). * - * 알림톡은 SMS 와 달리 "관리자가 연결한 승인 템플릿(binding)"이 있을 때만 발송된다. + * 알림톡 본문·요소의 출처는 bizppurio_templates 의 approved_content(승인 스냅샷)다. + * 게이트 = alimtalk_enabled + is_active + status=approved + approved_content 존재. * 발송 Job dispatch 는 Bus::fake 로 관찰한다(실제 발송은 SendMessageJobTest 가 커버). */ class AlimtalkChannelDriverTest extends PluginTestCase { /** - * 렌더 결과를 반환하는 NotificationTemplate mock 을 만듭니다. + * 승인 스냅샷을 담은 템플릿 행을 만듭니다. * - * @param array $rendered replaceVariables 반환값 + * @param array|null $approved approved_content */ - private function fakeTemplate(array $rendered = ['subject' => '', 'body' => '{name} 님 주문이 완료되었습니다.']): NotificationTemplate - { - $template = Mockery::mock(NotificationTemplate::class)->makePartial(); - $template->is_active = true; - $template->shouldReceive('replaceVariables')->andReturn($rendered); + private function templateRow( + ?array $approved = null, + BizppurioTemplateStatus $status = BizppurioTemplateStatus::Approved, + bool $alimtalkEnabled = true, + bool $isActive = true, + bool $fallback = false, + string|array|null $smsBody = null, + ): BizppurioTemplate { + $row = new BizppurioTemplate; + $row->notification_type = 'order_confirmed'; + $row->alimtalk_enabled = $alimtalkEnabled; + $row->template_code = 'g7_abcd1234_1'; + $row->status = $status; + $row->approved_content = $approved ?? ['templateContent' => '#{name}님 주문이 완료되었습니다.']; + $row->fallback_sms_enabled = $fallback; + // sms_body 는 로케일 맵이다(#597 §14.3). 문자열은 ko 본문으로 취급한다. + $row->sms_body = is_string($smsBody) ? ['ko' => $smsBody] : $smsBody; + $row->is_active = $isActive; - return $template; + return $row; } /** - * findActive 반환값을 지정한 binding 리포지토리 mock 을 만듭니다. - * - * @param BizppurioNotificationBinding|null $binding findActive 반환값 + * findByType 반환값을 지정한 템플릿 리포지토리 mock 을 만듭니다. */ - private function fakeBindings(?BizppurioNotificationBinding $binding): BizppurioNotificationBindingRepositoryInterface + private function fakeTemplates(?BizppurioTemplate $row): BizppurioTemplateRepositoryInterface { - $repo = Mockery::mock(BizppurioNotificationBindingRepositoryInterface::class); - $repo->shouldReceive('findActive') - ->with(Mockery::any(), 'alimtalk') - ->andReturn($binding); + $repo = Mockery::mock(BizppurioTemplateRepositoryInterface::class); + $repo->shouldReceive('findByType')->andReturn($row); return $repo; } /** - * 연결(binding) 모델을 만듭니다. - */ - private function binding(bool $fallback = false): BizppurioNotificationBinding - { - $binding = new BizppurioNotificationBinding; - $binding->notification_type = 'order_confirmed'; - $binding->channel = 'alimtalk'; - $binding->template_code = 'TW_1234'; - $binding->template_name = '주문완료'; - $binding->fallback_sms_enabled = $fallback; - $binding->is_active = true; - - return $binding; - } - - /** - * 카카오 상세조회 내용을 반환하는 KakaoTemplateContentResolver mock 을 만듭니다. - * - * @param array|null $content resolve 반환값 (null=조회 실패 → skip) - */ - private function fakeKakaoContent(?array $content): KakaoTemplateContentResolver - { - $resolver = Mockery::mock(KakaoTemplateContentResolver::class); - $resolver->shouldReceive('resolve')->andReturn($content); - - return $resolver; - } - - /** - * 기본 카카오 상세(본문만 있는 단순 승인 템플릿). - * - * @return array - */ - private function kakaoContent(string $templateContent = '#{name}님 주문이 완료되었습니다.'): array - { - return ['templateCode' => 'TW_1234', 'templateContent' => $templateContent]; - } - - /** - * 템플릿·binding·빌더를 조합한 driver 를 만듭니다. - * - * @param array|null $kakao 카카오 상세조회 내용 (null=조회 실패) - * @param bool $isTestMode 검수 모드 설정값 (이력 스냅샷 검증용) + * 드라이버를 조립합니다. */ private function makeDriver( - ?NotificationTemplate $template, - ?BizppurioNotificationBinding $binding, + ?BizppurioTemplate $row, ?MessagePayloadBuilder $builder = null, - array|false|null $kakao = false, bool $isTestMode = true, ): AlimtalkChannelDriver { - $templateService = Mockery::mock(NotificationTemplateService::class); - $templateService->shouldReceive('resolve') - ->with(Mockery::any(), 'alimtalk') - ->andReturn($template); - - // $kakao 기본값 false = "기본 카카오 내용 제공"(대부분 테스트). null = 조회 실패. - $content = $kakao === false ? $this->kakaoContent() : $kakao; - $pluginSettings = Mockery::mock(PluginSettingsService::class); $pluginSettings->shouldReceive('get') ->with('sirsoft-message_bizppurio', 'is_test_mode', true) @@ -132,13 +90,11 @@ class AlimtalkChannelDriverTest extends PluginTestCase $definitionService->shouldReceive('resolve')->andReturn(null); return new AlimtalkChannelDriver( - $templateService, $definitionService, - $this->fakeBindings($binding), + $this->fakeTemplates($row), $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, new DispatchLinkContext, - $this->fakeKakaoContent($content), new AlimtalkPayloadMapper, $pluginSettings, ); @@ -180,47 +136,163 @@ class AlimtalkChannelDriverTest extends PluginTestCase return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); } - public function test_연결된_템플릿이_없으면_예외를_던지고_발송하지_않는다(): void + /** + * @scenario template_state=row_missing, fallback_sms=off, sms_only=off, recipient=member + * + * @effects alimtalk_skipped_when_row_missing, no_kapi_call_at_dispatch_time + */ + public function test_템플릿_행이_없으면_예외를_던지고_발송하지_않는다(): void + { + Bus::fake(); + Http::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver(null)->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + $this->assertDatabaseCount('bizppurio_dispatches', 0); + // 발송 판정은 DB 행만 본다 — 게이트 불충족 시에도 카카오 조회가 없어야 한다(§3.4). + Http::assertNothingSent(); + } + } + + /** + * @scenario template_state=unapproved, fallback_sms=off, sms_only=off, recipient=member + * + * @effects alimtalk_skipped_when_not_approved, no_kapi_call_at_dispatch_time + */ + public function test_미승인_상태면_예외를_던지고_발송하지_않는다(): void + { + Bus::fake(); + Http::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // draft 상태 — 승인 스냅샷이 남아 있어도(승인취소 복귀) status 가 approved 가 아니면 차단. + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($this->templateRow(status: BizppurioTemplateStatus::Draft)) + ->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + Http::assertNothingSent(); + } + } + + /** + * @scenario template_state=disabled, fallback_sms=off, sms_only=off, recipient=member + * + * @effects alimtalk_skipped_when_alimtalk_disabled, no_kapi_call_at_dispatch_time + */ + public function test_알림톡_사용이_꺼져_있으면_발송하지_않는다(): void + { + Bus::fake(); + Http::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($this->templateRow(alimtalkEnabled: false)) + ->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + Http::assertNothingSent(); + } + } + + /** + * @scenario template_state=disabled, fallback_sms=off, sms_only=off, recipient=member + * + * @effects alimtalk_skipped_when_row_inactive + */ + public function test_비활성_행이면_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - // binding = null → 미연결 → NotificationSendSkippedException(코어가 실패로 기록하도록) $this->expectException(NotificationSendSkippedException::class); try { - $this->makeDriver($this->fakeTemplate(), null)->send($member, $this->notification()); + $this->makeDriver($this->templateRow(isActive: false)) + ->send($member, $this->notification()); } finally { Bus::assertNotDispatched(SendMessageJob::class); - $this->assertDatabaseCount('bizppurio_dispatches', 0); } } + /** + * @scenario template_state=unapproved, fallback_sms=off, sms_only=off, recipient=member + * + * @effects alimtalk_skipped_when_snapshot_missing + */ + public function test_승인_스냅샷이_없으면_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $row = $this->templateRow(); + $row->approved_content = null; + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($row)->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } + } + + /** + * @scenario template_state=approved, fallback_sms=off, sms_only=on, recipient=member + * + * @effects alimtalk_skipped_when_sms_only_selected, no_kapi_call_at_dispatch_time + */ + public function test_sms_단독을_선택한_알림은_승인되어도_알림톡을_발송하지_않는다(): void + { + Bus::fake(); + Http::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $row = $this->templateRow(); + $row->sms_only = true; + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($row)->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + Http::assertNothingSent(); + } + } + + /** + * @effects skip_exception_uses_human_readable_type_label + */ public function test_스킵_예외_메시지는_알림_유형_코드값_대신_사람이_읽는_이름을_사용한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - $definition = Mockery::mock(\App\Models\NotificationDefinition::class); - $definition->shouldReceive('getLocalizedName')->andReturn('회원가입 환영'); + $definition = Mockery::mock(NotificationDefinition::class); + $definition->shouldReceive('getLocalizedName')->andReturn('주문 완료'); - $definitionService = Mockery::mock(\App\Services\NotificationDefinitionService::class); + $definitionService = Mockery::mock(NotificationDefinitionService::class); $definitionService->shouldReceive('resolve')->with('order_confirmed')->andReturn($definition); - $templateService = Mockery::mock(\App\Services\NotificationTemplateService::class); - $templateService->shouldReceive('resolve')->with(Mockery::any(), 'alimtalk')->andReturn(null); - - $pluginSettings = Mockery::mock(\App\Services\PluginSettingsService::class); + $pluginSettings = Mockery::mock(PluginSettingsService::class); $pluginSettings->shouldReceive('get')->andReturn(true); $driver = new AlimtalkChannelDriver( - $templateService, $definitionService, - $this->fakeBindings(null), + $this->fakeTemplates(null), $this->spyBuilder(), new BizppurioDispatchRepository, new DispatchLinkContext, - $this->fakeKakaoContent($this->kakaoContent()), new AlimtalkPayloadMapper, $pluginSettings, ); @@ -229,74 +301,53 @@ class AlimtalkChannelDriverTest extends PluginTestCase $driver->send($member, $this->notification()); $this->fail('NotificationSendSkippedException 가 발생해야 한다.'); } catch (NotificationSendSkippedException $e) { - $this->assertStringContainsString('회원가입 환영', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.'); + $this->assertStringContainsString('주문 완료', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.'); $this->assertStringNotContainsString('order_confirmed', $e->getMessage()); } } - public function test_카카오_템플릿_내용_조회_실패시_예외를_던지고_발송하지_않는다(): void + public function test_스냅샷_치환_결과_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - // binding 은 있으나 카카오 상세조회 실패(고아·장애) → 알림톡 본문 소스 없음 - // → NotificationSendSkippedException(코어가 실패로 기록하도록) $this->expectException(NotificationSendSkippedException::class); try { - $this->makeDriver($this->fakeTemplate(), $this->binding(), kakao: null) + $this->makeDriver($this->templateRow(approved: ['templateContent' => ' '])) ->send($member, $this->notification()); } finally { Bus::assertNotDispatched(SendMessageJob::class); } } - public function test_카카오_치환_결과_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void - { - Bus::fake(); - $member = User::factory()->create(['mobile' => '010-1234-5678']); - - // 카카오 templateContent 가 치환 후에도 빈 문자열이면 발송 불가 - $this->expectException(NotificationSendSkippedException::class); - - try { - $this->makeDriver( - $this->fakeTemplate(), - $this->binding(), - kakao: ['templateCode' => 'TW_1234', 'templateContent' => ' '], - )->send($member, $this->notification()); - } finally { - Bus::assertNotDispatched(SendMessageJob::class); - } - } - - public function test_코어_alimtalk_템플릿이_없어도_카카오_내용으로_발송한다(): void - { - Bus::fake(); - $member = User::factory()->create(['mobile' => '010-1234-5678']); - - // 알림톡 본문은 카카오에서 오므로, 코어 alimtalk 템플릿(SMS 대체용)이 없어도 발송돼야 한다. - // 대체발송 OFF 이면 코어 템플릿을 아예 조회하지 않는다. - $this->makeDriver(null, $this->binding(fallback: false))->send($member, $this->notification()); - - Bus::assertDispatched(SendMessageJob::class); - } - - public function test_회원은_mobile로_연결된_템플릿코드로_발송한다(): void + /** + * @scenario template_state=approved, fallback_sms=off, sms_only=off, recipient=member + * + * @effects dispatch_uses_row_template_code, no_kapi_call_at_dispatch_time + */ + public function test_회원은_mobile로_자체_채번_템플릿코드로_발송한다(): void { Bus::fake(); + Http::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); $builder = $this->spyBuilder(); - $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder) - ->send($member, $this->notification()); + $this->makeDriver($this->templateRow(), $builder)->send($member, $this->notification()); Bus::assertDispatched(SendMessageJob::class); $this->assertCount(1, $builder->calls); $this->assertSame('01012345678', $builder->calls[0]['to']); - $this->assertSame('TW_1234', $builder->calls[0]['templateCode'], '연결된 카카오 템플릿 코드로 발송해야 한다.'); + $this->assertSame('g7_abcd1234_1', $builder->calls[0]['templateCode'], '행의 template_code 로 발송해야 한다.'); + // 본문·버튼은 승인 스냅샷에서 온다 — 발송 시점에 카카오를 조회하지 않는다(§3.4). + Http::assertNothingSent(); } + /** + * @scenario template_state=approved, fallback_sms=off, sms_only=off, recipient=guest + * + * @effects guest_phone_resolved_from_data_recipient_phone + */ public function test_비회원은_data의_전화번호로_발송한다(): void { Bus::fake(); @@ -304,40 +355,43 @@ class AlimtalkChannelDriverTest extends PluginTestCase $data = ['name' => '홍길동', AlimtalkChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; $builder = $this->spyBuilder(); - $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder) - ->send($guest, $this->notification($data)); + $this->makeDriver($this->templateRow(), $builder)->send($guest, $this->notification($data)); Bus::assertDispatched(SendMessageJob::class); $this->assertSame('01099990000', $builder->calls[0]['to']); } - public function test_카카오_본문의_변수를_알림_data로_치환해_발송한다(): void + /** + * @effects snapshot_content_substituted_with_notification_data + */ + public function test_스냅샷_본문의_변수를_알림_data로_치환해_발송한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); $builder = $this->spyBuilder(); - // 알림톡 본문은 카카오 승인 템플릿(#{var})에서 오고, 발송 시 알림 data 로 치환된다. $this->makeDriver( - $this->fakeTemplate(), - $this->binding(), + $this->templateRow(approved: ['templateContent' => '#{name}님 #{order_number} 주문 완료']), $builder, - kakao: ['templateCode' => 'TW_1234', 'templateContent' => '#{name}님 #{order_number} 주문 완료'], )->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1'])); $this->assertSame( '김철수님 A1 주문 완료', $builder->calls[0]['message'], - '카카오 본문의 #{var} 를 알림 data 로 치환해야 한다.', + '승인 스냅샷의 #{var} 를 알림 data 로 치환해야 한다.', ); } - public function test_카카오_버튼을_발송_extra로_전달한다(): void + /** + * @effects snapshot_buttons_mapped_to_dispatch_button_fields + */ + public function test_스냅샷_버튼을_발송_extra로_전달한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); - // 버튼 URL 변수까지 치환돼 payload button 에 실려야 한다(회귀: 이전엔 버튼 자체가 누락). + // 등록 페이로드(buttons[].linkMo)와 발송 규격(button[].url_mobile)의 필드 대응이 + // 매퍼에서 유지돼야 한다 — 스냅샷은 kapi add 페이로드 형태 그대로다. $builder = new class extends MessagePayloadBuilder { /** @var array> */ @@ -354,16 +408,13 @@ class AlimtalkChannelDriverTest extends PluginTestCase }; $this->makeDriver( - $this->fakeTemplate(), - $this->binding(), - $builder, - kakao: [ - 'templateCode' => 'TW_1234', + $this->templateRow(approved: [ 'templateContent' => '#{name}님 주문 완료', 'buttons' => [ ['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/orders/#{order_number}'], ], - ], + ]), + $builder, )->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1'])); $button = $builder->extras[0]['button'][0]; @@ -371,13 +422,16 @@ class AlimtalkChannelDriverTest extends PluginTestCase $this->assertSame('https://m.shop/orders/A1', $button['url_mobile'], '버튼 URL 변수도 치환돼 발송돼야 한다.'); } - public function test_대체발송_o_n이면_payload에_sms_resend가_병합된다(): void + /** + * @scenario template_state=approved, fallback_sms=on, sms_only=off, recipient=member + * + * @effects fallback_on_merges_resend_recontent_from_sms_body + */ + public function test_대체발송_on이면_행의_sms_body가_resend로_병합된다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); - // 실제 payload 병합을 관찰하려면 실 빌더가 필요하므로, buildAlimtalk 만 최소 stub 하고 - // withSmsFallback 결과를 dispatch 된 Job payload 로 검증한다. $builder = new class extends MessagePayloadBuilder { public function __construct() {} @@ -388,29 +442,103 @@ class AlimtalkChannelDriverTest extends PluginTestCase } }; - // replaceVariables 는 치환 완료본을 반환하므로 mock body 도 치환 완료 텍스트로 준다. - // 드라이버는 이 body 를 알림톡 본문(#{var} 변환)과 대체 SMS 본문(원문 그대로) 두 곳에 쓴다. + // 대체 SMS 본문 소스는 코어 템플릿이 아니라 행의 sms_body(#{var} 치환)다 (#597 §3.5). $this->makeDriver( - $this->fakeTemplate(['subject' => '', 'body' => '김철수 님 주문 완료']), - $this->binding(fallback: true), + $this->templateRow(fallback: true, smsBody: '[샵] #{name} 님 주문 완료'), $builder, - )->send($member, $this->notification()); + )->send($member, $this->notification(['name' => '김철수'])); Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) { $this->assertSame(['first' => 'sms'], $job->payload['resend'] ?? null, '대체발송 ON 은 resend:{first:sms} 를 넣어야 한다.'); - $this->assertArrayHasKey('recontent', $job->payload); - $this->assertSame('김철수 님 주문 완료', $job->payload['recontent']['sms']['message'] ?? null, '대체 SMS 본문은 치환 완료된 코어 본문(#{var} 미변환)이어야 한다.'); + $this->assertSame('[샵] 김철수 님 주문 완료', $job->payload['recontent']['sms']['message'] ?? null, '대체 SMS 본문은 행의 sms_body 를 치환한 값이어야 한다.'); return true; }); } - public function test_대체발송_of_f이면_resend가_없다(): void + /** + * @scenario template_state=approved, fallback_sms=on, sms_only=off, recipient=guest + * + * @effects fallback_on_merges_resend_recontent_from_sms_body, guest_phone_resolved_from_data_recipient_phone + */ + public function test_비회원도_대체발송_on이면_같은_전화번호로_resend가_병합된다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + $data = ['name' => '홍길동', AlimtalkChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; + + // 대체 SMS 는 비즈뿌리오가 같은 수신번호로 재발송한다(resend 위임) — 비회원도 동일 경로다. + $this->makeDriver($this->templateRow(fallback: true, smsBody: '[샵] #{name} 님 주문 완료')) + ->send($guest, $this->notification($data)); + + Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) { + $this->assertSame(['first' => 'sms'], $job->payload['resend'] ?? null); + $this->assertSame('[샵] 홍길동 님 주문 완료', $job->payload['recontent']['sms']['message'] ?? null); + $this->assertSame('01099990000', $job->payload['to'] ?? null, '대체 SMS 도 알림톡과 같은 수신번호를 쓴다.'); + + return true; + }); + } + + /** + * 대체 SMS 도 수신자 로케일로 렌더된다 (#597 §14.3). + * + * 같은 알림의 SMS 단독 발송(SmsChannelDriver)과 대체발송이 서로 다른 언어로 나가면 + * 안 된다 — 두 경로가 같은 sms_body 맵을 같은 규칙으로 읽는지 고정한다. + * + * @scenario template_state=approved, fallback_sms=on, sms_only=off, recipient=guest + * + * @effects fallback_on_merges_resend_recontent_from_sms_body, sms_body_rendered_in_recipient_locale + */ + public function test_대체발송_본문도_수신자_로케일로_렌더된다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', 'John', 'en'); + $data = ['name' => 'John', AlimtalkChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; + + $this->makeDriver($this->templateRow(fallback: true, smsBody: [ + 'ko' => '[샵] #{name} 님 주문 완료', + 'en' => '[Shop] #{name}, your order is complete.', + ]))->send($guest, $this->notification($data)); + + Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) { + $this->assertSame( + '[Shop] John, your order is complete.', + $job->payload['recontent']['sms']['message'] ?? null, + '대체 SMS 본문은 수신자 로케일(en)로 렌더되어야 한다.' + ); + + return true; + }); + } + + /** + * @effects fallback_on_with_empty_sms_body_skips_merge + */ + public function test_대체발송_on이라도_sms_body가_비어있으면_resend를_병합하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); - $this->makeDriver($this->fakeTemplate(), $this->binding(fallback: false)) + $this->makeDriver($this->templateRow(fallback: true, smsBody: ' ')) + ->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) { + $this->assertArrayNotHasKey('resend', $job->payload, '빈 대체 본문으로 빈 SMS 를 보내면 안 된다.'); + + return true; + }); + } + + /** + * @effects fallback_off_has_no_resend + */ + public function test_대체발송_off이면_resend가_없다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + $this->makeDriver($this->templateRow(fallback: false, smsBody: '대체 본문')) ->send($member, $this->notification()); Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) { @@ -420,12 +548,15 @@ class AlimtalkChannelDriverTest extends PluginTestCase }); } + /** + * @effects pending_dispatch_row_created_with_channel_and_user + */ public function test_회원_발송_시_pending_이력을_alimtalk_채널로_생성한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678', 'name' => '김철수']); - $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, $this->notification()); + $this->makeDriver($this->templateRow())->send($member, $this->notification()); $this->assertDatabaseCount('bizppurio_dispatches', 1); $dispatch = BizppurioDispatch::first(); @@ -441,36 +572,11 @@ class AlimtalkChannelDriverTest extends PluginTestCase $member = User::factory()->create(['mobile' => '010-1234-5678']); $builder = $this->spyBuilder(); - $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder) - ->send($member, $this->notification()); + $this->makeDriver($this->templateRow(), $builder)->send($member, $this->notification()); $dispatch = BizppurioDispatch::first(); - $this->assertNotNull($dispatch->request_payload, '결함① — 실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.'); - $this->assertSame('TW_1234', $dispatch->request_payload['templatecode'] ?? null); - } - - public function test_이력_저장_payload에서_개인식별_정보는_제외된다(): void - { - Bus::fake(); - $member = User::factory()->create(['mobile' => '010-1234-5678']); - - // 실제 MessagePayloadBuilder(스파이 아님)로 진짜 조립 로직을 태워야 forHistory() 제외 - // 규칙(to/refkey/type/message 제거)을 검증할 수 있다. - $pluginSettings = Mockery::mock(PluginSettingsService::class); - $pluginSettings->shouldReceive('get')->andReturn(''); - $realBuilder = new MessagePayloadBuilder($pluginSettings); - - $this->makeDriver($this->fakeTemplate(), $this->binding(), $realBuilder) - ->send($member, $this->notification()); - - $dispatch = BizppurioDispatch::first(); - $payload = $dispatch->request_payload; - - $this->assertArrayNotHasKey('to', $payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.'); - $this->assertArrayNotHasKey('refkey', $payload, 'refkey 는 refkey 컬럼과 중복이라 제외돼야 한다.'); - $this->assertArrayNotHasKey('type', $payload, 'type 은 channel 컬럼과 중복이라 제외돼야 한다.'); - $this->assertArrayNotHasKey('message', $payload['content']['at'] ?? [], 'message 는 content 컬럼과 중복이라 제외돼야 한다.'); - $this->assertArrayHasKey('templatecode', $payload['content']['at'] ?? [], 'templatecode 는 다른 컬럼에 없으므로 남아있어야 한다.'); + $this->assertNotNull($dispatch->request_payload, '실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.'); + $this->assertSame('g7_abcd1234_1', $dispatch->request_payload['templatecode'] ?? null); } public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void @@ -481,8 +587,7 @@ class AlimtalkChannelDriverTest extends PluginTestCase $this->expectException(NotificationSendSkippedException::class); try { - $this->makeDriver($this->fakeTemplate(), $this->binding()) - ->send($guest, $this->notification(['name' => '홍길동'])); + $this->makeDriver($this->templateRow())->send($guest, $this->notification(['name' => '홍길동'])); } finally { Bus::assertNotDispatched(SendMessageJob::class); } @@ -493,31 +598,17 @@ class AlimtalkChannelDriverTest extends PluginTestCase Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); - $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, new Notification); + $this->makeDriver($this->templateRow())->send($member, new Notification); Bus::assertNotDispatched(SendMessageJob::class); } - public function test_정상_조건에서_발송하고_이력을_생성한다(): void - { - // 비활성 확장 채널의 발송 차단은 코어 via() 책임이며 GenericNotificationViaTest 가 검증한다. - // 이 드라이버 테스트는 정상 조건(활성 전제)에서의 발송·이력 생성만 검증한다. - Bus::fake(); - $member = User::factory()->create(['mobile' => '010-1234-5678']); - - $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, $this->notification()); - - Bus::assertDispatched(SendMessageJob::class); - $this->assertDatabaseCount('bizppurio_dispatches', 1); - } - public function test_검수_모드_설정값이_이력에_스냅샷으로_기록된다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - $this->makeDriver($this->fakeTemplate(), $this->binding(), isTestMode: true) - ->send($member, $this->notification()); + $this->makeDriver($this->templateRow(), isTestMode: true)->send($member, $this->notification()); $this->assertTrue(BizppurioDispatch::first()->is_test_mode); } @@ -527,8 +618,7 @@ class AlimtalkChannelDriverTest extends PluginTestCase Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - $this->makeDriver($this->fakeTemplate(), $this->binding(), isTestMode: false) - ->send($member, $this->notification()); + $this->makeDriver($this->templateRow(), isTestMode: false)->send($member, $this->notification()); $this->assertFalse(BizppurioDispatch::first()->is_test_mode); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php index 750f9c43..2b500a91 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php @@ -19,6 +19,9 @@ class AlimtalkPayloadMapperTest extends PluginTestCase return new AlimtalkPayloadMapper; } + /** + * @effects snapshot_content_substituted_with_notification_data + */ public function test_본문의_변수를_치환해_message로_반환한다(): void { $result = $this->mapper()->map( @@ -29,6 +32,9 @@ class AlimtalkPayloadMapperTest extends PluginTestCase $this->assertSame('홍길동님 주문 A123 완료', $result['message']); } + /** + * @effects snapshot_buttons_mapped_to_dispatch_button_fields + */ public function test_웹링크_버튼을_발송형식으로_변환하고_url변수를_치환한다(): void { $result = $this->mapper()->map( @@ -56,6 +62,9 @@ class AlimtalkPayloadMapperTest extends PluginTestCase $this->assertArrayNotHasKey('linkMo', $button); } + /** + * @effects snapshot_buttons_mapped_to_dispatch_button_fields + */ public function test_앱링크_전화_플러그인_버튼_필드를_매핑한다(): void { $result = $this->mapper()->map( @@ -272,4 +281,67 @@ class AlimtalkPayloadMapperTest extends PluginTestCase $this->assertArrayNotHasKey('button', $result['extra']); } + + public function test_substitute_text는_임의_텍스트의_변수를_치환한다(): void + { + // 대체 SMS·SMS 단독 본문(sms_body)이 이 공개 진입점으로 치환된다 (#597 §3.5). + $result = (new AlimtalkPayloadMapper)->substituteText( + '[샵] #{name}님 #{order_number} 주문', + ['name' => '김철수', 'order_number' => 'A1'], + ); + + $this->assertSame('[샵] 김철수님 A1 주문', $result); + } + + /** + * @effects snapshot_content_substituted_with_notification_data, snapshot_buttons_mapped_to_dispatch_button_fields + */ + public function test_등록_페이로드_형태의_승인_스냅샷을_그대로_소비한다(): void + { + // #597: 발송 소스가 kapi 상세 응답에서 등록 페이로드 스냅샷(approved_content)으로 + // 전환됐다. 두 형태는 필드명이 동일하다는 대응표를 여기서 고정한다: + // templateContent→message / buttons[].linkMo→button[].url_mobile / + // quickReplies→quickreply / templateTitle→title / templateHeader→header / + // templateItem→item / templateItemHighlight→itemhighlight / templateRepresentLink→link + $snapshot = [ + 'templateName' => '주문 완료', + 'templateMessageType' => 'MI', + 'templateEmphasizeType' => 'ITEM_LIST', + 'templateContent' => '#{name}님 주문 완료', + 'templateTitle' => '주문 #{order_number}', + 'templateHeader' => '헤더', + 'categoryCode' => '001001', + 'buttons' => [ + ['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/#{order_number}'], + ], + 'quickReplies' => [ + ['name' => '문의', 'linkType' => 'BK'], + ], + 'templateItem' => [ + 'list' => [ + ['title' => '품목', 'description' => '#{item_name}'], + ['title' => '수량', 'description' => '#{qty}'], + ], + 'summary' => ['title' => '합계', 'description' => '#{total}원'], + ], + 'templateItemHighlight' => ['title' => '#{name}님 주문', 'description' => '결제 완료'], + 'templateRepresentLink' => ['linkMo' => 'https://m.shop/orders'], + ]; + + $result = (new AlimtalkPayloadMapper)->map($snapshot, [ + 'name' => '김철수', 'order_number' => 'A1', 'item_name' => '연필', 'qty' => '2', 'total' => '1000', + ]); + + $this->assertSame('김철수님 주문 완료', $result['message']); + $this->assertSame('https://m.shop/A1', $result['extra']['button'][0]['url_mobile']); + $this->assertSame('BK', $result['extra']['quickreply'][0]['type']); + $this->assertSame('주문 A1', $result['extra']['title']); + $this->assertSame('헤더', $result['extra']['header']); + $this->assertSame('연필', $result['extra']['item']['list'][0]['description']); + $this->assertSame('1000원', $result['extra']['item']['summary']['description']); + $this->assertSame('김철수님 주문', $result['extra']['itemhighlight']['title']); + $this->assertSame('https://m.shop/orders', $result['extra']['link']['url_mobile']); + // 등록 전용 메타(templateName 등)는 발송 extra 에 실리지 않는다. + $this->assertArrayNotHasKey('templateName', $result['extra']); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php index 4c2093de..45174ef0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php @@ -11,96 +11,76 @@ use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioKakaoApiClient; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** - * AlimtalkTemplateService — kapi 조회 위임 + 상태 배지 매핑 검증 (조회 전용). + * AlimtalkTemplateService — 작성 모달 참조 조회(카테고리·발신프로필) 위임 검증 (#597). + * + * 템플릿 목록/상세의 실시간 조회(구 Phase 5)는 DB 기반 라이프사이클 + * (BizppurioTemplateService)로 대체되어 제거됐다 — 이 서비스는 카테고리와 + * 발신프로필 조회만 남는다. */ class AlimtalkTemplateServiceTest extends PluginTestCase { private const IDENTIFIER = 'sirsoft-message_bizppurio'; /** - * @param array $settings + * kapi 자격증명이 준비된 서비스 인스턴스를 생성한다. */ - private function service(array $settings = ['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40']): AlimtalkTemplateService + private function service(): AlimtalkTemplateService { $pluginSettings = Mockery::mock(PluginSettingsService::class); - $pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); + $pluginSettings->shouldReceive('get')->with(self::IDENTIFIER) + ->andReturn(['bizppurio_id' => 'biz01', 'api_key' => 'key01']); - $kakao = new BizppurioKakaoApiClient($pluginSettings); - - return new AlimtalkTemplateService($kakao, $pluginSettings); + return new AlimtalkTemplateService(new BizppurioKakaoApiClient($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']); - // 회귀 방지: 배지 label_key 는 프론트 lang 키 형식(templates.status.*)이어야 한다. - // 백엔드 messages.php 네임스페이스(::messages.template.status.*)로 주면 프론트 $t() 가 - // 해석하지 못해 라벨 원문이 목록/상세에 그대로 노출된다(PO 브라우저 검수로 발견된 회귀). - $this->assertSame( - 'sirsoft-message_bizppurio.templates.status.sendable', - $tpl['status_badge']['label_key'], - ); - // 조회 전용 — 상태별 가능 액션(available_actions)은 더 이상 부가하지 않는다. - $this->assertArrayNotHasKey('available_actions', $tpl); - $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 + /** + * 카테고리 전체 조회는 data 배열을 그대로 반환한다. + */ + public function test_카테고리는_data_배열을_반환한다(): void { Http::fake([ 'kapi.ppurio.com/*' => Http::response([ 'code' => '200', 'data' => [ - 'templateCode' => 'TW_2', - 'inspectionStatus' => 'APR', - 'status' => 'A', - 'block' => false, - 'dormant' => false, + ['code' => '001001', 'name' => '회원가입', 'groupName' => '회원'], + ['code' => '002001', 'name' => '구매완료', 'groupName' => '구매'], ], ], 200), ]); - $detail = $this->service()->detail('TW_2'); + $categories = $this->service()->categories(); - // inspection=APR + status=A → ACT(정상) - $this->assertSame('ACT', $detail['service_status']); - $this->assertSame('green', $detail['status_badge']['variant']); + $this->assertCount(2, $categories); + $this->assertSame('001001', $categories[0]['code']); + $this->assertSame('구매', $categories[1]['groupName']); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/category/all') + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'); } - public function test_발신프로필은_data_success_배열을_반환한다(): void + /** + * 카테고리 조회가 실패 코드로 응답하면 message 원문·resultCode 를 보존한 예외를 던진다. + */ + public function test_카테고리_실패코드시_message와_결과코드를_예외에_보존한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '405', 'message' => '지원하지 않는 기능입니다.'], 200), + ]); + + try { + $this->service()->categories(); + $this->fail('BizppurioApiException 이 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame('지원하지 않는 기능입니다.', $e->getMessage()); + $this->assertSame('405', $e->getResultCode()); + } + } + + /** + * 발신프로필은 data.success 배열만 반환한다 (success/fail 껍데기 미노출). + */ + public function test_발신프로필은_data_success_배열만_반환한다(): void { // 규격(5.발신프로필관리): /v3/kakao/profile/use 응답 data 는 {success:[...], fail:[...]} // 2단 봉투다. 실제 발신프로필 목록은 data.success 안에 있으므로 그 배열을 반환해야 한다. @@ -122,49 +102,28 @@ class AlimtalkTemplateServiceTest extends PluginTestCase $this->assertCount(1, $profiles); $this->assertSame('SK_40', $profiles[0]['senderKey']); $this->assertSame('테스트채널', $profiles[0]['name']); - // 회귀 방지: data 통째 반환 시 노출되던 success/fail 키가 없어야 한다. $this->assertArrayNotHasKey('success', $profiles); $this->assertArrayNotHasKey('fail', $profiles); } - public function test_발신프로필_키_미설정시_예외(): void - { - $this->expectException(BizppurioApiException::class); - - $this->service(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => '']) - ->list(); - } - - public function test_kapi_실패코드시_예외에_결과코드가_담긴다(): void + /** + * 발신프로필 조회가 실패 코드로 응답하면 예외를 던진다. + */ + public function test_발신프로필_실패코드시_예외(): void { Http::fake([ - 'kapi.ppurio.com/*' => Http::response(['code' => '7204', 'message' => '템플릿 불일치'], 200), + 'kapi.ppurio.com/*' => Http::response(['code' => '7204', 'message' => '발신프로필을 찾을 수 없습니다.'], 200), ]); try { - $this->service()->list(); - $this->fail('예외가 발생해야 한다.'); + $this->service()->senderProfiles(); + $this->fail('BizppurioApiException 이 발생해야 한다.'); } catch (BizppurioApiException $e) { + $this->assertSame('발신프로필을 찾을 수 없습니다.', $e->getMessage()); $this->assertSame('7204', $e->getResultCode()); - $this->assertStringContainsString('템플릿 불일치', $e->getMessage()); } } - public function test_목록조회는_kapi_508을_빈_목록으로_처리한다(): void - { - // 카카오 결과코드 508 = "요청한 데이터가 없음"(13.응답코드정의.md). 목록 검색에서 - // 매칭 결과가 0건일 때 카카오가 이 코드로 응답하므로, 진짜 에러가 아니라 빈 목록으로 - // 취급해야 한다(PO 실측: "댓글"은 200 정상 필터링, "대글"처럼 매칭 없는 키워드만 508). - Http::fake([ - 'kapi.ppurio.com/*' => Http::response(['code' => '508', 'message' => '요청한 데이타가 없습니다.'], 200), - ]); - - $result = $this->service()->list(['keyword' => '대글']); - - $this->assertSame([], $result['templates']); - $this->assertSame(0, $result['pagination']['total']); - } - protected function tearDown(): void { Mockery::close(); 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 6579b1ab..440e5b75 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php @@ -143,6 +143,193 @@ class BizppurioKakaoApiClientTest extends PluginTestCase } } + public function test_템플릿_등록은_add_경로로_pos_t한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->client()->addTemplate([ + 'senderKey' => 'SK', + 'templateCode' => 'TW_1', + 'templateName' => '주문완료', + ]); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/add') + && $request['senderKey'] === 'SK' + && $request['templateCode'] === 'TW_1' + && $request['templateName'] === '주문완료' + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_템플릿_수정은_update_경로로_pos_t한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->client()->updateTemplate([ + 'senderKey' => 'SK', + 'templateCode' => 'TW_1', + 'newTemplateCode' => 'TW_2', + ]); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/update') + && $request['newTemplateCode'] === 'TW_2' + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_템플릿_삭제는_delete_경로로_pos_t한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->client()->deleteTemplate('SK', 'TW_1'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/delete') + && $request['senderKey'] === 'SK' + && $request['templateCode'] === 'TW_1' + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_템플릿_코드_중복검증은_code_check_경로로_pos_t한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->client()->checkTemplateCode('SK', 'TW_1'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/codeCheck') + && $request['senderKey'] === 'SK' + && $request['templateCode'] === 'TW_1' + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_검수요청은_comment를_포함해_request_경로로_pos_t한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->client()->requestInspection('SK', 'TW_1', '변수는 주문번호입니다.'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/request') + && $request['senderKey'] === 'SK' + && $request['templateCode'] === 'TW_1' + && $request['comment'] === '변수는 주문번호입니다.' + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_검수요청_comment_미전달시_comment_키를_싣지_않는다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->client()->requestInspection('SK', 'TW_1'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/request') + && $request['senderKey'] === 'SK' + && ! array_key_exists('comment', $request->data()) + && $request['bizId'] === 'biz01'; + }); + } + + public function test_검수취소는_cancel_request_경로로_pos_t한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->client()->cancelInspection('SK', 'TW_1'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/cancel_request') + && $request['senderKey'] === 'SK' + && $request['templateCode'] === 'TW_1' + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_승인취소는_cancel_approval_경로로_pos_t한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->client()->cancelApproval('SK', 'TW_1'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/cancel_approval') + && $request['senderKey'] === 'SK' + && $request['templateCode'] === 'TW_1' + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_휴면해제는_release_경로로_pos_t한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->client()->releaseDormant('SK', 'TW_1'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/release') + && $request['senderKey'] === 'SK' + && $request['templateCode'] === 'TW_1' + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_이미지_업로드는_multipart로_전송하고_최상위_image_필드를_반환한다(): void + { + // kapi 유일의 비-JSON(multipart) 엔드포인트 — 응답도 data 봉투가 아니라 + // 최상위 image 필드에 업로드 URL 이 실린다(부록 A-7). + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'message' => 'success', + 'image' => 'https://mud-kage.kakao.com/dn/example.png', + ], 200), + ]); + + $tmpPath = tempnam(sys_get_temp_dir(), 'biz_img_'); + file_put_contents($tmpPath, 'fake-png-bytes'); + + try { + $result = $this->client()->uploadTemplateImage($tmpPath, 'template.png'); + } finally { + @unlink($tmpPath); + } + + $this->assertSame('https://mud-kage.kakao.com/dn/example.png', $result['image']); + $this->assertArrayNotHasKey('data', $result); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/v3/kakao/image/alimtalk/template')) { + return false; + } + + if (! $request->isMultipart()) { + return false; + } + + // multipart 파트에 image 파일 + bizId/apiKey 필드가 실려야 한다. + // 파일 contents 는 스트림 리소스로 실리므로(전송 후 close) 값 비교가 불가 — + // 파트명 + 원본 파일명으로 단언한다. + $parts = collect($request->data()); + + return $request->hasFile('image', null, 'template.png') + && $parts->contains(fn ($part) => ($part['name'] ?? null) === 'bizId' && ($part['contents'] ?? null) === 'biz01') + && $parts->contains(fn ($part) => ($part['name'] ?? null) === 'apiKey' && ($part['contents'] ?? null) === 'key01'); + }); + } + protected function tearDown(): void { Mockery::close(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php new file mode 100644 index 00000000..c635a05e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php @@ -0,0 +1,736 @@ + $extra data 등 추가 필드 + */ + private function kapiResponse(string $code = '200', array $extra = []): array + { + return array_merge(['code' => $code, 'message' => $code === '200' ? 'success' : 'kapi failure '.$code], $extra); + } + + /** + * 서비스를 조립합니다 (실 저장소 + 실 클라이언트 + mock 설정). + */ + private function makeService(string $senderKey = 'SK_TEST'): BizppurioTemplateService + { + $settings = Mockery::mock(PluginSettingsService::class); + $settings->shouldReceive('get') + ->with('sirsoft-message_bizppurio') + ->andReturn(['bizppurio_id' => 'biz1', 'api_key' => 'key1']); + $settings->shouldReceive('get') + ->with('sirsoft-message_bizppurio', 'sender_key', '') + ->andReturn($senderKey); + + return new BizppurioTemplateService( + new BizppurioTemplateRepository, + new BizppurioKakaoApiClient($settings), + $settings, + ); + } + + /** + * 템플릿 행을 DB 에 만듭니다. + * + * @param array $attrs 오버라이드 + */ + private function row(array $attrs = []): BizppurioTemplate + { + return BizppurioTemplate::create(array_merge([ + 'notification_type' => 'welcome', + 'status' => BizppurioTemplateStatus::Draft->value, + 'content' => ['templateName' => '환영', 'templateMessageType' => 'BA', 'templateEmphasizeType' => 'NONE', 'templateContent' => '#{name}님 환영합니다', 'categoryCode' => '001001'], + ], $attrs)); + } + + public function test_create는_draft_상태로_생성한다(): void + { + $template = $this->makeService()->create([ + 'notification_type' => 'welcome', + 'content' => ['templateName' => '환영', 'templateContent' => '본문'], + ]); + + $this->assertSame(BizppurioTemplateStatus::Draft, $template->status); + $this->assertDatabaseHas('bizppurio_templates', ['notification_type' => 'welcome']); + } + + public function test_draft에서_content를_수정할_수_있다(): void + { + $template = $this->row(); + + $updated = $this->makeService()->update($template, [ + 'content' => ['templateName' => '환영2', 'templateContent' => '새 본문'], + ]); + + $this->assertSame('환영2', $updated->content['templateName']); + } + + /** + * @effects content_edit_locked_outside_draft_rejected + */ + public function test_requested_상태에서_content_변경은_거부된다(): void + { + $template = $this->row(['status' => BizppurioTemplateStatus::Requested->value]); + + $this->expectException(BizppurioTemplateStateException::class); + + $this->makeService()->update($template, [ + 'content' => ['templateName' => '변경', 'templateContent' => '변경 본문'], + ]); + } + + /** + * @effects sms_delivery_fields_editable_in_any_status + */ + public function test_requested_상태라도_sms_설정은_수정할_수_있다(): void + { + $template = $this->row(['status' => BizppurioTemplateStatus::Requested->value]); + + $updated = $this->makeService()->update($template, [ + 'fallback_sms_enabled' => true, + 'sms_body' => ['ko' => '[샵] #{name} 님 환영'], + ]); + + $this->assertTrue($updated->fallback_sms_enabled); + $this->assertSame(['ko' => '[샵] #{name} 님 환영'], $updated->sms_body); + } + + /** + * @effects content_edit_locked_outside_draft_rejected + */ + public function test_requested_상태에서는_동일_content_전달도_거부된다(): void + { + $template = $this->row(['status' => BizppurioTemplateStatus::Requested->value]); + + // content 키가 실리면 값의 동일 여부와 무관하게 잠긴다 (#597 §3.2). + // "값이 달라질 때만 막는다" 완화는 라운드 3 에서 철회했다 — DB 가 JSON 키 순서를 + // 정규화해 저장하므로 동일성 비교가 성립하지 않았고(항상 '변경됨'), 화면도 검수중· + // 승인 상태에서는 수정 진입 자체를 막고 있어 완화가 보호하는 경로가 없었다. + $this->expectException(BizppurioTemplateStateException::class); + + $this->makeService()->update($template, [ + 'content' => $template->content, + 'sms_only' => true, + ]); + } + + /** + * @effects sms_delivery_fields_editable_in_any_status + */ + public function test_approved_상태에서도_content_키가_없으면_수정된다(): void + { + $template = $this->row(['status' => BizppurioTemplateStatus::Approved->value]); + + // 발송 설정만 보내는 경로(행 토글·SMS 모달)는 content 키를 싣지 않으므로 잠금 대상이 아니다. + $updated = $this->makeService()->update($template, ['sms_only' => true]); + + $this->assertTrue($updated->sms_only); + } + + /** + * @effects content_edit_locked_outside_draft_rejected + */ + public function test_approved_상태에서_content_변경은_거부된다(): void + { + $template = $this->row(['status' => BizppurioTemplateStatus::Approved->value]); + + $this->expectException(BizppurioTemplateStateException::class); + + $this->makeService()->update($template, [ + 'content' => ['templateName' => '변경', 'templateContent' => '변경 본문'], + ]); + } + + public function test_upsert_delivery는_행이_없으면_draft로_생성한다(): void + { + $template = $this->makeService()->upsertDelivery('welcome', ['sms_only' => true, 'sms_body' => ['ko' => '본문']]); + + $this->assertSame(BizppurioTemplateStatus::Draft, $template->status); + $this->assertTrue($template->sms_only); + $this->assertNull($template->content); + } + + public function test_upsert_delivery는_기존_행의_발송_설정만_갱신한다(): void + { + $existing = $this->row(['status' => BizppurioTemplateStatus::Approved->value]); + + $template = $this->makeService()->upsertDelivery('welcome', ['fallback_sms_enabled' => true]); + + $this->assertSame($existing->id, $template->id); + $this->assertTrue($template->fallback_sms_enabled); + $this->assertSame(BizppurioTemplateStatus::Approved, $template->status, '발송 설정 upsert 는 검수 상태를 건드리지 않는다.'); + } + + /** + * @scenario content_variant=ba_none, transition=draft_to_requested + * + * @effects request_generates_code_with_codecheck_retry_up_to_three_generations, first_request_calls_add_then_request_with_full_content_payload, request_snapshots_sender_key + */ + public function test_최초_검수_신청은_채번_add_request_순으로_호출하고_requested가_된다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + $template = $this->row(); + + $updated = $this->makeService()->requestInspection($template); + + $this->assertSame(BizppurioTemplateStatus::Requested, $updated->status); + $this->assertSame('SK_TEST', $updated->sender_key, '신청 당시 발신프로필을 스냅샷한다.'); + $this->assertNotNull($updated->requested_at); + $this->assertMatchesRegularExpression('/^g7_[0-9a-f]{8}_1$/', (string) $updated->template_code, '자체 채번 형식(g7_{md5 8자}_{세대}).'); + + // 호출 순서: codeCheck → add → request (총 3회) + $paths = []; + Http::assertSentCount(3); + Http::assertSent(function ($request) use (&$paths) { + $paths[] = parse_url($request->url(), PHP_URL_PATH); + + return true; + }); + $this->assertSame([ + '/v3/kakao/template/codeCheck', + '/v3/kakao/template/add', + '/v3/kakao/template/request', + ], $paths); + } + + public function test_검수_신청의_add_요청_본문은_content와_채번_코드를_싣는다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + $template = $this->row(); + + $updated = $this->makeService()->requestInspection($template); + + Http::assertSent(function ($request) use ($updated) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/add') { + return false; + } + + $body = $request->data(); + $this->assertSame('biz1', $body['bizId'] ?? null, '클라이언트가 bizId 를 주입해야 한다.'); + $this->assertSame('key1', $body['apiKey'] ?? null); + $this->assertSame('SK_TEST', $body['senderKey'] ?? null); + $this->assertSame($updated->template_code, $body['templateCode'] ?? null); + $this->assertSame('환영', $body['templateName'] ?? null, 'content 필드가 등록 페이로드로 그대로 실려야 한다.'); + $this->assertSame('#{name}님 환영합니다', $body['templateContent'] ?? null); + + return true; + }); + } + + public function test_code_check_충돌시_세대를_올려_재시도한다(): void + { + Http::fake([ + '*codeCheck*' => Http::sequence() + ->push($this->kapiResponse('504')) + ->push($this->kapiResponse('200')), + self::KAPI => Http::response($this->kapiResponse()), + ]); + $template = $this->row(); + + $updated = $this->makeService()->requestInspection($template); + + $this->assertStringEndsWith('_2', (string) $updated->template_code, '충돌 시 세대 2 로 재시도해야 한다.'); + } + + public function test_code_check가_전부_충돌하면_예외를_던지고_코드를_확정하지_않는다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse('504'))]); + $template = $this->row(); + + try { + $this->makeService()->requestInspection($template); + $this->fail('BizppurioApiException 이 발생해야 한다.'); + } catch (BizppurioApiException) { + $this->assertNull($template->fresh()->template_code, '재시도 소진 시 코드가 확정되면 안 된다.'); + $this->assertSame(BizppurioTemplateStatus::Draft, $template->fresh()->status); + } + } + + /** + * @effects rerequest_calls_update_not_add + */ + public function test_재신청은_add가_아니라_update를_호출한다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + $template = $this->row([ + 'status' => BizppurioTemplateStatus::Rejected->value, + 'template_code' => 'g7_deadbeef_1', + 'sender_key' => 'SK_SNAP', + ]); + + $this->makeService()->requestInspection($template); + + // 재신청: update → request (codeCheck·add 없음) + $paths = []; + Http::assertSent(function ($request) use (&$paths) { + $paths[] = parse_url($request->url(), PHP_URL_PATH); + + return true; + }); + $this->assertSame(['/v3/kakao/template/update', '/v3/kakao/template/request'], $paths); + } + + public function test_content_없이_검수_신청하면_거부된다(): void + { + Http::fake(); + $template = $this->row(['content' => null]); + + $this->expectException(BizppurioTemplateStateException::class); + + try { + $this->makeService()->requestInspection($template); + } finally { + Http::assertNothingSent(); + } + } + + public function test_approved_상태에서_검수_신청은_거부된다(): void + { + Http::fake(); + $template = $this->row(['status' => BizppurioTemplateStatus::Approved->value]); + + $this->expectException(BizppurioTemplateStateException::class); + + try { + $this->makeService()->requestInspection($template); + } finally { + Http::assertNothingSent(); + } + } + + /** + * @effects concurrent_duplicate_request_claims_single_submission + */ + public function test_동시_중복_신청은_한_건만_kapi에_제출된다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + $template = $this->row(); + // 경합 재현: 같은 draft 상태를 보는 두 번째 모델 핸들 — 더블 클릭·이중 탭에서 + // 각 요청의 route model binding 이 서로의 커밋을 모른 채 같은 행을 든 상황. + $stale = BizppurioTemplate::query()->findOrFail($template->id); + + $this->makeService()->requestInspection($template); + + try { + $this->makeService()->requestInspection($stale); + $this->fail('BizppurioTemplateStateException 이 발생해야 한다.'); + } catch (BizppurioTemplateStateException) { + // 첫 신청의 codeCheck·add·request 3회뿐 — 두 번째 신청은 kapi 에 도달하지 않는다. + Http::assertSentCount(3); + $this->assertSame(BizppurioTemplateStatus::Requested, $template->fresh()->status); + } + } + + public function test_kapi_add_실패시_사유_원문을_보존하고_상태를_바꾸지_않는다(): void + { + Http::fake([ + '*codeCheck*' => Http::response($this->kapiResponse()), + '*template/add*' => Http::response(['code' => '507', 'message' => '유효하지 않은 발신프로필']), + ]); + $template = $this->row(); + + try { + $this->makeService()->requestInspection($template); + $this->fail('BizppurioApiException 이 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame('유효하지 않은 발신프로필', $e->getMessage(), 'kapi 사유 원문이 보존돼야 한다.'); + $this->assertSame('507', $e->getResultCode()); + $this->assertSame(BizppurioTemplateStatus::Draft, $template->fresh()->status); + } + } + + /** + * @scenario content_variant=ba_none, transition=requested_cancel + * + * @effects cancel_request_returns_to_draft + */ + public function test_검수_신청_취소는_draft로_복귀한다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + $template = $this->row([ + 'status' => BizppurioTemplateStatus::Requested->value, + 'template_code' => 'g7_deadbeef_1', + 'sender_key' => 'SK_SNAP', + ]); + + $updated = $this->makeService()->cancelRequest($template); + + $this->assertSame(BizppurioTemplateStatus::Draft, $updated->status); + Http::assertSent(fn ($request) => parse_url($request->url(), PHP_URL_PATH) === '/v3/kakao/template/cancel_request' + && ($request->data()['senderKey'] ?? null) === 'SK_SNAP'); + } + + public function test_검수중이_아니면_신청_취소가_거부된다(): void + { + Http::fake(); + $template = $this->row(); + + $this->expectException(BizppurioTemplateStateException::class); + + $this->makeService()->cancelRequest($template); + } + + /** + * @scenario content_variant=ba_none, transition=approved_cancel + * + * @effects cancel_approval_returns_to_draft_keeping_snapshot, cancel_approval_immediately_blocks_dispatch_gate + */ + public function test_승인_취소는_draft로_복귀하되_승인_스냅샷을_유지한다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + $approved = ['templateContent' => '승인된 본문']; + $template = $this->row([ + 'status' => BizppurioTemplateStatus::Approved->value, + 'template_code' => 'g7_deadbeef_1', + 'approved_content' => $approved, + 'approved_at' => now(), + ]); + + $updated = $this->makeService()->cancelApproval($template); + + $this->assertSame(BizppurioTemplateStatus::Draft, $updated->status); + $this->assertSame($approved, $updated->approved_content, '승인 스냅샷은 유지된다 — 발송 차단은 status 게이트가 담당.'); + $this->assertFalse($updated->isAlimtalkSendable(), '스냅샷이 남아도 status 를 잃는 순간 발송 게이트가 차단돼야 한다.'); + } + + public function test_승인_상태가_아니면_승인_취소가_거부된다(): void + { + Http::fake(); + $template = $this->row(); + + $this->expectException(BizppurioTemplateStateException::class); + + $this->makeService()->cancelApproval($template); + } + + /** + * @scenario content_variant=ba_none, transition=requested_to_approved + * + * @effects approval_transition_freezes_content_into_approved_content, manual_sync_updates_single_row + */ + public function test_sync는_승인_전이시_content를_승인_스냅샷으로_동결한다(): void + { + Http::fake([ + '*template/detail*' => Http::response($this->kapiResponse('200', [ + 'data' => ['templateCode' => 'g7_deadbeef_1', 'serviceStatus' => 'RDY'], + ])), + ]); + $template = $this->row([ + 'status' => BizppurioTemplateStatus::Requested->value, + 'template_code' => 'g7_deadbeef_1', + 'sender_key' => 'SK_SNAP', + ]); + + $updated = $this->makeService()->sync($template); + + $this->assertSame(BizppurioTemplateStatus::Approved, $updated->status); + $this->assertSame($template->content, $updated->approved_content, '승인 전이 시 content 가 발송 SSoT 로 동결돼야 한다.'); + $this->assertNotNull($updated->approved_at); + $this->assertNotNull($updated->last_synced_at); + } + + public function test_sync는_이미_승인이면_스냅샷을_다시_덮지_않는다(): void + { + Http::fake([ + '*template/detail*' => Http::response($this->kapiResponse('200', [ + 'data' => ['templateCode' => 'g7_deadbeef_1', 'serviceStatus' => 'ACT'], + ])), + ]); + $frozen = ['templateContent' => '승인 당시 본문']; + $template = $this->row([ + 'status' => BizppurioTemplateStatus::Approved->value, + 'template_code' => 'g7_deadbeef_1', + 'approved_content' => $frozen, + 'content' => ['templateContent' => '이후 수정한 본문(미승인)'], + ]); + + $updated = $this->makeService()->sync($template); + + $this->assertSame($frozen, $updated->approved_content, '승인 유지 상태에서 스냅샷이 현재 content 로 덮이면 안 된다.'); + } + + /** + * @scenario content_variant=ba_none, transition=requested_to_rejected + * + * @effects rejection_transition_stores_comments_detail + */ + public function test_sync는_반려_전이시_comments를_저장한다(): void + { + $comments = [['status' => 'REJ', 'content' => '심사 기준 미달', 'createdAt' => '2026-08-19 10:00:00']]; + Http::fake([ + '*template/detail*' => Http::response($this->kapiResponse('200', [ + 'data' => ['templateCode' => 'g7_deadbeef_1', 'serviceStatus' => 'REJ', 'comments' => $comments], + ])), + ]); + $template = $this->row([ + 'status' => BizppurioTemplateStatus::Requested->value, + 'template_code' => 'g7_deadbeef_1', + ]); + + $updated = $this->makeService()->sync($template); + + $this->assertSame(BizppurioTemplateStatus::Rejected, $updated->status); + $this->assertSame($comments, $updated->inspection_detail, '반려 사유 원문(comments)이 저장돼야 한다.'); + } + + public function test_sync는_코드가_없는_행을_kapi_호출_없이_그대로_반환한다(): void + { + Http::fake(); + $template = $this->row(); + + $result = $this->makeService()->sync($template); + + $this->assertSame($template->id, $result->id); + Http::assertNothingSent(); + } + + public function test_sync_requested는_대상이_없으면_kapi를_호출하지_않는다(): void + { + Http::fake(); + $this->row(); // draft 행만 존재 + + $result = $this->makeService()->syncRequested(); + + $this->assertSame(['checked' => 0, 'transitioned' => 0], $result); + Http::assertNothingSent(); + } + + public function test_sync_requested는_list_일괄_대조로_전이한다(): void + { + Http::fake([ + '*template/list*' => Http::response($this->kapiResponse('200', [ + 'data' => [ + 'totalPage' => 1, + 'list' => [ + ['templateCode' => 'g7_aaaa1111_1', 'serviceStatus' => 'ACT'], + ['templateCode' => 'g7_bbbb2222_1', 'serviceStatus' => 'REQ'], + ], + ], + ])), + ]); + $a = $this->row([ + 'notification_type' => 'welcome', + 'status' => BizppurioTemplateStatus::Requested->value, + 'template_code' => 'g7_aaaa1111_1', + 'sender_key' => 'SK_SNAP', + ]); + $b = $this->row([ + 'notification_type' => 'order_confirmed', + 'status' => BizppurioTemplateStatus::Requested->value, + 'template_code' => 'g7_bbbb2222_1', + 'sender_key' => 'SK_SNAP', + ]); + + $result = $this->makeService()->syncRequested(); + + $this->assertSame(2, $result['checked']); + $this->assertSame(1, $result['transitioned']); + $this->assertSame(BizppurioTemplateStatus::Approved, $a->fresh()->status); + $this->assertSame(BizppurioTemplateStatus::Requested, $b->fresh()->status, '여전히 검수중이면 상태 유지.'); + // 행별 detail 호출 금지 — list 1회만 (레이트리밋 보호) + Http::assertSentCount(1); + } + + public function test_sync_requested는_반려_전이_행만_detail을_추가_호출해_사유를_확보한다(): void + { + $comments = [['status' => 'REJ', 'content' => '변수 규격 위반']]; + Http::fake([ + '*template/list*' => Http::response($this->kapiResponse('200', [ + 'data' => ['totalPage' => 1, 'list' => [['templateCode' => 'g7_aaaa1111_1', 'serviceStatus' => 'REJ']]], + ])), + '*template/detail*' => Http::response($this->kapiResponse('200', [ + 'data' => ['templateCode' => 'g7_aaaa1111_1', 'serviceStatus' => 'REJ', 'comments' => $comments], + ])), + ]); + $row = $this->row([ + 'status' => BizppurioTemplateStatus::Requested->value, + 'template_code' => 'g7_aaaa1111_1', + 'sender_key' => 'SK_SNAP', + ]); + + $this->makeService()->syncRequested(); + + $fresh = $row->fresh(); + $this->assertSame(BizppurioTemplateStatus::Rejected, $fresh->status); + $this->assertSame($comments, $fresh->inspection_detail); + Http::assertSentCount(2); // list 1 + detail 1 + } + + /** + * @effects delete_removes_kakao_side_only_in_deletable_state + */ + public function test_delete는_삭제_가능_상태면_카카오측도_함께_지운다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + $template = $this->row([ + 'template_code' => 'g7_deadbeef_1', + 'sender_key' => 'SK_SNAP', + ]); + + $result = $this->makeService()->delete($template); + + $this->assertTrue($result['kakao_deleted']); + $this->assertNull($result['kakao_skip_reason']); + $this->assertDatabaseCount('bizppurio_templates', 0); + Http::assertSent(fn ($request) => parse_url($request->url(), PHP_URL_PATH) === '/v3/kakao/template/delete'); + } + + public function test_delete는_삭제_불가_상태면_db행만_지우고_사유를_명시한다(): void + { + Http::fake(); + $template = $this->row([ + 'status' => BizppurioTemplateStatus::Approved->value, + 'template_code' => 'g7_deadbeef_1', + ]); + + $result = $this->makeService()->delete($template); + + $this->assertFalse($result['kakao_deleted']); + $this->assertSame('state_not_deletable', $result['kakao_skip_reason']); + $this->assertDatabaseCount('bizppurio_templates', 0); + Http::assertNothingSent(); + } + + public function test_delete는_카카오측_실패에도_db행을_지운다(): void + { + Http::fake([self::KAPI => Http::response(['code' => '509', 'message' => '처리 불가 상태'])]); + $template = $this->row(['template_code' => 'g7_deadbeef_1', 'sender_key' => 'SK_SNAP']); + + $result = $this->makeService()->delete($template); + + $this->assertFalse($result['kakao_deleted']); + $this->assertSame('처리 불가 상태', $result['kakao_skip_reason']); + $this->assertDatabaseCount('bizppurio_templates', 0); + } + + public function test_카카오_미등록_행_delete는_kapi를_호출하지_않는다(): void + { + Http::fake(); + $template = $this->row(); + + $result = $this->makeService()->delete($template); + + $this->assertSame('not_registered', $result['kakao_skip_reason']); + Http::assertNothingSent(); + } + + public function test_휴면_해제는_release_후_상태를_재동기화한다(): void + { + Http::fake([ + '*template/release*' => Http::response($this->kapiResponse()), + '*template/detail*' => Http::response($this->kapiResponse('200', [ + 'data' => ['templateCode' => 'g7_deadbeef_1', 'serviceStatus' => 'ACT'], + ])), + ]); + $template = $this->row([ + 'status' => BizppurioTemplateStatus::Dormant->value, + 'template_code' => 'g7_deadbeef_1', + 'sender_key' => 'SK_SNAP', + 'approved_content' => ['templateContent' => '본문'], + ]); + + $updated = $this->makeService()->releaseDormant($template); + + $this->assertSame(BizppurioTemplateStatus::Approved, $updated->status, '해제 후 카카오 실제 상태(ACT)로 재동기화돼야 한다.'); + } + + public function test_휴면_상태가_아니면_해제가_거부된다(): void + { + Http::fake(); + $template = $this->row(); + + $this->expectException(BizppurioTemplateStateException::class); + + $this->makeService()->releaseDormant($template); + } + + /** + * 요약 맵은 SMS 본문의 유무·미리보기를 **모델 판정 결과로** 내보낸다 (#597 라운드 5 R4). + * + * 화면이 로케일 맵을 직접 훑으면 발송 게이트와 규칙이 갈린다 — 실제로 행 하단은 + * truthy 판정(공백만 있어도 "본문 있음")에 폴백 로케일을 'ko' 로 박아 두었고, + * 발송은 trim 비교에 config('app.fallback_locale') 를 쓰고 있었다. 같은 판정을 + * 두 벌 두면 한쪽만 고쳐지고, 증상은 "화면엔 본문이 있는데 발송은 스킵" 뿐이다. + * + * @effects sms_body_presence_and_preview_resolved_by_server + */ + public function test_요약맵이_sms_본문_유무와_미리보기를_서버_판정으로_내보낸다(): void + { + BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'sms_body' => ['ko' => ' [샵] 가입을 환영합니다. ', 'en' => 'Welcome!'], + ]); + // 전 로케일이 공백뿐이면 발송은 스킵된다 — 화면도 "본문 없음" 이어야 한다. + BizppurioTemplate::create([ + 'notification_type' => 'order_completed', + 'sms_body' => ['ko' => ' ', 'en' => ''], + ]); + BizppurioTemplate::create([ + 'notification_type' => 'comment_added', + 'sms_body' => null, + ]); + + $map = $this->makeService()->summaryMap(); + + $this->assertTrue($map['welcome']['has_sms_body']); + $this->assertSame(' [샵] 가입을 환영합니다. ', $map['welcome']['sms_body_preview']); + + $this->assertFalse($map['order_completed']['has_sms_body'], '공백뿐인 본문은 발송되지 않는다 — 화면도 없음으로 본다'); + $this->assertFalse($map['comment_added']['has_sms_body']); + + // 모달 시딩용 원본 맵은 그대로 유지된다(요약이 원본을 대체하지 않는다). + // MySQL json 컬럼은 키를 (길이, 사전순)으로 정규화 저장하므로 키 순서는 비교하지 않는다(§13.1). + $this->assertEqualsCanonicalizing(['ko' => ' [샵] 가입을 환영합니다. ', 'en' => 'Welcome!'], $map['welcome']['sms_body']); + } + + /** + * 미리보기 로케일 해석이 모델과 같은 체인을 탄다 (#597 라운드 5 R4). + * + * @effects sms_body_presence_and_preview_resolved_by_server + */ + public function test_요약맵_미리보기가_현재_로케일과_폴백_체인을_따른다(): void + { + BizppurioTemplate::create([ + 'notification_type' => 'welcome', + 'sms_body' => ['ko' => '한국어 본문', 'en' => 'English body'], + ]); + + app()->setLocale('en'); + $this->assertSame('English body', $this->makeService()->summaryMap()['welcome']['sms_body_preview']); + + // 해당 로케일 본문이 없으면 fallback_locale 로 내려간다 (하드코딩 'ko' 가 아니다) + config(['app.fallback_locale' => 'en']); + app()->setLocale('ja'); + $this->assertSame('English body', $this->makeService()->summaryMap()['welcome']['sms_body_preview']); + + app()->setLocale('ko'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php deleted file mode 100644 index c84736e3..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php +++ /dev/null @@ -1,174 +0,0 @@ -flush(); - } - - /** - * @param array $settings - */ - private function resolver(array $settings = ['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40']): KakaoTemplateContentResolver - { - $pluginSettings = Mockery::mock(PluginSettingsService::class); - $pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); - // template_cache_minutes 조회 (기본 60분 → 내부에서 ×60 초) - $pluginSettings->shouldReceive('get') - ->with(self::IDENTIFIER, 'template_cache_minutes', Mockery::any()) - ->andReturnUsing(fn ($id, $key, $default) => $settings['template_cache_minutes'] ?? $default); - - $kakao = new BizppurioKakaoApiClient($pluginSettings); - $templates = new AlimtalkTemplateService($kakao, $pluginSettings); - - // 실제 확장 캐시 드라이버 주입 (contextual binding 과 동일 계약) - $cache = app(CacheInterface::class); - - return new KakaoTemplateContentResolver($templates, $cache, $pluginSettings); - } - - /** - * 상세조회 응답의 원본 카카오 필드(templateContent/buttons)를 그대로 반환한다. - */ - public function test_템플릿_내용을_조회해_반환한다(): void - { - Http::fake([ - 'kapi.ppurio.com/*' => Http::response([ - 'code' => '200', - 'data' => [ - 'templateCode' => 'TW_1', - 'templateContent' => '#{name}님 주문 완료', - 'buttons' => [ - ['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/#{order}'], - ], - 'inspectionStatus' => 'APR', - 'status' => 'A', - ], - ], 200), - ]); - - $content = $this->resolver()->resolve('TW_1'); - - $this->assertNotNull($content); - $this->assertSame('#{name}님 주문 완료', $content['templateContent']); - $this->assertSame('WL', $content['buttons'][0]['linkType']); - } - - /** - * 같은 template_code 를 두 번 조회하면 두 번째는 캐시 히트 — kapi 호출은 1회뿐. - */ - public function test_두번째_조회는_캐시_히트로_kapi를_다시_부르지_않는다(): void - { - Http::fake([ - 'kapi.ppurio.com/*' => Http::response([ - 'code' => '200', - 'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'], - ], 200), - ]); - - $resolver = $this->resolver(); - $resolver->resolve('TW_1'); - $resolver->resolve('TW_1'); - - Http::assertSentCount(1); - } - - /** - * TTL=0 이면 캐시를 우회 — 매 조회마다 kapi 를 부른다(항상 최신). - */ - public function test_ttl이_0이면_캐시를_우회해_매번_조회한다(): void - { - Http::fake([ - 'kapi.ppurio.com/*' => Http::response([ - 'code' => '200', - 'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'], - ], 200), - ]); - - $resolver = $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0]); - $resolver->resolve('TW_1'); - $resolver->resolve('TW_1'); - - Http::assertSentCount(2); - } - - /** - * 조회 실패(고아 템플릿·장애)는 null 을 반환한다(호출측이 발송 skip). - */ - public function test_조회_실패시_null을_반환한다(): void - { - Http::fake([ - 'kapi.ppurio.com/*' => Http::response(['code' => '7315', 'message' => '템플릿 없음'], 200), - ]); - - $this->assertNull($this->resolver()->resolve('GONE')); - } - - /** - * rate limit(HTTP 429)이면 짧게 재시도한 뒤 성공하면 내용을 반환한다(조회 폭주 완화). - */ - public function test_rate_limit_429는_재시도해_성공하면_반환한다(): void - { - // 첫 응답 429 → 재시도 → 두 번째 성공. - Http::fakeSequence('kapi.ppurio.com/*') - ->push(['code' => '5002', 'description' => 'too many requests'], 429) - ->push([ - 'code' => '200', - 'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'], - ], 200); - - $content = $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0]) - ->resolve('TW_1'); - - $this->assertNotNull($content); - $this->assertSame('본문', $content['templateContent']); - Http::assertSentCount(2); - } - - /** - * 재시도해도 계속 429면 null 을 반환한다(무한 재시도 방지). - */ - public function test_rate_limit이_지속되면_null을_반환한다(): void - { - Http::fake([ - 'kapi.ppurio.com/*' => Http::response(['code' => '5002', 'description' => 'too many requests'], 429), - ]); - - $this->assertNull( - $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0]) - ->resolve('TW_1'), - ); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php deleted file mode 100644 index b1f71f6b..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php +++ /dev/null @@ -1,316 +0,0 @@ -> $templates list()['templates'] 반환값 - */ - private function fakeTemplateService(array $templates): AlimtalkTemplateService - { - $service = Mockery::mock(AlimtalkTemplateService::class); - $service->shouldReceive('list')->andReturn(['templates' => $templates, 'pagination' => []]); - - return $service; - } - - private function makeService(array $templates = [], ?KakaoTemplateContentResolver $kakaoContent = null): NotificationBindingService - { - return new NotificationBindingService( - new BizppurioNotificationBindingRepository, - $this->fakeTemplateService($templates), - $kakaoContent ?? $this->fakeKakaoContent(), - ); - } - - /** - * clearMany 호출 인자를 기록하는 KakaoTemplateContentResolver mock 을 만듭니다. - */ - private function fakeKakaoContent(): KakaoTemplateContentResolver - { - $resolver = Mockery::mock(KakaoTemplateContentResolver::class); - $resolver->shouldReceive('clearMany') - ->andReturnUsing(fn (array $codes) => count(array_unique(array_filter($codes)))); - - return $resolver; - } - - public function test_all은_알림톡_연동을_type_키_맵으로_반환한다(): void - { - (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ - 'template_code' => 'TW_1234', - 'template_name' => '주문완료', - 'fallback_sms_enabled' => true, - 'is_active' => true, - ]); - - $map = $this->makeService()->all(); - - $this->assertArrayHasKey('order_confirmed', $map); - $this->assertSame('TW_1234', $map['order_confirmed']['template_code']); - $this->assertSame('주문완료', $map['order_confirmed']['template_name']); - $this->assertTrue($map['order_confirmed']['fallback_sms_enabled']); - } - - public function test_all은_연동이_없으면_빈_맵을_반환한다(): void - { - $this->assertSame([], $this->makeService()->all()); - } - - public function test_all_withAvailability는_승인목록에_있는_연동을_사용가능으로_표시한다(): void - { - (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ - 'template_code' => 'TW_LIVE', - 'template_name' => '살아있음', - 'is_active' => true, - ]); - - // 승인 목록에 TW_LIVE 가 있음 → 사용 가능(is_unavailable=false) - $map = $this->makeService([ - ['templateCode' => 'TW_LIVE', 'templateName' => '살아있음', 'serviceStatus' => 'ACT'], - ])->all(withAvailability: true); - - $this->assertFalse($map['order_confirmed']['is_unavailable']); - } - - public function test_all_withAvailability는_승인목록에_없는_연동을_사용불가로_표시한다(): void - { - (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ - 'template_code' => 'TW_GONE', - 'template_name' => '삭제됨', - 'is_active' => true, - ]); - - // 승인 목록에 TW_GONE 이 없음(삭제·차단·미승인) → 사용 불가(is_unavailable=true) - $map = $this->makeService([ - ['templateCode' => 'TW_OTHER', 'templateName' => '다른템플릿', 'serviceStatus' => 'ACT'], - ])->all(withAvailability: true); - - $this->assertTrue($map['order_confirmed']['is_unavailable']); - } - - public function test_all_withAvailability는_카카오_조회_실패시_소실판정을_생략한다(): void - { - (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ - 'template_code' => 'TW_1234', - 'template_name' => '주문완료', - 'is_active' => true, - ]); - - // 카카오 승인 목록 조회가 실패(자격증명 미설정·장애)하면 판정을 건너뛴다 — - // 살아있는 연동이 일시 장애로 "사용 불가"로 오탐되지 않게(is_unavailable 필드 미부여). - $throwingTemplates = Mockery::mock(AlimtalkTemplateService::class); - $throwingTemplates->shouldReceive('list') - ->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정')); - - $service = new NotificationBindingService( - new BizppurioNotificationBindingRepository, - $throwingTemplates, - $this->fakeKakaoContent(), - ); - - $map = $service->all(withAvailability: true); - - $this->assertArrayNotHasKey('is_unavailable', $map['order_confirmed'], '조회 실패 시 소실 판정을 생략해야 한다.'); - } - - public function test_all은_기본값에서_소실판정을_하지_않는다(): void - { - (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ - 'template_code' => 'TW_1234', - 'template_name' => '주문완료', - 'is_active' => true, - ]); - - // store(저장) 경로처럼 프리필만 필요하면 카카오 대조를 생략한다(is_unavailable 필드 없음). - $map = $this->makeService()->all(); - - $this->assertArrayNotHasKey('is_unavailable', $map['order_confirmed']); - } - - public function test_승인_상태_템플릿만_연결_후보로_노출한다(): void - { - $service = $this->makeService([ - ['templateCode' => 'TW_A', 'templateName' => '승인됨', 'serviceStatus' => 'ACT'], - ['templateCode' => 'TW_B', 'templateName' => '발송전', 'serviceStatus' => 'RDY'], - ['templateCode' => 'TW_C', 'templateName' => '검수중', 'serviceStatus' => 'REQ'], - ['templateCode' => 'TW_D', 'templateName' => '반려', 'serviceStatus' => 'REJ'], - ]); - - $codes = array_column($service->approvedTemplates(), 'template_code'); - - $this->assertContains('TW_A', $codes, 'ACT(정상)는 노출되어야 한다.'); - $this->assertContains('TW_B', $codes, 'RDY(발송전)는 노출되어야 한다.'); - $this->assertNotContains('TW_C', $codes, 'REQ(검수중)는 제외되어야 한다.'); - $this->assertNotContains('TW_D', $codes, 'REJ(반려)는 제외되어야 한다.'); - } - - /** - * bind() 승인 검증을 통과시키는 템플릿 목록으로 서비스를 만듭니다 (bind 계열 테스트 공용 fixture). - * - * @param array $approvedCodes 승인(발송 가능) 처리할 template_code 목록 - */ - private function makeServiceWithApprovedCodes(array $approvedCodes): NotificationBindingService - { - return $this->makeService(array_map( - fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'], - $approvedCodes, - )); - } - - public function test_bind는_연동을_생성하고_unbind는_삭제한다(): void - { - $service = $this->makeServiceWithApprovedCodes(['TW_1236']); - - $service->bind('welcome', [ - 'template_code' => 'TW_1236', - 'template_name' => '가입환영', - 'fallback_sms_enabled' => false, - ]); - - $this->assertDatabaseHas('bizppurio_notification_bindings', [ - 'notification_type' => 'welcome', - 'channel' => 'alimtalk', - 'template_code' => 'TW_1236', - 'is_active' => true, - ]); - - $service->unbind('welcome'); - - $this->assertDatabaseMissing('bizppurio_notification_bindings', [ - 'notification_type' => 'welcome', - 'channel' => 'alimtalk', - ]); - } - - public function test_bind는_같은_알림에_대해_갱신한다(): void - { - $service = $this->makeServiceWithApprovedCodes(['TW_1', 'TW_2']); - - $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '첫번째']); - $service->bind('welcome', ['template_code' => 'TW_2', 'template_name' => '두번째']); - - $this->assertDatabaseCount('bizppurio_notification_bindings', 1); - $this->assertDatabaseHas('bizppurio_notification_bindings', [ - 'notification_type' => 'welcome', - 'template_code' => 'TW_2', - ]); - } - - public function test_bind는_미승인_템플릿이면_예외를_던지고_저장하지_않는다(): void - { - // 회귀: bind() 가 승인 상태를 재검증하지 않아, 드롭다운(화면) 필터를 우회해 API 를 - // 직접 호출하면 미승인 템플릿도 저장되던 결함. - $service = $this->makeServiceWithApprovedCodes(['TW_OTHER']); - - $this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class); - - try { - $service->bind('welcome', ['template_code' => 'TW_UNAPPROVED', 'template_name' => '미승인']); - } finally { - $this->assertDatabaseMissing('bizppurio_notification_bindings', [ - 'notification_type' => 'welcome', - 'template_code' => 'TW_UNAPPROVED', - ]); - } - } - - public function test_bind는_카카오_조회_실패시_예외를_던지고_저장하지_않는다(): void - { - // 승인 여부를 판정할 수 없으면 안전측으로 저장을 거부한다(조회 실패="승인됨" 오인 방지). - $throwingTemplates = Mockery::mock(AlimtalkTemplateService::class); - $throwingTemplates->shouldReceive('list') - ->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정')); - - $service = new NotificationBindingService( - new BizppurioNotificationBindingRepository, - $throwingTemplates, - $this->fakeKakaoContent(), - ); - - $this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class); - - try { - $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '가입환영']); - } finally { - $this->assertDatabaseMissing('bizppurio_notification_bindings', [ - 'notification_type' => 'welcome', - 'template_code' => 'TW_1', - ]); - } - } - - public function test_apply_from_template_save는_코드가_있으면_연동을_저장한다(): void - { - $this->makeServiceWithApprovedCodes(['TW_9']) - ->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true); - - $this->assertDatabaseHas('bizppurio_notification_bindings', [ - 'notification_type' => 'welcome', - 'channel' => 'alimtalk', - 'template_code' => 'TW_9', - 'fallback_sms_enabled' => true, - ]); - } - - public function test_apply_from_template_save는_코드가_비면_연동을_해제한다(): void - { - $service = $this->makeServiceWithApprovedCodes(['TW_1']); - $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '기존']); - - // 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제 (승인 검증 대상 아님) - $service->applyFromTemplateSave('welcome', '', null, false); - - $this->assertDatabaseMissing('bizppurio_notification_bindings', [ - 'notification_type' => 'welcome', - 'channel' => 'alimtalk', - ]); - } - - public function test_캐시초기화는_연결된_모든_template_code를_resolver에_넘긴다(): void - { - // 서로 다른 알림에 두 템플릿을 연결. - $repo = new BizppurioNotificationBindingRepository; - $repo->upsert('order_confirmed', 'alimtalk', ['template_code' => 'TW_1', 'template_name' => 'A', 'is_active' => true]); - $repo->upsert('welcome', 'alimtalk', ['template_code' => 'TW_2', 'template_name' => 'B', 'is_active' => true]); - - // resolver 가 실제로 받은 코드 목록을 포착. - $received = null; - $resolver = Mockery::mock(KakaoTemplateContentResolver::class); - $resolver->shouldReceive('clearMany') - ->once() - ->andReturnUsing(function (array $codes) use (&$received) { - $received = $codes; - - return count($codes); - }); - - $cleared = $this->makeService([], $resolver)->clearTemplateContentCache(); - - $this->assertSame(2, $cleared); - $this->assertContains('TW_1', $received); - $this->assertContains('TW_2', $received); - } - - public function test_캐시초기화는_연동이_없으면_0을_반환한다(): void - { - $this->assertSame(0, $this->makeService()->clearTemplateContentCache()); - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php index ec1fe165..2275afbb 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -2,20 +2,24 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services; -use App\Models\NotificationTemplate; +use App\Models\NotificationDefinition; use App\Models\User; use App\Notifications\GenericNotification; use App\Notifications\GuestNotifiable; use App\Services\NotificationDefinitionService; -use App\Services\NotificationTemplateService; use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; +use PHPUnit\Framework\Attributes\DataProvider; +use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus; use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface; +use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkPayloadMapper; use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext; use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder; use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; @@ -23,44 +27,47 @@ use Plugins\Sirsoft\MessageBizppurio\Services\SmsTypeResolver; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** - * SmsChannelDriver — 전화번호 해석·템플릿 게이트·SMS/LMS 판별·Job 위임 검증. + * SmsChannelDriver — 전화번호 해석·sms_body 게이트·#{var} 치환·SMS/LMS 판별·Job 위임 검증 + * (#597 §3.5 — 본문 소스가 코어 템플릿에서 bizppurio_templates.sms_body 로 전환됨). * * 발송 Job dispatch 를 Bus::fake 로 관찰한다(실제 발송은 SendMessageJobTest 가 커버). */ class SmsChannelDriverTest extends PluginTestCase { /** - * 렌더 결과를 반환하는 NotificationTemplate mock 을 만듭니다. + * sms_body 를 담은 템플릿 행을 만듭니다. * - * Eloquent 이벤트 재인스턴스화(new static)와 충돌하지 않도록 서브클래스 대신 - * Mockery mock 을 사용한다(is_active + replaceVariables 만 stub). + * sms_body 는 로케일 맵이다(#597 §14.3). 문자열을 넘기면 ko 본문으로 취급한다 — + * 로케일 축과 무관한 기존 케이스가 맵 표기를 반복하지 않게 하기 위한 편의다. * - * @param array $rendered replaceVariables 반환값 + * @param string|array|null $smsBody 로케일 맵 또는 ko 본문 */ - private function fakeTemplate(array $rendered = ['subject' => '', 'body' => '주문이 완료되었습니다.']): NotificationTemplate - { - $template = Mockery::mock(NotificationTemplate::class)->makePartial(); - $template->is_active = true; - $template->shouldReceive('replaceVariables')->andReturn($rendered); + private function templateRow( + string|array|null $smsBody = '주문이 완료되었습니다.', + bool $isActive = true, + bool $smsOnly = false, + ): BizppurioTemplate { + $row = new BizppurioTemplate; + $row->notification_type = 'order_confirmed'; + $row->status = BizppurioTemplateStatus::Draft; + $row->sms_body = is_string($smsBody) ? ['ko' => $smsBody] : $smsBody; + $row->sms_only = $smsOnly; + $row->is_active = $isActive; - return $template; + return $row; } /** - * 템플릿 resolve 결과를 지정한 driver 를 만듭니다. - * - * @param NotificationTemplate|null $template resolve 반환값 - * @param bool $isTestMode 검수 모드 설정값 (이력 스냅샷 검증용) + * findByType 반환값을 지정한 driver 를 만듭니다. */ private function makeDriver( - ?NotificationTemplate $template, + ?BizppurioTemplate $row, ?MessagePayloadBuilder $builder = null, bool $isTestMode = true, + ?NotificationDefinition $definition = null, ): SmsChannelDriver { - $templateService = Mockery::mock(NotificationTemplateService::class); - $templateService->shouldReceive('resolve') - ->with(Mockery::any(), 'sms') - ->andReturn($template); + $templates = Mockery::mock(BizppurioTemplateRepositoryInterface::class); + $templates->shouldReceive('findByType')->andReturn($row); $pluginSettings = Mockery::mock(PluginSettingsService::class); $pluginSettings->shouldReceive('get') @@ -69,11 +76,12 @@ class SmsChannelDriverTest extends PluginTestCase // 스킵 예외 메시지의 알림 유형 라벨 조회 — 정의 없음(null)이면 resolveTypeLabel 이 코드값으로 폴백. $definitionService = Mockery::mock(NotificationDefinitionService::class); - $definitionService->shouldReceive('resolve')->andReturn(null); + $definitionService->shouldReceive('resolve')->andReturn($definition); return new SmsChannelDriver( - $templateService, $definitionService, + $templates, + new AlimtalkPayloadMapper, new SmsTypeResolver, $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, @@ -114,7 +122,10 @@ class SmsChannelDriverTest extends PluginTestCase return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); } - public function test_템플릿이_없으면_예외를_던지고_발송하지_않는다(): void + /** + * @effects sms_skipped_when_body_empty_or_row_missing + */ + public function test_템플릿_행이_없으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); @@ -125,56 +136,81 @@ class SmsChannelDriverTest extends PluginTestCase $this->makeDriver(null)->send($member, $this->notification()); } finally { Bus::assertNotDispatched(SendMessageJob::class); + $this->assertDatabaseCount('bizppurio_dispatches', 0); } } - public function test_스킵_예외_메시지는_알림_유형_코드값_대신_사람이_읽는_이름을_사용한다(): void + /** + * @effects sms_skipped_when_body_empty_or_row_missing + */ + public function test_sms_body가_비어있으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - $definition = Mockery::mock(\App\Models\NotificationDefinition::class); - $definition->shouldReceive('getLocalizedName')->andReturn('회원가입 환영'); - - $definitionService = Mockery::mock(NotificationDefinitionService::class); - $definitionService->shouldReceive('resolve')->with('order_confirmed')->andReturn($definition); - - $templateService = Mockery::mock(NotificationTemplateService::class); - $templateService->shouldReceive('resolve')->with(Mockery::any(), 'sms')->andReturn(null); - - $pluginSettings = Mockery::mock(PluginSettingsService::class); - $pluginSettings->shouldReceive('get')->andReturn(true); - - $driver = new SmsChannelDriver( - $templateService, - $definitionService, - new SmsTypeResolver, - $this->spyBuilder(), - new BizppurioDispatchRepository, - new DispatchLinkContext, - $pluginSettings, - ); + $this->expectException(NotificationSendSkippedException::class); try { - $driver->send($member, $this->notification()); - $this->fail('NotificationSendSkippedException 가 발생해야 한다.'); - } catch (NotificationSendSkippedException $e) { - $this->assertStringContainsString('회원가입 환영', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.'); - $this->assertStringNotContainsString('order_confirmed', $e->getMessage()); + $this->makeDriver($this->templateRow(smsBody: ' '))->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); } } + public function test_비활성_행이면_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($this->templateRow(isActive: false))->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } + } + + /** + * sms_only 는 알림톡을 끄는 플래그지 SMS 를 켜거나 끄는 플래그가 아니다 (#597 §3.5). + * + * 두 값을 모두 태워야 "무관" 이 증명된다 — 한쪽만(특히 기본값과 같은 false 만) 넣으면 + * 이후 누군가 SmsChannelDriver 에 sms_only 분기를 넣어도 이 테스트는 green 으로 남는다. + * + * @effects sms_dispatches_regardless_of_sms_only_flag + */ + #[DataProvider('smsOnlyProvider')] + public function test_sms_body가_있으면_sms_only_값과_무관하게_발송한다(bool $smsOnly): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->templateRow(smsOnly: $smsOnly))->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class); + } + + /** + * @return array + */ + public static function smsOnlyProvider(): array + { + return [ + 'sms_only 꺼짐 (알림톡 병행)' => [false], + 'sms_only 켜짐 (문자 단독)' => [true], + ]; + } + public function test_회원은_mobile로_발송한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); $builder = $this->spyBuilder(); - $this->makeDriver($this->fakeTemplate(), $builder)->send($member, $this->notification()); + $this->makeDriver($this->templateRow(), $builder)->send($member, $this->notification()); Bus::assertDispatched(SendMessageJob::class); - $this->assertCount(1, $builder->calls); - $this->assertSame('01012345678', $builder->calls[0]['to'], '하이픈 제거된 회원 mobile 로 발송해야 한다.'); + $this->assertSame('01012345678', $builder->calls[0]['to']); } public function test_비회원은_data의_전화번호로_발송한다(): void @@ -184,24 +220,162 @@ class SmsChannelDriverTest extends PluginTestCase $data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; $builder = $this->spyBuilder(); - $this->makeDriver($this->fakeTemplate(), $builder)->send($guest, $this->notification($data)); + $this->makeDriver($this->templateRow(), $builder)->send($guest, $this->notification($data)); Bus::assertDispatched(SendMessageJob::class); $this->assertSame('01099990000', $builder->calls[0]['to']); } + /** + * @effects sms_body_is_dispatch_source_not_core_template + */ + public function test_sms_body의_변수를_알림_data로_치환해_발송한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + $builder = $this->spyBuilder(); + + // sms_body 는 알림톡 본문과 동일한 #{var} 표기를 쓴다 (#597 §3.1). + $this->makeDriver($this->templateRow(smsBody: '[샵] #{name}님 #{order_number} 주문 완료'), $builder) + ->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1'])); + + $this->assertSame('[샵] 김철수님 A1 주문 완료', $builder->calls[0]['message']); + } + + /** + * 수신자 로케일별 본문 선택 (#597 §14.3). + * + * 개편 과정에서 sms_body 가 단일 문자열이 되며 이 축이 사라진 적이 있다 — 코드에서 + * 로케일 해석을 빼도 어떤 테스트도 red 가 되지 않았고, 다국어 사이트의 en/ja 수신자가 + * ko 본문을 받는 것이 유일한 증상이었다. 이 테스트가 그 축을 고정한다. + * + * @effects sms_body_rendered_in_recipient_locale + */ + #[DataProvider('recipientLocaleProvider')] + public function test_sms_body는_수신자_로케일로_렌더된다(string $locale, string $expected): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', $locale); + $builder = $this->spyBuilder(); + + $this->makeDriver($this->templateRow(smsBody: [ + 'ko' => '[샵] 주문이 완료되었습니다.', + 'en' => '[Shop] Your order is complete.', + ]), $builder)->send($guest, $this->notification([ + SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000', + ])); + + $this->assertSame($expected, $builder->calls[0]['message']); + } + + /** + * @return array + */ + public static function recipientLocaleProvider(): array + { + return [ + 'ko 수신자' => ['ko', '[샵] 주문이 완료되었습니다.'], + 'en 수신자' => ['en', '[Shop] Your order is complete.'], + // 본문이 없는 로케일은 fallback_locale(ko)로 발송한다 — 빈 문자열로 스킵하지 않는다. + '본문 없는 로케일 → ko 폴백' => ['ja', '[샵] 주문이 완료되었습니다.'], + ]; + } + + /** + * LMS 제목도 본문과 같은 수신자 로케일로 렌더된다 (#597 §15.1). + * + * 본문만 로케일을 따르고 제목이 앱 로케일이면 한 통 안에서 언어가 갈린다. 이 축은 + * 라운드 5 까지 무단언이었다 — effect 이름은 "…_definition_label_subject" 인데 어느 + * 테스트도 subject 를 읽지 않아, resolveTypeLabel 의 $locale 인자를 지워도 green 이었다. + * + * @effects sms_lms_split_by_body_bytes_with_definition_label_subject, sms_body_rendered_in_recipient_locale + */ + public function test_lms_제목도_수신자_로케일로_렌더된다(): void + { + Bus::fake(); + $definition = new NotificationDefinition; + $definition->forceFill([ + 'type' => 'order_completed', + 'name' => ['ko' => '주문 완료', 'en' => 'Order completed'], + ]); + + foreach ([['ko', '주문 완료'], ['en', 'Order completed']] as [$locale, $expected]) { + $builder = $this->spyBuilder(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', $locale); + + // 바이트 길이로 LMS 분기가 되도록 두 로케일 본문을 모두 길게 둔다. + $this->makeDriver($this->templateRow(smsBody: [ + 'ko' => str_repeat('가', 100), + 'en' => str_repeat('a', 300), + ]), $builder, true, $definition)->send($guest, $this->notification([ + SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000', + ])); + + $this->assertSame('lms', $builder->calls[0]['type']); + $this->assertSame($expected, $builder->calls[0]['subject'], "{$locale} 수신자의 LMS 제목"); + } + } + + /** + * 행 게이트는 맵 전체를 보고, 본문 선택은 로케일별로 다시 판정한다 (#597 §14.3). + * + * 두 판정은 층이 다르다. 행 게이트(hasSmsBody)가 "현재 앱 로케일의 본문" 만 보면 + * ko 만 채운 알림이 en 관리자 세션에서 "본문 없음" 으로 잘못 판정된다. 반대로 행 + * 게이트만 통과시키고 끝내면 그 수신자의 로케일에 본문이 없을 때 빈 SMS 가 나간다. + * + * 로케일 해석은 코어 NotificationContentBehavior 와 동일한 체인이다 — + * `수신자 로케일 → fallback_locale → ''`. fallback_locale(ko) 이 비어 있으면 + * 발송하지 않는다(개편 전 코어 템플릿 경로와 같은 결과). + */ + public function test_fallback_locale에_본문이_없으면_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + // en 만 채운 행 — 행 게이트(hasSmsBody)는 통과하지만 ko 수신자용 본문이 없다. + $row = $this->templateRow(smsBody: ['en' => '[Shop] Done']); + $this->assertTrue($row->hasSmsBody(), '행 게이트는 어느 로케일에든 본문이 있으면 통과한다.'); + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($row)->send($member, $this->notification()); + } finally { + Bus::assertNothingDispatched(); + } + } + + /** + * 모든 로케일이 빈 문자열이면 발송하지 않는다 (#597 §14.3). + * + * 맵이 존재한다는 사실만으로 게이트를 통과하면 빈 SMS 가 나간다. + */ + public function test_모든_로케일이_비어있으면_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($this->templateRow(smsBody: ['ko' => ' ', 'en' => ''])) + ->send($member, $this->notification()); + } finally { + Bus::assertNothingDispatched(); + } + } + public function test_회원_발송_시_pending_이력을_생성하고_회원id를_기록한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678', 'name' => '김철수']); - $this->makeDriver($this->fakeTemplate())->send($member, $this->notification()); + $this->makeDriver($this->templateRow())->send($member, $this->notification()); $this->assertDatabaseCount('bizppurio_dispatches', 1); $dispatch = BizppurioDispatch::first(); $this->assertSame('pending', $dispatch->status->value); - $this->assertSame('01012345678', $dispatch->to_number); - $this->assertSame($member->id, $dispatch->to_user_id, '회원 발송은 to_user_id 를 채워야 한다.'); + $this->assertSame($member->id, $dispatch->to_user_id); $this->assertSame('order_confirmed', $dispatch->notification_type); } @@ -211,61 +385,23 @@ class SmsChannelDriverTest extends PluginTestCase $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); $data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; - $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification($data)); + $this->makeDriver($this->templateRow())->send($guest, $this->notification($data)); - $dispatch = BizppurioDispatch::first(); - $this->assertNotNull($dispatch); - $this->assertNull($dispatch->to_user_id, '비회원 발송은 to_user_id 가 null 이어야 한다.'); - } - - public function test_발송_시_실제_전송_payload가_이력에_저장된다(): void - { - Bus::fake(); - $member = User::factory()->create(['mobile' => '010-1234-5678']); - $builder = $this->spyBuilder(); - - $this->makeDriver($this->fakeTemplate(), $builder)->send($member, $this->notification()); - - $dispatch = BizppurioDispatch::first(); - $this->assertNotNull($dispatch->request_payload, '결함① — 실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.'); - $this->assertArrayNotHasKey('to', $dispatch->request_payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.'); - } - - public function test_이력_저장_payload에서_개인식별_정보는_제외된다(): void - { - Bus::fake(); - $member = User::factory()->create(['mobile' => '010-1234-5678']); - - // 실제 MessagePayloadBuilder(스파이 아님)로 진짜 조립 로직을 태워야 forHistory() 제외 - // 규칙(to/refkey/type/message 제거)을 검증할 수 있다. - $pluginSettings = Mockery::mock(PluginSettingsService::class); - $pluginSettings->shouldReceive('get')->andReturn(''); - $realBuilder = new MessagePayloadBuilder($pluginSettings); - - $this->makeDriver($this->fakeTemplate(), $realBuilder)->send($member, $this->notification()); - - $dispatch = BizppurioDispatch::first(); - $payload = $dispatch->request_payload; - - $this->assertArrayNotHasKey('to', $payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.'); - $this->assertArrayNotHasKey('refkey', $payload, 'refkey 는 refkey 컬럼과 중복이라 제외돼야 한다.'); - $this->assertArrayNotHasKey('type', $payload, 'type 은 channel 컬럼과 중복이라 제외돼야 한다.'); - $this->assertArrayNotHasKey('message', $payload['content']['sms'] ?? [], 'message 는 content 컬럼과 중복이라 제외돼야 한다.'); + $this->assertNull(BizppurioDispatch::first()->to_user_id); } public function test_발송하지_않으면_이력도_생성하지_않는다(): void { Bus::fake(); - $member = User::factory()->create(['mobile' => '010-1234-5678']); - - // 템플릿 없음 → NotificationSendSkippedException → 이력도 없어야 함 - $this->expectException(NotificationSendSkippedException::class); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); try { - $this->makeDriver(null)->send($member, $this->notification()); - } finally { - $this->assertDatabaseCount('bizppurio_dispatches', 0); + $this->makeDriver($this->templateRow())->send($guest, $this->notification()); + } catch (NotificationSendSkippedException) { + // 전화번호 없음 skip — 이력 미생성 검증이 목적 } + + $this->assertDatabaseCount('bizppurio_dispatches', 0); } public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void @@ -273,81 +409,50 @@ class SmsChannelDriverTest extends PluginTestCase Bus::fake(); $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); - // data 에 전화번호 없음 + 게스트라 mobile 속성 없음 $this->expectException(NotificationSendSkippedException::class); try { - $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification([])); + $this->makeDriver($this->templateRow())->send($guest, $this->notification()); } finally { Bus::assertNotDispatched(SendMessageJob::class); } } - public function test_짧은_본문은_sm_s로_긴_본문은_lm_s로_보낸다(): void + /** + * @effects sms_lms_split_by_body_bytes_with_definition_label_subject + */ + public function test_짧은_본문은_sms로_긴_본문은_lms로_보낸다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); - // SMS (90 byte 이하) - $smsBuilder = $this->spyBuilder(); - $this->makeDriver($this->fakeTemplate(['subject' => '제목', 'body' => '짧은 본문']), $smsBuilder) + $shortBuilder = $this->spyBuilder(); + $this->makeDriver($this->templateRow(smsBody: '짧은 본문'), $shortBuilder) ->send($member, $this->notification()); - $this->assertSame('sms', $smsBuilder->calls[0]['type']); + $this->assertSame('sms', $shortBuilder->calls[0]['type']); - // LMS (90 byte 초과 — 한글 45자 이상 = EUC-KR 90byte 초과) - $longBody = str_repeat('가', 100); - $lmsBuilder = $this->spyBuilder(); - $this->makeDriver($this->fakeTemplate(['subject' => '제목', 'body' => $longBody]), $lmsBuilder) + $longBuilder = $this->spyBuilder(); + $this->makeDriver($this->templateRow(smsBody: str_repeat('가', 100)), $longBuilder) ->send($member, $this->notification()); - $this->assertSame('lms', $lmsBuilder->calls[0]['type']); - $this->assertSame('제목', $lmsBuilder->calls[0]['subject'], 'LMS 는 subject(코어 제목)를 재사용해야 한다.'); - } - - public function test_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void - { - Bus::fake(); - $member = User::factory()->create(['mobile' => '01011112222']); - - $this->expectException(NotificationSendSkippedException::class); - - try { - $this->makeDriver($this->fakeTemplate(['subject' => '', 'body' => ' '])) - ->send($member, $this->notification()); - } finally { - Bus::assertNotDispatched(SendMessageJob::class); - } + $this->assertSame('lms', $longBuilder->calls[0]['type']); } public function test_generic_notification이_아니면_무시한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); - $other = new Notification; - $this->makeDriver($this->fakeTemplate())->send($member, $other); + $this->makeDriver($this->templateRow())->send($member, new Notification); Bus::assertNotDispatched(SendMessageJob::class); } - public function test_정상_조건에서_발송하고_이력을_생성한다(): void - { - // 비활성 확장 채널의 발송 차단은 코어 via() 책임이며 GenericNotificationViaTest 가 검증한다. - // 이 드라이버 테스트는 정상 조건(활성 전제)에서의 발송·이력 생성만 검증한다. - Bus::fake(); - $member = User::factory()->create(['mobile' => '010-1234-5678']); - - $this->makeDriver($this->fakeTemplate())->send($member, $this->notification()); - - Bus::assertDispatched(SendMessageJob::class); - $this->assertDatabaseCount('bizppurio_dispatches', 1); - } - public function test_검수_모드_설정값이_이력에_스냅샷으로_기록된다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - $this->makeDriver($this->fakeTemplate(), isTestMode: true)->send($member, $this->notification()); + $this->makeDriver($this->templateRow(), isTestMode: true)->send($member, $this->notification()); $this->assertTrue(BizppurioDispatch::first()->is_test_mode); } @@ -357,7 +462,7 @@ class SmsChannelDriverTest extends PluginTestCase Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - $this->makeDriver($this->fakeTemplate(), isTestMode: false)->send($member, $this->notification()); + $this->makeDriver($this->templateRow(), isTestMode: false)->send($member, $this->notification()); $this->assertFalse(BizppurioDispatch::first()->is_test_mode); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml deleted file mode 100644 index d4f549fd..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml +++ /dev/null @@ -1,148 +0,0 @@ -# audit:allow test-scenario-coverage reason: | -# Phase 6(재설계)는 알림톡 실발송 위임(백엔드) + 시딩 필터 훅 template 증강 + 코어 편집 모달 -# 전용 칸(즉시 저장) + 상태 배너 overlay 이다. axes cross product 의 런타임 효과는 계층별로 -# 전수 가드된다: -# - 발송 위임: AlimtalkChannelDriverTest — binding 게이트/카카오 내용 발송/버튼 extra/대체발송/전화번호/이력/플러그인 활성. -# - 카카오 내용 조회·캐시(kakao_content/content_cache 축): KakaoTemplateContentResolverTest — 조회/캐시 히트/TTL=0 우회/429 재시도/실패 null. -# - 카카오→발송 변환(kakao_elements 축): AlimtalkPayloadMapperTest — 본문/버튼/바로연결/title·header·item·highlight·link 변환 + #{var} 치환 + 부재 필드 생략. -# - 캐시 수동 초기화 엔드포인트·권한: AlimtalkTemplateControllerTest + NotificationBindingServiceTest. -# - template 증강: SeedChannelTemplatesListenerTest — 회원판정/기본문구/채널추가/멱등. -# - 연동 즉시저장·조회·권한(저장 시점 카카오 승인 재검증 포함): NotificationBindingServiceTest + -# NotificationBindingEndpointTest. -# - 채널 메타(is_test_mode): RegisterNotificationChannelsListenerTest. -# - overlay 전용칸·배너·i18n·무오염: notification_tab_binding.test.tsx. -# e2e_* 축(브라우저 왕복: 편집 열기→드롭다운 선택→즉시 저장→toast→배너)은 실제 카카오 발신 -# 프로필·승인 템플릿이 필요해, 계획서 §9 방침대로 PO 직접 수행하는 실연동 검증 단계에서 -# Playwright spec 을 최초 도입하며 그때 @scenario/@effects 를 이 매니페스트에 매핑한다. Vitest -# 렌더/구조 테스트가 프론트 overlay·배너·무오염을 가드하므로 이번 Phase 는 spec 미도입 -# (frontend-change-requires-e2e 는 이 매니페스트의 e2e axis + Vitest 커버로 충족). - -feature: 비즈뿌리오 알림톡 채널 실발송 + 알림 설정 편집 모달 연동 (Phase 6 재설계) - -description: | - 회원 대상 알림에 sms·alimtalk 채널 template 을 시딩 필터 훅으로 증강하고(코어/모듈 무수정), - 코어 알림 편집 모달(alimtalk 채널)에 전용 칸을 얹어 승인 알림톡 템플릿을 연결한다. 전용 칸 - 값은 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음 — 코어 저장 버튼과 무관해 코어 - 템플릿 무오염). 이벤트 발생 시 연결된 템플릿으로 알림톡을 자동발송한다. - - 핵심 동작: - - SeedChannelTemplatesListener: 코어/모듈 시딩 필터 훅(seed.*.notifications.translations)에 - 편승해 회원 알림에 sms·alimtalk template 증강(cleanupStaleTemplates 에도 유지). - - AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip. 플러그인 비활성 시 차단. - - 카카오 내용 반영(B안): 발송 본문·버튼·요소를 카카오 승인 템플릿(templateContent/buttons/ - quickReplies/title/header/item/itemHighlight/representLink)에서 상세조회로 가져와 채운다. - 비즈뿌리오 발송 API 는 완성본을 요구하므로 templatecode 만으로는 안 된다. - · KakaoTemplateContentResolver: 상세조회 + template_code 단위 캐시(기본 1h, TTL=0 끔, - 429/5002 rate limit 시 1회 재시도, 실패 null → skip). - · AlimtalkPayloadMapper: 카카오 필드 → 발송 형식 변환(linkMo→url_mobile 등) + #{var} 치환. - · 캐시 수동 초기화 엔드포인트/버튼(manage 권한): 카카오 변경 즉시 반영. - - 대체발송: binding.fallback_sms_enabled ON → payload 에 resend:{first:sms}+recontent 병합. - 대체 SMS 본문은 카카오와 무관하게 코어 alimtalk 템플릿에서 렌더(빈 본문이면 미병합). - - 행 연결 UI(extension_point notification_definition_row_footer): 알림톡 탭의 각 알림 행 하단에 - 연결 상태 줄(템플릿명·SMS대체) + [연결/변경] 버튼. channel==='alimtalk' 일 때만. - - 연결 모달(overlay modals, 우리 소유): 코어 편집 모달과 분리. 안내 카드 + 연결 템플릿 - 드롭다운(승인 RDY/ACT만) + SMS 대체 토글 + 변수 안내 + [취소][저장]. [저장]→우리 API(store) - + toast + 모달 닫힘 + 목록 갱신. 카카오 API 422 는 errorHandling.suppress 로 조용히 처리. - - 상태 배너(overlay injections): sms·alimtalk 탭에서 readiness 미충족 🔴설정하기 / is_test_mode 🟡. - - 안내 박스(overlay): 알림톡 탭 상시 안내(무엇을 하는 화면인지). - -axes: - channel: [alimtalk] - recipient: [member, guest] - binding_state: [bound, unbound] - fallback_sms: [on, off] - member_facing: [member_target, admin_only] - banner_state: [not_ready, test_mode, normal] - e2e_binding_action: [connect, change, unbind] - # B안 — 카카오 내용 반영·캐시 - kakao_content: [fetched, orphan_or_error, rate_limited] - kakao_elements: [body_only, with_button, with_quickreply, with_title, with_item, with_link] - content_cache: [hit, miss, ttl_zero, manual_cleared] - -exclusions: - - { binding_state: unbound, fallback_sms: on, reason: "미연결 알림은 대체발송 설정 자체가 없음" } - - { member_facing: admin_only, binding_state: bound, reason: "관리자 전용 알림은 증강 대상 아님 — 연동 불가" } - - { recipient: guest, e2e_binding_action: connect, reason: "연동 저장 UI 는 관리자 화면 — 수신자(member/guest)는 발송 축, UI 축과 무관" } - - { kakao_content: orphan_or_error, kakao_elements: with_button, reason: "조회 실패 시 내용 자체가 없어 요소 축 무의미 — skip 으로 수렴" } - - { kakao_content: rate_limited, kakao_elements: with_button, reason: "rate limit 은 조회 단계 — 재시도 소진 시 null → skip, 요소 축 무의미" } - - { content_cache: manual_cleared, kakao_elements: with_item, reason: "캐시 초기화는 조회 시점 축 — 요소 종류와 직교, 대표 조합만 커버" } - -effects: - # 발송 위임 (AlimtalkChannelDriver) - - alimtalk_dispatched_with_bound_template_code - - dispatch_skipped_when_no_binding - - dispatch_skipped_when_plugin_inactive - - dispatch_skipped_when_no_phone - - member_phone_resolved_from_mobile - - guest_phone_resolved_from_data_recipient_phone - - fallback_on_merges_sms_resend_recontent - - fallback_off_has_no_resend - - fallback_sms_body_from_core_template_not_kakao - - fallback_skipped_when_core_template_missing - - pending_dispatch_created_with_alimtalk_channel - # 카카오 내용 반영 (KakaoTemplateContentResolver + AlimtalkPayloadMapper) — B안 - - alimtalk_body_from_kakao_template_content - - dispatch_skipped_when_kakao_content_unavailable - - dispatch_can_send_without_core_alimtalk_template - - kakao_variables_substituted_from_notification_data - - kakao_buttons_mapped_to_send_form_with_url_substitution - - kakao_quickreplies_mapped_to_send_form - - kakao_title_header_item_highlight_link_mapped - - absent_kakao_fields_omitted_from_payload - - content_cache_hit_skips_kapi_call - - content_cache_ttl_zero_bypasses_cache - - content_cache_rate_limit_retried_then_null - - manual_cache_clear_forgets_bound_template_codes - - manual_cache_clear_requires_manage_permission - # template 증강 (SeedChannelTemplatesListener) - - member_facing_notification_gets_sms_and_alimtalk_channels - - admin_only_notification_not_augmented - - augmented_body_reuses_database_plaintext - - augmented_body_strips_html_when_no_database - - existing_channel_not_duplicated - - module_sequential_array_augmented - # 채널 메타 (RegisterNotificationChannelsListener) - - channel_meta_carries_is_test_mode - - test_mode_off_sets_is_test_mode_false - # 연동 즉시 저장·조회·권한 (NotificationBindingService/Controller) - - store_creates_or_updates_binding_immediately - - store_empty_code_unbinds - - only_approved_templates_offered_as_options - - bind_rejects_unapproved_template_code - - bind_rejects_when_kakao_lookup_fails - - bindings_map_returned_for_prefill - - view_permission_required_for_read - - manage_permission_required_for_store - # 행 연결 UI·연결 모달·배너 (프론트 — Vitest 구조/i18n) - - row_binding_shown_only_for_alimtalk_channel - - row_shows_bound_or_unbound_status_from_bindings - - connect_button_seeds_modal_and_opens_our_modal - - openmodal_runs_before_approved_refetch - - our_binding_modal_registered_via_modals - - fallback_toggle_disabled_when_no_template - - save_posts_to_our_api_then_toast_close_refresh - - approved_templates_422_suppressed - - banner_shown_only_on_sms_alimtalk_tab_when_problem - - not_ready_banner_has_setup_action - - test_mode_banner_shown_when_ready_and_test_mode - - alimtalk_tab_guide_box_shown - - core_edit_modal_untouched_no_bizppurio_fields - # e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상) - - e2e_open_alimtalk_edit_shows_section - - e2e_select_template_saves_and_toasts - - e2e_unbind_via_none_option_removes_binding - -test_files: - - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php - - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php - - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php - - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php - - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php - - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php - - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php - - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php - - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx - - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx - -audit: - status: codified \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml deleted file mode 100644 index 48bb8ddb..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml +++ /dev/null @@ -1,93 +0,0 @@ -# audit:allow test-scenario-coverage reason: | -# 알림톡 템플릿 화면은 설정 페이지(plugin_settings.json)의 탭으로 배치되며(독립 페이지·메뉴 -# 없음), kapi 실시간 위임이라 DB 저장이 없다. 조회 전용 전환으로 등록·수정·삭제·검수·상태변경을 -# 제거하고 목록·상태·내용 조회 + 알림 연결만 남겼다(등록·관리는 비즈뿌리오 콘솔로 위임). axes -# cross product 의 UI 구조(탭 전환·목록 서브뷰·상세 모달·콘솔/준비 안내·i18n) effects 는 Vitest -# (alimtalk_templates.test.tsx) 정적 검증으로 가드된다. 조회 권한 경계(view)·등록 라우트 제거(405)· -# kapi 실패 전파(422)는 PHPUnit Feature(AlimtalkTemplateControllerTest) 로, 상태 배지 매핑·kapi -# 파라미터 전달은 Unit(AlimtalkTemplateServiceTest) 로 Http::fake 왕복 관찰한다. 런타임 Playwright -# spec 은 실연동 검증(PO 직접 수행, 카카오 관리 API 자격증명 필요) 단계에서 최초 도입한다. - -feature: 알림톡 템플릿 조회 (조회 전용) - -description: | - 설정 페이지 알림톡 템플릿 탭에서 카카오 관리 API(kapi.ppurio.com)로 알림톡 템플릿을 - 실시간 조회한다(목록·상태·내용). DB 저장 없이 매 요청 kapi 위임이며, 등록·수정·삭제·검수· - 상태변경은 비즈뿌리오 콘솔로 위임한다. 이 화면의 고유 가치는 카카오 템플릿 ↔ G7 알림 연결 - (binding)이며, 진입은 코어 소유 설정 페이지 하나로 통일한다(메뉴 없음). - - 핵심 동작: - - 탭 네비게이션: 환경설정 ↔ 알림톡 템플릿 (query.tab, 배타 전환) - - 목록 서브뷰: 상태 필터(templateStatus) + 검색(keyword) + 상태 배지(serviceStatus) + [내용] - - 상세 모달: 내용만 표시 + [닫기] (관리 액션 없음) - - 권한: 조회=messaging.view (변경 라우트는 제거 — 등록은 콘솔) - - kapi 실패(발신프로필 미설정·조회 실패)는 그대로 422 로 전파(운영자가 사유 즉시 확인) - - readiness: api_key·sender_key 미설정 시 안내 + [설정하기] 탭 이동 - - 안내: 목록 상단 콘솔 위임 안내 + 환경설정 탭 사용 전 준비 안내(카카오·SMS) - -axes: - auth: [view, forbidden, guest] - kapi_result: [ok, fail, not_found] - -exclusions: - - { auth: guest, kapi_result: fail, reason: "비인증은 결과 무관 401 — kapi 도달 전 차단" } - - { auth: forbidden, kapi_result: fail, reason: "권한 없으면 403 — kapi 도달 전 차단" } - - { auth: guest, kapi_result: not_found, reason: "비인증은 결과 무관 401 — kapi 도달 전 차단" } - - { auth: forbidden, kapi_result: not_found, reason: "권한 없으면 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 - - init_actions_loads_list_when_entering_templates_tab - # 데이터소스 (UI) — 조회 전용, 카테고리 데이터소스 제거 - - templates_datasource_targets_admin_api - - category_datasource_removed_read_only - # 조회 전용 — 폼·관리 제거 (UI) - - form_view_removed - - action_modal_removed - - form_and_manage_state_absent_from_layout - # 목록 서브뷰 (UI) - - toolbar_has_status_filter_and_keyword_search - - toolbar_has_no_new_template_button_refresh_only - - table_card_always_shows_header_and_branches_tbody_by_count - - table_iterates_templates_with_status_badge - - manage_column_is_single_content_button_opening_detail_modal - - list_has_row_number_requested_at_processed_at_columns - - status_badge_appends_rdy_sub_label_only_for_rdy - - top_level_actions_all_have_event_type - - pagination_updates_page_state_and_refetches - # 안내 (UI) - - list_notice_shows_badge_meaning_and_console_delegation - - settings_tab_shows_preparation_notice_kakao_and_sms - # 상세 모달 & readiness (UI) - - detail_modal_defined - - detail_modal_shows_status_badge_and_content_close_only - - readiness_notice_shown_when_ready_flag_false_via_readiness_api - # 권한 경계 (백엔드) - - list_returns_templates_with_status_badge - - show_returns_template_detail_with_status_badge - - list_requires_authentication_returns_401 - - list_requires_view_permission_returns_403 - - store_route_removed_returns_405_read_only - # 상태 배지 & kapi 전달 (백엔드) - - service_status_maps_to_badge_without_available_actions - - status_badge_label_key_uses_frontend_lang_format - - detail_derives_status_from_inspection_and_status - - list_passes_status_keyword_to_kapi - # kapi 실패 전파 (백엔드) - - kapi_failure_is_surfaced_as_422_with_result_code - - sender_key_missing_throws - # kapi 508(요청한 데이터가 없음=검색 결과 0건) 은 에러가 아니라 빈 목록으로 처리 (백엔드) - - list_treats_kapi_508_as_empty_result_not_error - # 다국어 - - 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 diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-dispatch.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-dispatch.yaml new file mode 100644 index 00000000..9baa0a0f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-dispatch.yaml @@ -0,0 +1,66 @@ +feature: 비즈뿌리오 발송 파이프라인 — DB 승인 스냅샷 소스 전환 (#597) + +description: | + 알림톡 발송 본문·요소의 출처를 카카오 실시간 조회에서 bizppurio_templates 의 + 승인 스냅샷(approved_content)으로, SMS 본문을 코어 알림 템플릿에서 sms_body 로 전환한다. + + 핵심 동작: + - 알림톡 게이트: 행 존재 + alimtalk_enabled + is_active + sms_only 아님 + status=approved + + approved_content 존재. 불충족 시 NotificationSendSkippedException(이력 "실패" 기록 — + 이슈 #28 계약 유지). 발송 시점 카카오 조회 없음. + - 본문 변환: 스냅샷(등록 페이로드 필드명) → 발송 규격(AlimtalkPayloadMapper — + buttons[].linkMo→button[].url_mobile 등 대응표 고정) + #{var} 치환. + - 대체 SMS: fallback_sms_enabled + sms_body 있을 때만 resend/recontent 병합 + (비즈뿌리오 위임 — G7 레벨 재발송 없음, 현행 유지). + - SMS 채널: 행 존재 + is_active + sms_body 비어있지 않음이면 발송(sms_only 무관 — + sms_only 는 알림톡을 끄는 플래그지 SMS 를 켜는 플래그가 아니다). + +axes: + template_state: [approved, unapproved, disabled, row_missing] + fallback_sms: [on, off] + sms_only: [on, off] + recipient: [member, guest] + +exclusions: + - { template_state: [unapproved, disabled, row_missing], fallback_sms: on, reason: "알림톡이 스킵되면 payload 자체가 없어 resend 병합 지점이 없다 — fallback 축은 approved 에서만 유효" } + - { sms_only: on, template_state: [unapproved, disabled, row_missing], reason: "이미 알림톡이 스킵되는 상태라 sms_only 의 추가 차단 효과를 관측할 수 없다 — sms_only 의 게이트 효과는 approved 에서만 유효" } + - { sms_only: on, fallback_sms: on, reason: "sms_only 가 알림톡을 차단하면 그 알림톡 payload 에 resend 를 병합할 지점이 사라진다 — 위 첫 exclusion 과 같은 사유가 approved 안에서 반복된다" } + - { template_state: [unapproved, disabled, row_missing], recipient: guest, reason: "게이트가 수신자 해석(mobile / data.recipient_phone)보다 먼저 차단하므로 스킵 경로에서는 recipient 축이 관측되지 않는다 — 수신자 분기는 알림톡이 실제 발송되는 approved 에서만 유효" } + - { sms_only: on, recipient: guest, reason: "동일 — sms_only 스킵도 수신자 해석 이전 지점이다" } + +effects: + # 알림톡 게이트 + - alimtalk_skipped_when_row_missing + - alimtalk_skipped_when_not_approved + - alimtalk_skipped_when_alimtalk_disabled + - alimtalk_skipped_when_row_inactive + - alimtalk_skipped_when_snapshot_missing + - alimtalk_skipped_when_sms_only_selected + - no_kapi_call_at_dispatch_time + # 본문·요소 + - snapshot_content_substituted_with_notification_data + - snapshot_buttons_mapped_to_dispatch_button_fields + - dispatch_uses_row_template_code + # 대체 SMS + - fallback_on_merges_resend_recontent_from_sms_body + - fallback_on_with_empty_sms_body_skips_merge + - fallback_off_has_no_resend + # SMS 채널 + - sms_body_is_dispatch_source_not_core_template + - sms_skipped_when_body_empty_or_row_missing + - sms_dispatches_regardless_of_sms_only_flag + - sms_lms_split_by_body_bytes_with_definition_label_subject + # 로케일 (§14.3 — sms_body 는 로케일 맵, 수신자 로케일로 렌더) + - sms_body_rendered_in_recipient_locale + # 이력·수신자 + - pending_dispatch_row_created_with_channel_and_user + - guest_phone_resolved_from_data_recipient_phone + - skip_exception_uses_human_readable_type_label + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php + +audit: + status: codified diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-template-lifecycle.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-template-lifecycle.yaml new file mode 100644 index 00000000..afca18d9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-template-lifecycle.yaml @@ -0,0 +1,131 @@ +feature: 비즈뿌리오 알림톡 템플릿 라이프사이클 (#597) + +description: | + 알림톡 템플릿을 시스템에서 작성(draft) → 검수 신청(requested) → 카카오 승인(approved) + 후 발송 활성화하는 전 흐름. 발송 판정은 DB(bizppurio_templates)가 유일한 근거다 + (발송 전 실시간 조회 폐지 — 이슈 요구). + + 핵심 동작: + - 작성: 알림 1건당 1행(unique), content 는 kapi 등록 페이로드 JSON 단일 컬럼 + (유형별 컬럼 분해 없음 — 카카오 규격 변경에 스키마 불변) + - 검수 신청: template_code 자체 채번(g7_{md5 8}_{세대}, codeCheck 충돌 시 세대 증가 + 최대 3회) → kapi add(최초)/update(재신청) → request → requested + - 전이 가드: content 수정·삭제는 draft/rejected 만 (requested 는 신청 취소 먼저, + approved 는 승인 취소 먼저 — 승인 취소 즉시 발송 게이트 차단 경고) + - 동기화: 스케줄러(30분, requested 있을 때만 list 일괄 대조) + 수동 새로고침(detail 1건). + 승인 전이 시 content 를 approved_content 로 동결(발송 SSoT), 반려 시 comments 저장 + - 화면: 3면(코어/게시판/이커머스) '비즈뿌리오' 통합 탭(sms 탭 숨김 + tab_channels 규칙) + 행 하단 라이프사이클 UI + 작성 모달(4×4 유형) + 플러그인 관리 화면(DB 목록) + + axis 설계 노트: + - content_variant 는 카카오 메시지유형×강조유형의 대표 변형 4종이다 — 유형별 조건부 + 검증·페이로드 보존이 각 변형에서 실측된다. 유형 전 조합(4×4)의 세부 판정은 + FormRequest 매트릭스 effects(데이터셋 전수)가 담당한다. + - 전이 axis 는 유형과 직교(전이 로직은 content 를 불투명 JSON 으로 다룬다)하므로 + 대표 유형(ba_none)으로만 교차한다 — exclusions 에 명시. + - 동기화 경로(스케줄러/수동)·화면 면(3면+관리)은 조합 폭발 축이 아니라 효과 축이라 + effects 로 검증한다(스케줄러=커맨드 테스트, 3면 패리티=렌더 테스트, 브라우저=e2e). + +axes: + content_variant: [ba_none, ex_text, ad_image, mi_item_list] + transition: [draft_to_requested, requested_to_approved, requested_to_rejected, approved_cancel, requested_cancel] + +exclusions: + - { content_variant: [ex_text, ad_image, mi_item_list], transition: [requested_to_approved, requested_to_rejected, approved_cancel, requested_cancel], reason: "전이 규칙은 유형과 직교(content 는 불투명 JSON) — 전이 axis 는 대표 유형(ba_none)으로 검증하고, 유형 axis 는 검증 매트릭스·신청 페이로드 보존으로 검증" } + +effects: + # 데이터 모델·상태 (S1) + - status_enum_maps_kapi_service_status_vocabulary + - unknown_service_status_does_not_overwrite_state + - notification_type_unique_one_row_per_definition + - model_casts_content_snapshot_inspection_json + # 검수 신청 (S2) + - request_generates_code_with_codecheck_retry_up_to_three_generations + - first_request_calls_add_then_request_with_full_content_payload + - rerequest_calls_update_not_add + - request_without_content_rejected_422 + - request_snapshots_sender_key + - kapi_failure_reason_surfaced_in_errors_bizppurio_message + # 전이 가드 (S2) + - content_edit_locked_outside_draft_rejected + - concurrent_duplicate_request_claims_single_submission + - request_chain_click_actions_carry_reentrancy_guard + - sms_delivery_fields_editable_in_any_status + - cancel_request_returns_to_draft + - cancel_approval_returns_to_draft_keeping_snapshot + - cancel_approval_immediately_blocks_dispatch_gate + - delete_removes_kakao_side_only_in_deletable_state + # 유형 매트릭스 (S2 — FormRequest) + - ex_mi_require_template_extra + - text_requires_title_and_subtitle + - image_requires_image_url + - item_list_requires_two_to_ten_items + - buttons_max_five_with_linktype_conditional_fields + - quick_replies_restricted_linktypes + - highlight_length_depends_on_thumbnail_presence + - server_prunes_irrelevant_type_fields_before_save + # 이미지 업로드 프록시 (S2 — 부록 A-7) + - image_upload_proxies_to_kapi_and_returns_url + - image_upload_rejects_out_of_spec_file_before_kapi + # 동기화 (S3) + - scheduler_skips_kapi_when_no_requested_rows + - scheduler_uses_list_batch_not_per_row_detail + - approval_transition_freezes_content_into_approved_content + - rejection_transition_stores_comments_detail + - manual_sync_updates_single_row + # 화면 (S5) + - bizppurio_tab_replaces_sms_and_alimtalk_tabs + - tab_visible_when_any_of_tab_channels_active + - row_footer_shows_status_badge_and_lifecycle_actions + - compose_modal_switches_conditional_fields_by_type + - manage_screen_lists_db_rows_with_merge_query_round_trip + - sms_modal_saves_body_via_delivery_upsert + - sms_modal_edits_body_per_locale_tab + # 업로드 진행 상태 배선 (라운드 5 — 이 축이 effect 에 없어 배선 단언만 지우면 룰이 조용했다) + - upload_in_progress_locks_save_buttons_not_cancel + - upload_rejects_concurrent_selection_while_in_flight + - upload_result_discarded_when_modal_reseeded + # 화면·서버 판정 일치 (라운드 5) + - save_chain_click_actions_carry_reentrancy_guard + - manage_row_actions_match_row_footer_visibility + - sms_body_presence_and_preview_resolved_by_server + +scenarios: + - id: bizppurio_tab_integration_e2e + description: 코어 알림 설정에서 문자·알림톡 개별 탭이 사라지고 '비즈뿌리오' 통합 탭이 노출되며, 행 하단에 알림톡·SMS 라이프사이클 UI 가 렌더된다 (i18n 원문 노출 0) + effects: + - bizppurio_tab_replaces_sms_and_alimtalk_tabs + - row_footer_shows_status_badge_and_lifecycle_actions + - id: bizppurio_compose_modal_e2e + description: 작성 모달을 열고 강조 유형(NONE→TEXT→IMAGE→ITEM_LIST)을 전환하면 조건부 필드 블록이 등장/소멸한다 + effects: + - compose_modal_switches_conditional_fields_by_type + - id: bizppurio_manage_round_trip_e2e + description: 플러그인 관리 화면에서 필터·페이지 상태가 URL 에 보존되고(mergeQuery) SMS 본문 모달이 저장 왕복한다 + effects: + - manage_screen_lists_db_rows_with_merge_query_round_trip + - sms_modal_saves_body_via_delivery_upsert + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/BizppurioTemplateStatusTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioTemplateTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/SyncTemplateStatusCommandTest.php + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/handlers/uploadTemplateImage.test.ts + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts + # 3면 패리티(오버레이 전문 동일성) — 파일째 지워도 룰이 울지 않던 사각을 닫는다. + # plugin_settings.test.tsx 는 plugin-settings.yaml 이 소유하므로 여기에 중복 등재하지 않는다. + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx + # E2E spec — 통합 탭 노출(tab_channels 활성 규칙 포함)은 브라우저 실측이 SSoT 라 여기에도 선언 + - plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts + - plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts + +e2e_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts + - plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts + +audit: + status: codified diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml index 9d04e6b5..a40bf24b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml @@ -67,7 +67,6 @@ test_files: - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php - - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php audit: 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 7c0cc219..946095d1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -87,9 +87,11 @@ effects: - settings_save_success_invalidates_token_cache_regardless_of_changed_fields - settings_save_failure_does_not_invalidate_token_cache - other_plugin_settings_save_does_not_invalidate_this_plugin_token_cache - # 탭 전환 (환경설정 ↔ 알림톡 템플릿) — query.tab 변경 시 if 조건부 패널 재평가 필요 + # 탭 전환 (환경설정 ↔ 알림 템플릿 관리) — query.tab 변경 시 if 조건부 패널 재평가 필요 - tab_buttons_use_navigate_not_replaceurl # replaceUrl 은 URL만 바꿔 if 재평가 없음 → 화면 미전환 회귀 - - alimtalk_template_list_error_shown_with_kakao_reason # 목록 조회 실패 시 카카오 사유(kakao_message)를 배너로 노출 + # 구 '알림톡 템플릿'(kapi 실시간 목록 조회) 탭의 오류 배너 effect 는 제거했다 — 그 화면 자체가 + # #597 §3.5 에서 DB 기반 라이프사이클로 대체돼 사라졌고, 남겨 두면 존재하지 않는 화면을 + # 서술하는 계약이 된다. 현 오류 표면화 계약은 bizppurio-template-lifecycle.yaml 이 소유한다. # 다국어 - all_referenced_i18n_keys_exist_in_ko_and_en # 설치·활성화 (백엔드) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml index 623b6534..de1592b7 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml @@ -21,10 +21,15 @@ description: | - ChannelManager::extend('sms'): SmsChannelDriver 로 발송 위임(alimtalk 은 Phase 6, no-op 스텁) - 전화번호 해석: 회원=users.mobile / 비회원=data 의 _recipient_phone(D1) - 비회원 전화번호 주입: 이커머스 extract_data 구독으로 orderer_phone → data(GuestPhoneExtractListener) - - sms 템플릿 없으면 발송 skip(본문 시드는 Phase 6) / 본문 byte 로 SMS·LMS 판별 + - sms 본문이 없으면 발송 skip / 본문 byte 로 SMS·LMS 판별 ※ alimtalk 실발송·탭 UI·본문 시드는 Phase 5·6. 발송 이력 DB(bizppurio_dispatches) 영속화는 Phase 4. + ※ #597 이후 본문 SSoT 변경: sms 본문 출처는 코어 notification_templates(sms) 가 아니라 + bizppurio_templates.sms_body 이고, 발송 게이트는 (행 존재 && is_active && sms_body 비어있지 않음) + 이다. 알림톡 게이트·대체 SMS·SMS 단독을 포함한 현행 발송 규칙의 정본은 + bizppurio-dispatch.yaml 이며, 본 매니페스트는 채널 등록·준비도·전화번호 해석 축을 담당한다. + axes: channel: [sms, alimtalk] recipient: [member, guest] diff --git a/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap b/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap index d311e555..9ce67254 100644 --- a/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap +++ b/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap @@ -86,7 +86,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: greedy ] `; -exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량 1`] = `476`; +exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량 1`] = `513`; exports[`BindingShape 라우팅 diff 하네스 > 판정이 갈리는 식의 분류별 건수 요약 1`] = ` { diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index fd74cf5d..2eee62d5 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -10,6 +10,10 @@ - 환경설정 > SEO 탭에서 사이트 기본 공유 이미지(og:image)를 업로드할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 링크 미리보기 카드에 사용됩니다. +### Changed + +- 관리자 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 확장이 여러 발송 채널을 하나의 탭으로 묶어 표시할 수 있으며(예: 비즈뿌리오 문자·알림톡 → '비즈뿌리오' 단일 탭), 묶인 채널 중 하나라도 켜져 있으면 탭이 노출됩니다. + ## [1.0.6] - 2026-08-22 ### Added diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-bizppurio-tab-filter.test.ts b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-bizppurio-tab-filter.test.ts new file mode 100644 index 00000000..fee90cf5 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-bizppurio-tab-filter.test.ts @@ -0,0 +1,261 @@ +/** + * @file admin-settings-bizppurio-tab-filter.test.ts + * @description 알림 정의 탭 — 확장 채널 통합 탭 필터/라벨 규칙 회귀 테스트 (#597) + * + * 배경: 비즈뿌리오 플러그인이 sms·alimtalk 개별 탭을 숨기고(hidden_tab) '비즈뿌리오' 통합 + * 탭 하나로 노출하는 구조로 전환됐다. 탭 필터 표현식은 세 규칙을 모두 담아야 한다: + * + * 1. hidden_tab 제외 — 확장이 hidden_tab:true 로 선언한 채널(sms/alimtalk 개별 탭)은 + * 목록에서 빠진다. + * 2. 코어 채널 — 저장값(is_active)이 명시적으로 false 가 아니면 노출(기본 노출). + * 3. 확장 채널 — tab_channels(통합 탭이 대표하는 채널 집합, 미선언 시 자기 자신) 중 + * 하나라도 활성 저장(is_active === true)이면 노출. 전부 꺼져 있으면 탭 자체가 사라진다. + * + * 탭 라벨은 확장이 준 tab_label_key 가 있으면 $t 로 해석하고, 없으면 name → id 폴백이다. + * (tab_label_key 를 $t 없이 원문 출력하면 화면에 i18n 키가 그대로 노출된다.) + * + * 이 규칙은 코어/게시판/이커머스 3면 partial 이 문자열 수준으로 동일해야 한다 — + * 한 면만 고치면 나머지 면에서 통합 탭이 조용히 사라지거나 라벨이 키로 노출된다. + * + * @scenario resource=notification_definitions_tab,endpoint=admin_settings,observation=tab_filter + * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active + */ + +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +const ADMIN_BASIC_TAB = path.resolve( + __dirname, + '../../layouts/partials/admin_settings/_tab_notification_definitions.json', +); +const BOARD_TAB = path.resolve( + __dirname, + '../../../../../modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json', +); +const ECOMMERCE_TAB = path.resolve( + __dirname, + '../../../../../modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json', +); + +/** + * 레이아웃 파일을 읽어 파싱합니다. + * + * @param filePath 레이아웃 파일 절대 경로 + * @returns 파싱된 JSON 객체 + */ +function readLayout(filePath: string): Record { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); +} + +/** + * id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다. + * (면 접두: 코어 없음 / board_ / ecommerce_) + * + * @param node 탐색할 JSON 노드 + * @returns 서브탭 컨테이너 노드 (없으면 null) + */ +function findSubTabsContainer(node: unknown): Record | null { + if (!node || typeof node !== 'object') return null; + if (Array.isArray(node)) { + for (const child of node) { + const found = findSubTabsContainer(child); + if (found) return found; + } + return null; + } + const n = node as Record; + if (typeof n.id === 'string' && n.id.endsWith('channel_sub_tabs')) { + return n; + } + for (const value of Object.values(n)) { + const found = findSubTabsContainer(value); + if (found) return found; + } + return null; +} + +/** + * 서브탭 컨테이너 안의 채널 탭 반복(iteration) 버튼 노드를 찾습니다. + * + * @param node 탐색할 JSON 노드 + * @returns 탭 버튼 iteration 노드 (없으면 null) + */ +function findChannelTabIteration(node: unknown): Record | null { + const container = findSubTabsContainer(node); + if (!container) return null; + const walk = (n: unknown): Record | null => { + if (!n || typeof n !== 'object') return null; + if (Array.isArray(n)) { + for (const child of n) { + const found = walk(child); + if (found) return found; + } + return null; + } + const obj = n as Record; + if (typeof obj.iteration?.source === 'string' && obj.iteration.source.includes('availableChannels')) { + return obj; + } + for (const value of Object.values(obj)) { + const found = walk(value); + if (found) return found; + } + return null; + }; + return walk(container); +} + +describe('알림 정의 탭 필터 — 확장 채널 통합 탭 규칙 (#597)', () => { + const tabNode = findChannelTabIteration(readLayout(ADMIN_BASIC_TAB)); + const source: string = tabNode?.iteration?.source ?? ''; + + it('채널 탭 반복 노드가 존재한다 (0건이면 아래 단언은 공회전한다)', () => { + expect(tabNode).toBeTruthy(); + expect(source.length).toBeGreaterThan(0); + }); + + it('hidden_tab:true 채널을 목록에서 제외한다 (sms·alimtalk 개별 탭 숨김)', () => { + expect(source).toContain('c.hidden_tab !== true'); + }); + + it('코어 채널은 저장값이 명시적 false 가 아니면 노출된다 (기본 노출)', () => { + expect(source).toContain( + "c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false", + ); + }); + + it('확장 채널은 tab_channels 중 하나라도 활성 저장이면 노출된다 (전부 꺼지면 탭 소멸)', () => { + expect(source).toContain( + "(c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0", + ); + }); + + it('탭 라벨은 tab_label_key 를 $t 로 해석하고 name → id 로 폴백한다', () => { + const raw = JSON.stringify(tabNode); + expect(raw).toContain('{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}'); + }); +}); + +describe.each([ + ['admin_basic', ADMIN_BASIC_TAB], + ['board', BOARD_TAB], + ['ecommerce', ECOMMERCE_TAB], +])('알림 정의 탭 필터 — 조합 실평가 [%s] (#597 §14.2 T8)', (_face, layoutPath) => { + // 문자열 단언은 표현식 오타(예: !== 를 === 로)를 잡지 못한다. 레이아웃 파일에서 추출한 + // 표현식 원문을 그대로 실행해 저장값 조합별 노출 결과를 판정한다. + // + // 3면 각각을 실평가한다 — 패리티 블록(문자열 동일성)만으로 1면 평가를 3면으로 확대 + // 해석하면, 패리티 판정이 낡거나 한 면만 손댄 순간 그 면의 조합 결과가 미측정으로 + // 남는다. 세 면의 표현식이 같다면 세 벌 평가는 그 사실을 재확인할 뿐이고, 달라지는 + // 순간에만 비용이 드러난다. + const tabNode = findChannelTabIteration(readLayout(layoutPath)); + const expr = (tabNode?.iteration?.source ?? '').replace(/^\{\{|\}\}$/g, ''); + + const CHANNELS = [ + { id: 'mail', source: 'core', name: '메일' }, + { id: 'database', source: 'core', name: '사이트내 알림' }, + { id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true }, + { + id: 'alimtalk', + source: 'sirsoft-message_bizppurio', + name: '비즈뿌리오 알림톡', + tab_channels: ['sms', 'alimtalk'], + tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab', + }, + ]; + + /** + * 레이아웃의 필터 표현식 원문을 실행해 노출 탭 id 목록을 반환합니다. + * + * @param saved _local.form.notifications.channels 저장값 + * @param channels availableChannels 응답 채널 목록 (기본: 코어2 + 비즈뿌리오2) + * @returns 필터를 통과한 채널 id 배열 + */ + function visibleTabs( + saved: Array<{ id: string; is_active: boolean }>, + channels: Array> = CHANNELS, + ): string[] { + const fn = new Function('availableChannels', '_local', `return ${expr};`); + const result = fn( + { data: { channels } }, + { form: { notifications: { channels: saved } } }, + ) as Array<{ id: string }>; + return result.map((c) => c.id); + } + + it('sms·alimtalk 모두 활성 → 통합 탭 1개만 노출, sms 개별 탭은 숨김', () => { + expect(visibleTabs([ + { id: 'sms', is_active: true }, + { id: 'alimtalk', is_active: true }, + ])).toEqual(['mail', 'database', 'alimtalk']); + }); + + it('sms 만 활성(alimtalk 비활성) → tab_channels 규칙으로 통합 탭 노출 (§6.3 1d)', () => { + expect(visibleTabs([ + { id: 'sms', is_active: true }, + { id: 'alimtalk', is_active: false }, + ])).toEqual(['mail', 'database', 'alimtalk']); + }); + + it('alimtalk 만 활성 → 통합 탭 노출', () => { + expect(visibleTabs([{ id: 'alimtalk', is_active: true }])).toEqual(['mail', 'database', 'alimtalk']); + }); + + it('sms·alimtalk 모두 비활성 → 통합 탭 미노출 (§6.3 1e)', () => { + expect(visibleTabs([ + { id: 'sms', is_active: false }, + { id: 'alimtalk', is_active: false }, + ])).toEqual(['mail', 'database']); + }); + + it('확장 채널 저장값 자체가 없으면(미저장=opt-in 전) 통합 탭 미노출', () => { + expect(visibleTabs([])).toEqual(['mail', 'database']); + }); + + it('코어 채널은 저장값이 명시적 false 일 때만 숨는다 (기본 노출)', () => { + expect(visibleTabs([{ id: 'mail', is_active: false }])).toEqual(['database']); + }); + + it('tab_channels 미선언 확장 채널은 자기 자신의 활성 저장 기준으로 판정된다', () => { + const withPlain = [...CHANNELS, { id: 'push', source: 'some-plugin', name: '푸시' }]; + expect(visibleTabs([{ id: 'push', is_active: true }], withPlain)).toEqual(['mail', 'database', 'push']); + expect(visibleTabs([{ id: 'push', is_active: false }], withPlain)).toEqual(['mail', 'database']); + }); +}); + +describe('알림 정의 탭 필터 — 3면 패리티 (#597)', () => { + /** + * partial 에서 탭 필터 표현식과 라벨 표현식을 추출합니다. + * + * @param filePath partial 절대 경로 + * @returns { source, label } 두 표현식 + */ + function extractExpressions(filePath: string): { source: string; label: string } { + const node = findChannelTabIteration(readLayout(filePath)); + expect(node, `${path.basename(path.dirname(filePath))} 의 채널 탭 반복 노드를 찾지 못했다`).toBeTruthy(); + const raw = JSON.stringify(node); + // 라벨 표현식 내부에는 '}' 가 없으므로 비탐욕 매치가 전체 표현식을 정확히 얻는다 + const label = raw.match(/\{\{ch\.tab_label_key[\s\S]*?\}\}/)?.[0] ?? ''; + return { source: node!.iteration.source as string, label }; + } + + const core = extractExpressions(ADMIN_BASIC_TAB); + + it.each([ + ['게시판', BOARD_TAB], + ['이커머스', ECOMMERCE_TAB], + ])('%s partial 의 탭 필터 표현식이 admin_basic 과 문자열 동일하다', (label, file) => { + const other = extractExpressions(file); + expect(other.source, `${label} 탭 필터 표현식 불일치 — 한 면만 고치면 통합 탭이 그 면에서 사라진다`).toBe(core.source); + }); + + it.each([ + ['게시판', BOARD_TAB], + ['이커머스', ECOMMERCE_TAB], + ])('%s partial 의 탭 라벨 표현식이 admin_basic 과 문자열 동일하다', (label, file) => { + expect(core.label.length, '라벨 표현식 추출 실패 (기준점이 비면 패리티 단언이 공회전한다)').toBeGreaterThan(0); + const other = extractExpressions(file); + expect(other.label, `${label} 탭 라벨 표현식 불일치`).toBe(core.label); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json index 0b9cd425..3fe27ee9 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json @@ -139,7 +139,7 @@ "type": "basic", "name": "Button", "iteration": { - "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}", + "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.hidden_tab !== true && (c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0))}}", "item_var": "ch", "index_var": "chIdx" }, @@ -159,7 +159,7 @@ { "type": "basic", "name": "Span", - "text": "{{ch.name ?? ch.id}}" + "text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}" } ], "actions": [