diff --git a/AGENTS.md b/AGENTS.md index ad5912e2..45fae97e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,7 +155,7 @@ | 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 | -### 확장 API 레퍼런스 (13개 확장, 자동 스캔) +### 확장 API 레퍼런스 (14개 확장, 자동 스캔) > 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음). @@ -168,6 +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) | 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/CHANGELOG.md b/CHANGELOG.md index 9c7c5b72..0ad31cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,21 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [7.0.9] - 2026-08-22 +## [7.0.9] - 2026-08-24 ### Added - 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.) - 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다. +### Fixed + +- 알림 템플릿·본인인증 메시지에서 [기본값 복원]을 실행하면 언어팩이 제공한 번역(일본어 등)이 함께 지워지던 문제를 수정했습니다. 이제 복원 기본값에도 활성 언어팩의 번역이 포함되어, 복원 후에도 해당 언어 화면에 번역이 유지됩니다. 또한 이번 버전으로 업그레이드하면 이전 버전에서 이미 지워졌던 언어팩 번역이 활성 언어팩 기준으로 자동 복구됩니다. +- 설치·복구 과정의 데이터 동기화에서 코어 알림 정의와 본인인증 메시지에 언어팩 번역이 주입되지 않을 수 있던 문제를 수정했습니다. +- 언어팩 업데이트가 실패했을 때 사용 중이던 언어팩이 비활성 상태로 남아, 해당 언어의 화면·메일 문구가 안내 없이 기본 언어로 표시되던 문제를 수정했습니다. 이제 실패 시 이전 상태로 복원됩니다. +- 언어팩을 다시 활성화하거나 업데이트할 때 관리자가 직접 수정해 둔 알림·본인인증 템플릿의 해당 언어 문구가 언어팩 기본값으로 덮어써지던 문제를 수정했습니다. 이제 관리자가 수정한 언어만 보존되고, 수정하지 않은 언어는 언어팩 갱신을 그대로 따라갑니다. +- 언어팩 활성화 직후 같은 화면 흐름에서 일부 기본 데이터에 새 언어 번역이 반영되지 않던 문제를 수정했습니다. + ## [7.0.8] - 2026-08-22 ### Added diff --git a/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php b/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php index 2b39ecce..60614db2 100644 --- a/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php +++ b/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php @@ -112,11 +112,13 @@ class RunSeedersOnLanguagePackLifecycle */ private function refreshRegistry(): void { - // Singleton 캐시된 registry/injector 가 stale 활성 팩 목록을 들고 있을 수 있으므로 forget - $this->app->forgetInstance(LanguagePackRegistry::class); - $this->app->forgetInstance(LanguagePackSeedInjector::class); - + // boot 시점에 등록된 seed 필터 클로저들은 injector→registry "인스턴스"를 캡처하고 있다. + // forgetInstance 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시(활성 팩 목록)가 + // 그대로 남아, 같은 프로세스에서 활성화된 신규 팩이 코어 시더의 번역 필터에 보이지 + // 않는다 (오류 없이 해당 로케일만 미주입). 싱글톤을 유지한 채 내부 캐시만 무효화해 + // 캡처된 참조에도 전파한다 (#597 에서 실측·교정). $registry = $this->app->make(LanguagePackRegistry::class); + $registry->invalidate(); config([ 'app.supported_locales' => $registry->getActiveCoreLocales(), 'app.locale_names' => $registry->getLocaleNames(), diff --git a/app/Listeners/LanguagePack/SyncDatabaseTranslations.php b/app/Listeners/LanguagePack/SyncDatabaseTranslations.php index 2ec5bd7c..ef880a55 100644 --- a/app/Listeners/LanguagePack/SyncDatabaseTranslations.php +++ b/app/Listeners/LanguagePack/SyncDatabaseTranslations.php @@ -9,17 +9,19 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; /** - * `core.language_packs.after_activate` / `after_deactivate` 액션 훅을 받아 DB JSON 다국어 + * `core.language_packs.activated` / `deactivated` 액션 훅을 받아 DB JSON 다국어 * 컬럼을 동기화하는 thin Listener (G7 표준 HookManager::doAction → addAction 메커니즘). * * 영속/도메인 책임은 모두 LanguagePackTranslationRepository 에 위임. 본 클래스는 seed 파일 * 로딩 + Repository 호출 + 감사 로그 라우팅만 수행합니다. * - * 정책 (계획서 §6.6, §9.4): + * 정책 (계획서 §6.6, §9.4) — 보존 판정은 운영자 수정의 실제 기록 형식인 dot-path + * 항목(`"{컬럼}.{로케일}"`, HasUserOverrides updating 이벤트가 기록)을 기준으로 한다: * - Case A: 기존 row 의 다국어 JSON 에 해당 locale 키 부재 → 추가 - * - Case B: locale 키 존재 + user_overrides 에 컬럼 등록 → 건너뜀 (사용자 수정 보존) - * - Case C: locale 키 존재 + user_overrides 미등록 → 시드 값으로 덮어쓰기 - * - 비활성화/제거 시: user_overrides 등록 locale 은 JSON 에서 제거하지 않음 + * - Case B: locale 키 존재 + user_overrides 에 `컬럼.로케일` 등록 → 건너뜀 (사용자 수정 보존) + * - Case C: locale 키 존재 + 해당 dot-path 미등록 → 시드 값으로 덮어쓰기 + * (컬럼 전체 항목 `"subject"` 등은 시더 재실행 보호 선언이라 팩 병합을 막지 않는다) + * - 비활성화/제거 시: user_overrides 에 `컬럼.로케일` 등록된 locale 은 JSON 에서 제거하지 않음 */ // audit:allow listener-must-implement-hooklistenerinterface reason: LanguagePackServiceProvider 가 HookManager::addAction 으로 직접 등록하는 명시 등록 패턴 (auto-discovery 대상 아님) class SyncDatabaseTranslations diff --git a/app/Models/IdentityMessageTemplate.php b/app/Models/IdentityMessageTemplate.php index 4190b7fe..e9cd546a 100644 --- a/app/Models/IdentityMessageTemplate.php +++ b/app/Models/IdentityMessageTemplate.php @@ -30,6 +30,17 @@ class IdentityMessageTemplate extends Model 'is_active', ]; + /** + * 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록. + * (NotificationTemplate 과 동형 — 미선언의 결과는 그쪽 주석 참조, #597 에서 교정) + * + * @var array + */ + protected array $translatableTrackableFields = [ + 'subject', + 'body', + ]; + /** * 활동 로그 추적 필드. * diff --git a/app/Models/NotificationTemplate.php b/app/Models/NotificationTemplate.php index b15f53dd..72e8de99 100644 --- a/app/Models/NotificationTemplate.php +++ b/app/Models/NotificationTemplate.php @@ -27,6 +27,20 @@ class NotificationTemplate extends Model 'is_active', ]; + /** + * 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록. + * + * 미선언 시 수정이 컬럼 전체 항목(`"subject"`)으로 기록되어 시더 재실행이 컬럼을 + * 통째로 얼리고(다른 로케일 갱신·신규 팩 로케일 추가 불가), 언어팩 병합의 + * 사용자 수정 보존(Case B)도 로케일 단위로 판정할 수 없다 (#597 에서 교정). + * + * @var array + */ + protected array $translatableTrackableFields = [ + 'subject', + 'body', + ]; + /** * 모델 이벤트 등록 — 템플릿 변경 시 알림 정의 캐시 자동 삭제. */ @@ -110,13 +124,6 @@ class NotificationTemplate extends Model return $this->belongsTo(User::class, 'updated_by'); } - /** - * 특정 채널의 템플릿 조회. - * - * @param Builder $query - * @param string $channel - * @return Builder - */ /** * 수신자 규칙이 설정되어 있는지 확인합니다. * @@ -127,6 +134,13 @@ class NotificationTemplate extends Model return ! empty($this->recipients); } + /** + * 특정 채널의 템플릿 조회. + * + * @param Builder $query + * @param string $channel + * @return Builder + */ public function scopeByChannel(Builder $query, string $channel): Builder { return $query->where('channel', $channel); diff --git a/app/Repositories/LanguagePackTranslationRepository.php b/app/Repositories/LanguagePackTranslationRepository.php index 6ba7b7ec..30f731ed 100644 --- a/app/Repositories/LanguagePackTranslationRepository.php +++ b/app/Repositories/LanguagePackTranslationRepository.php @@ -41,29 +41,37 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi */ public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array { - $audit = []; - $locale = $pack->locale; + // 팩 주입은 시딩 컨텍스트다 — 바인딩 없이 저장하면 UserOverridesAwareBuilder 가 + // 주입 자체를 "사용자 수정"으로 오인해 user_overrides 를 오염시키고, 그 선언이 + // 이후 시더 재실행의 컬럼 갱신을 영구히 얼린다 (#597 에서 실측·교정). + app()->instance('user_overrides.seeding', true); + try { + $audit = []; + $locale = $pack->locale; - if (! empty($seedBundle['permissions'])) { - $this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit); - } - if (! empty($seedBundle['roles'])) { - $this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit); - } - if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) { - $this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit); - } - if (! empty($seedBundle['notifications'])) { - $this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit); - } - if (! empty($seedBundle['identity_messages'])) { - $this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit); - } - if (! empty($seedBundle['manifest'])) { - $this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit); - } + if (! empty($seedBundle['permissions'])) { + $this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit); + } + if (! empty($seedBundle['roles'])) { + $this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit); + } + if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) { + $this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit); + } + if (! empty($seedBundle['notifications'])) { + $this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit); + } + if (! empty($seedBundle['identity_messages'])) { + $this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit); + } + if (! empty($seedBundle['manifest'])) { + $this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit); + } - return $audit; + return $audit; + } finally { + app()->forgetInstance('user_overrides.seeding'); + } } /** @@ -73,6 +81,23 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi * @return array> 감사 로그 항목 */ public function stripLocaleFromPack(LanguagePack $pack): array + { + // applySeedFromPack 과 동일 — 제거도 시딩 컨텍스트 (user_overrides 오염 차단) + app()->instance('user_overrides.seeding', true); + try { + return $this->stripLocaleFromPackBody($pack); + } finally { + app()->forgetInstance('user_overrides.seeding'); + } + } + + /** + * stripLocaleFromPack 의 본문 (시딩 바인딩 하에서 실행). + * + * @param LanguagePack $pack 비활성화된 언어팩 + * @return array> 감사 로그 항목 + */ + private function stripLocaleFromPackBody(LanguagePack $pack): array { $audit = []; $locale = $pack->locale; @@ -402,7 +427,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi } $hasLocaleKey = array_key_exists($locale, $current); - $isOverridden = isset($overrides[$column]); + $isOverridden = $this->isColumnLocaleOverridden($overrides, $column, $locale); if ($hasLocaleKey && $isOverridden) { $audit[] = [ @@ -413,6 +438,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi 'locale' => $locale, 'reason' => 'user_overrides', ]; + continue; } @@ -443,7 +469,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi continue; } - if (isset($overrides[$column])) { + if ($this->isColumnLocaleOverridden($overrides, $column, $locale)) { $audit[] = [ 'action' => 'preserved', 'table' => $row->getTable(), @@ -452,6 +478,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi 'locale' => $locale, 'reason' => 'user_overrides', ]; + continue; } @@ -464,4 +491,27 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi $row->saveQuietly(); } } + + /** + * 해당 컬럼·로케일이 운영자 수정(user_overrides)으로 보존 대상인지 판정합니다. + * + * 운영자 수정의 실제 기록 형식은 dot-path 리스트(`"{column}.{locale}"` — + * HasUserOverrides 의 updating 이벤트가 기록, beta.4 가 레거시 항목을 dot-path 로 + * 전환)이므로 그 형식만 본다. 종전의 `isset($overrides[$column])` 판정은 어떤 + * 생산자도 쓰지 않는 연관 형식을 전제해 항상 false 였고, 그 결과 활성화/비활성화가 + * 운영자 수정 번역을 오류 없이 덮어쓰거나 제거했다 (#597 에서 발견·교정). + * + * 컬럼 전체 항목(`"subject"` 등)은 의도적으로 보존 사유로 보지 않는다 — 그 형식은 + * 시더 재실행(syncFromUpgrade)의 컬럼 보호 선언이며, 팩 병합까지 차단하면 팩 + * 업데이트가 자기 로케일 번역을 갱신하지 못하게 된다 (종전에도 차단된 적 없음). + * + * @param array $overrides user_overrides 리스트 + * @param string $column 다국어 JSON 컬럼명 + * @param string $locale 대상 로케일 + * @return bool 보존 대상 여부 + */ + protected function isColumnLocaleOverridden(array $overrides, string $column, string $locale): bool + { + return in_array($column.'.'.$locale, $overrides, true); + } } diff --git a/app/Services/IdentityMessageTemplateService.php b/app/Services/IdentityMessageTemplateService.php index ed882824..f690138d 100644 --- a/app/Services/IdentityMessageTemplateService.php +++ b/app/Services/IdentityMessageTemplateService.php @@ -7,6 +7,7 @@ use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface; use App\Extension\HookManager; use App\Models\IdentityMessageDefinition; use App\Models\IdentityMessageTemplate; +use App\Services\LanguagePack\LanguagePackSeedInjector; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\Auth; @@ -37,6 +38,7 @@ class IdentityMessageTemplateService private readonly IdentityMessageTemplateRepositoryInterface $repository, private readonly IdentityMessageDefinitionService $definitionService, private readonly CacheInterface $cache, + private readonly LanguagePackSeedInjector $seedInjector, ) {} /** @@ -271,11 +273,19 @@ class IdentityMessageTemplateService { $coreDefinitions = $this->loadCoreMessageDefinitions(); - return HookManager::applyFilters( + $definitions = HookManager::applyFilters( 'core.identity.filter_default_message_definitions', $coreDefinitions, [] ); + + // 활성 언어팩 seed 로케일 병합 — 시딩(seed.identity_messages.translations)과 같은 SSoT. + // 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의 + // ko/en 만으로 대체해 영구 소실시킨다 (notification 쪽 실사례의 동형 — #597 보완 실측). + // injectIdentityMessages 는 string 키('mail.purpose.signup' 등)로 seed 를 매칭하므로 + // loadCoreMessageDefinitions 가 config 키를 보존해 넘긴다. 확장 리스너가 정수 키로 + // append 한 항목은 seed 에 매칭되지 않아 그대로 통과한다. + return $this->seedInjector->injectIdentityMessages($definitions); } /** @@ -292,13 +302,14 @@ class IdentityMessageTemplateService $common = $this->commonVariables(); $result = []; - foreach ($messages as $data) { + foreach ($messages as $key => $data) { if (($data['variables'] ?? null) === '__common__') { $data['variables'] = $common; } $data['extension_type'] = 'core'; $data['extension_identifier'] = 'core'; - $result[] = $data; + // config 키('mail.provider_default' 등)를 보존 — 언어팩 seed 매칭 키와 동일 (collectDefaultDefinitions 참조) + $result[$key] = $data; } return $result; diff --git a/app/Services/LanguagePack/LanguagePackSeedInjector.php b/app/Services/LanguagePack/LanguagePackSeedInjector.php index 48121990..84b85cf4 100644 --- a/app/Services/LanguagePack/LanguagePackSeedInjector.php +++ b/app/Services/LanguagePack/LanguagePackSeedInjector.php @@ -112,7 +112,7 @@ class LanguagePackSeedInjector } foreach ($definitions as $defIdx => $def) { - $type = $def['type'] ?? null; + $type = $this->resolveDefinitionType($defIdx, $def); if (! $type || ! isset($seed[$type])) { continue; } @@ -166,7 +166,7 @@ class LanguagePackSeedInjector continue; } foreach ($definitions as $defIdx => $def) { - $type = $def['type'] ?? null; + $type = $this->resolveDefinitionType($defIdx, $def); if (! $type || ! isset($seed[$type])) { continue; } @@ -722,4 +722,30 @@ class LanguagePackSeedInjector return $config; } + + /** + * 정의 배열 항목에서 알림 type 을 해석합니다. + * + * 코어 시더(`loadConfigSeed('core.notification_definitions', ...)`)는 config 원형 + * (연관 배열 — 키가 곧 type, 항목에 type 필드 없음)을 그대로 필터에 넘기고, + * 확장 시더(ModuleManager/PluginManager)와 [기본값 복원] 경로는 각 항목에 type + * 필드를 포함한 리스트를 넘긴다. 양쪽 형태를 모두 수용한다 — + * 문자열 키가 있으면 그것이 type 이고, 아니면 항목의 type 필드를 본다. + * (형태 불일치 시 예외 없이 전 항목이 스킵되어 ja 가 조용히 주입되지 않는 + * 회귀가 있었다 — 2026-08-24 #597 보완 실측. NotificationSeedInjectionTest 가 고정.) + * + * @param int|string $defIdx 정의 배열의 키 + * @param mixed $def 정의 항목 + * @return string|null 해석된 type (해석 불가 시 null) + */ + private function resolveDefinitionType(int|string $defIdx, mixed $def): ?string + { + if (is_string($defIdx) && $defIdx !== '') { + return $defIdx; + } + + $type = is_array($def) ? ($def['type'] ?? null) : null; + + return is_string($type) && $type !== '' ? $type : null; + } } diff --git a/app/Services/LanguagePackService.php b/app/Services/LanguagePackService.php index 36c613f4..c6484156 100644 --- a/app/Services/LanguagePackService.php +++ b/app/Services/LanguagePackService.php @@ -1814,10 +1814,21 @@ class LanguagePackService } } - // 상태 복원 (DB 행이 finalizeInstall 에서 status 를 다시 쓰지 않은 경우만) + // 상태 복원 — 파일은 위에서 백업으로 원상 복원되므로 상태도 이전 상태로 되돌린다. + // Updating: 설치 트랜잭션 이전에 실패한 경우. + // Installed: 설치 트랜잭션이 installed 를 기록한 뒤(활성화 단계 이전/도중) 실패한 경우 — + // 여기서 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의 + // 백엔드 번역이 오류·로그 없이 통째로 폴백된다 (#597 보완 실측에서 유사 상태 실측). + // 활성화가 성공적으로 끝난 뒤(active) 실패한 경우는 덮어쓰지 않는다. try { $current = $this->repository->findByIdentifier($identifier); - if ($current && $current->status === LanguagePackStatus::Updating->value) { + $restorable = [ + LanguagePackStatus::Updating->value, + LanguagePackStatus::Installed->value, + ]; + if ($current + && $current->status !== $previousStatus + && in_array($current->status, $restorable, true)) { $this->repository->update($current, ['status' => $previousStatus]); } } catch (Throwable $statusError) { diff --git a/app/Services/NotificationTemplateService.php b/app/Services/NotificationTemplateService.php index 9d652331..d4855cf2 100644 --- a/app/Services/NotificationTemplateService.php +++ b/app/Services/NotificationTemplateService.php @@ -7,6 +7,7 @@ use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface; use App\Contracts\Repositories\NotificationTemplateRepositoryInterface; use App\Extension\HookManager; use App\Models\NotificationTemplate; +use App\Services\LanguagePack\LanguagePackSeedInjector; use Illuminate\Database\Eloquent\Collection; class NotificationTemplateService @@ -33,6 +34,7 @@ class NotificationTemplateService private readonly NotificationTemplateRepositoryInterface $templateRepository, private readonly NotificationDefinitionRepositoryInterface $definitionRepository, private readonly CacheInterface $cache, + private readonly LanguagePackSeedInjector $seedInjector, ) {} /** @@ -189,6 +191,11 @@ class NotificationTemplateService ['type' => $type, 'channel' => $channel] ); + // 활성 언어팩 seed 로케일 병합 — 시딩(seed.notifications.translations)과 같은 SSoT. + // 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의 + // ko/en 만으로 대체해 영구 소실시킨다 (2026-08-23 실사례 — #597 보완 실측). + $definitions = $this->mergeLanguagePackLocalesIntoDefaults($definitions); + foreach ($definitions as $def) { if ($def['type'] === $type) { foreach ($def['templates'] as $tpl) { @@ -207,6 +214,36 @@ class NotificationTemplateService return []; } + /** + * 기본 정의 배열에 활성 언어팩 seed 로케일을 병합합니다. + * + * 코어 소유 정의는 활성 코어 팩(injectNotifications), 확장 소유 정의는 + * 해당 확장의 활성 모듈/플러그인 팩(injectExtensionNotifications)이 공급한다. + * definition type 은 전역 고유(UNIQUE)이므로 확장별 호출이 다른 확장의 + * 정의에 오병합될 여지는 없다. + * + * @param array> $definitions type 필드를 포함한 기본 정의 배열 + * @return array> 활성 팩 로케일이 병합된 정의 배열 + */ + private function mergeLanguagePackLocalesIntoDefaults(array $definitions): array + { + $definitions = $this->seedInjector->injectNotifications($definitions); + + $extensionIdentifiers = []; + foreach ($definitions as $def) { + $identifier = is_array($def) ? ($def['extension_identifier'] ?? null) : null; + if (is_string($identifier) && $identifier !== '' && $identifier !== 'core') { + $extensionIdentifiers[$identifier] = true; + } + } + + foreach (array_keys($extensionIdentifiers) as $identifier) { + $definitions = $this->seedInjector->injectExtensionNotifications($definitions, $identifier); + } + + return $definitions; + } + /** * 미리보기용 렌더링. * diff --git a/database/seeders/IdentityMessageDefinitionSeeder.php b/database/seeders/IdentityMessageDefinitionSeeder.php index 2468a621..9b1bf65b 100644 --- a/database/seeders/IdentityMessageDefinitionSeeder.php +++ b/database/seeders/IdentityMessageDefinitionSeeder.php @@ -72,20 +72,25 @@ class IdentityMessageDefinitionSeeder extends Seeder * `extension_type='core'` / `extension_identifier='core'` 자동 주입. * `variables: '__common__'` 마커는 commonVariables() 로 expand. * - * @return array> + * @return array> config 복합 키('mail.purpose.signup' 등) => 정의 */ private function getDefaultDefinitions(): array { $messages = config('core.identity_messages', []); $result = []; - foreach ($messages as $data) { + foreach ($messages as $key => $data) { if (($data['variables'] ?? null) === '__common__') { $data['variables'] = $this->commonVariables(); } $data['extension_type'] = 'core'; $data['extension_identifier'] = 'core'; - $result[] = $data; + // config 복합 키('mail.purpose.signup' 등)를 보존 — 언어팩 주입 필터 + // (seed.identity_messages.translations)가 이 키로 seed 를 매칭한다. + // 리스트로 만들면 전 항목이 매칭 불가로 스킵되어 팩 로케일이 오류 없이 + // 주입되지 않는다 (#597 에서 실측·교정 — IdentityMessageTemplateService + // ::loadCoreMessageDefinitions 의 동형 보존과 짝을 이룬다). + $result[$key] = $data; } return $result; diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 72652dfb..d41c588b 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -259,6 +259,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 | | `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/docs/backend/notification-system.md b/docs/backend/notification-system.md index 4c9b0b0f..523b7477 100644 --- a/docs/backend/notification-system.md +++ b/docs/backend/notification-system.md @@ -611,6 +611,49 @@ user_id 없는 비회원(주문자 이메일/이름만 보유)도 회원과 동 - 중복 제거 키는 네임스페이스 분리: 회원 `user:{id}` / 비회원 `guest:{이메일 해시}` — null-id 게스트가 하나로 뭉개지지 않습니다. - `exclude_trigger_user` 는 회원(user_id) 기준이라 게스트 수신자를 깨뜨리지 않습니다. +### 채널 메타의 화면 표시 키 (hidden_tab · tab_channels · tab_label_key · hidden_template_editor) + +확장이 `core.notification.filter_available_channels` 훅으로 등록하는 채널 메타는 임의 필드를 보존해 프론트(`availableChannels` 데이터소스)까지 그대로 도달한다. 관리자 알림 설정 화면(코어 `admin_settings` · 게시판 `admin_board_settings` · 이커머스 `admin_ecommerce_settings`)은 다음 범용 키를 해석한다. 특정 확장 이름을 알지 못하며, 키가 없으면 종전과 동일하게 동작한다. + +| 키 | 타입 | 효과 | +| --- | --- | --- | +| `hidden_tab` | bool | 채널 서브탭만 숨긴다. 채널 토글 카드·발송 축은 그대로 | +| `tab_channels` | string[] | 이 채널의 탭이 대표하는 채널 id 목록. 목록 중 하나라도 활성 저장이면 탭을 노출한다(여러 채널을 하나의 탭으로 묶을 때) | +| `tab_label_key` | string | 탭 라벨용 프론트 lang 키(`$t` 해석). 카드 라벨(`name_key`)과 분리 | +| `hidden_template_editor` | bool | 알림 템플릿 [편집] 모달에서 코어의 언어탭·제목·본문·클릭 URL·변수 안내·[미리보기]·코어 [저장]을 숨긴다. 그 채널의 본문 규격이 코어 템플릿과 달라 확장이 편집기와 저장을 대신할 때 선언한다. 수신자 규칙·[취소]는 남는다 | + +`hidden_template_editor` 를 선언한 확장은 편집 모달의 확장 지점 두 곳에 자기 UI 를 주입한다(3면 공통 이름 — extension_point 파일 1본으로 세 화면에 주입된다). + +| extension_point | 위치 | props | +| --- | --- | --- | +| `notification_template_form_sections` | 수신자 규칙 다음, 코어 언어탭 앞 | `definition` · `template` · `channel` · `modalId` · `stateKey`(면별 코어 모달 상태 키) · `saveEndpoint`(코어 채널 템플릿 PUT) · `refetchDataSourceId`(면별 알림 정의 목록) | +| `notification_template_form_footer_actions` | [취소] 와 코어 [저장] 사이 | 위와 동일 | + +확장은 이 props 만으로 코어 저장 계약을 이행한다 — 코어 엔드포인트·상태 키를 리터럴로 적지 않는다. 코어 채널 템플릿의 수신자 규칙은 `_global?.[extensionPointProps.stateKey]?.recipients` 로 읽어 바뀐 경우에만 `saveEndpoint` 로 PUT 한다(매번 PUT 하면 코어가 행을 "사용자 수정" 으로 표시한다). 선례: `plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_*.json`. 행 하단 확장 지점 `notification_definition_row_footer`(props `definition` · `activeChannel`)는 상태 요약 같은 읽기 전용 UI 용이다. + +### 정의 타입 라벨의 공급처 + +관리자 알림 설정 화면의 정의 목록 제목은 DB `name` 컬럼이 아니라 프론트 다국어 키 +`admin.settings.notification_definitions.types.{type}` 로 렌더된다. 이 키의 공급처는 정의 소유자를 따른다. + +| 정의 소유 | 라벨 위치 | +| --- | --- | +| 코어 (`config/core.php` `notification_definitions`) | admin 템플릿 `lang/partial/{ko,en}/admin.json` 의 `settings.notification_definitions.types` (ja 는 템플릿 언어팩) | +| 모듈 | 각 모듈의 알림 설정 화면과 lang 이 담당 | + +키가 없으면 예외도 경고도 없이 원시 키 문자열이 화면 제목으로 그대로 노출된다. 코어 정의를 +추가하는 변경은 반드시 admin 템플릿 라벨(ko·en)과 템플릿 언어팩(ja)을 함께 추가해야 하며, +코어 정의 전수 ↔ 템플릿 라벨 패리티는 정적 검사(테스트)가 자동 대조한다. + +## 채널 템플릿을 시드하는 확장의 [기본값 복원] 의무 + +확장이 시딩 필터(`seed.notifications.translations` 등)로 자기 채널의 템플릿 행을 알림 정의에 끼워 넣으면, 같은 증강을 +`core.notification.filter_default_definitions` 에도 걸어야 한다. 관리자 화면의 [기본값 복원]은 +`NotificationTemplateService::getDefaultTemplateData()` 가 이 필터를 통과시킨 정의 배열에서 해당 채널의 template 을 찾아 +복원하므로, 시드 출처와 복원 출처가 같은 함수가 아니면 그 채널 행의 복원은 "기본 템플릿 데이터를 찾을 수 없습니다" 로 끝난다 +(시드는 되는데 복원만 안 되는 상태라 오류 로그도 남지 않는다). 모듈이 자기 정의를 이 필터에 보태는 priority(20) 뒤에서 돌아야 +모듈 정의도 증강된다. 선례: `plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php`. + ### 채널별 비회원 발송 허용 (allow_guest) 채널이 비회원 발송을 허용하는지는 `config/notification.php` 채널 메타의 `allow_guest` 로 선언합니다. diff --git a/docs/extension/language-packs.md b/docs/extension/language-packs.md index 49529cce..0fa7678c 100644 --- a/docs/extension/language-packs.md +++ b/docs/extension/language-packs.md @@ -287,6 +287,48 @@ foreach ($config['categories'] as $cat) { ... } - `permissions.json` → `{scope}.{target}.permissions.translations` 필터 (3-레벨 트리: module/categories/permissions) - 기타 → `seed.{target}.{entity}.translations` 필터 (단순 entity 시드) +### 복원 경로의 언어팩 병합 + +[기본값 복원](알림 템플릿·본인인증 메시지)은 위 시딩 필터가 아니라 자체 기본값 필터 +(`core.notification.filter_default_definitions` / `core.identity.filter_default_message_definitions`)를 +탄다. 복원 기본값에는 **활성 언어팩 seed 로케일이 반드시 병합**되어야 한다 — 병합하지 않으면 +복원이 팩이 주입해 둔 로케일(ja 등)을 config 의 ko/en 만으로 대체해 영구 소실시키며, +오류도 로그도 남지 않는다. 코어 서비스(NotificationTemplateService·IdentityMessageTemplateService)가 +필터 적용 후 시딩과 같은 주입기(SSoT)로 병합하며, 정적 검사(테스트)가 이 계약을 고정한다. + +시딩 필터의 페이로드는 **발화 주체의 원형 키를 보존**해야 한다 — 코어 알림 시더는 config +원형(연관 배열, 키가 곧 type), 본인인증은 복합 키(`mail.purpose.signup` 등)가 매칭 키다. +키를 버리고 리스트로 만들면 주입기가 전 항목을 스킵해 팩 로케일이 오류 없이 주입되지 않는다. +알림 주입기는 두 형태(문자열 키 우선, 없으면 type 필드)를 수용한다. + +### 사용자 수정 보존 판정과 시딩 컨텍스트 + +언어팩 병합·제거의 사용자 수정 보존 판정은 운영자 수정의 실제 기록 형식인 **dot-path 항목** +(`user_overrides` 의 `"{컬럼}.{로케일}"`)을 기준으로 한다. 컬럼 전체 항목(`"subject"`)은 시더 +재실행의 컬럼 보호 선언이며 팩 병합을 막지 않는다 — 막으면 팩 업데이트가 자기 로케일 번역을 +갱신하지 못한다. + +이를 위해 두 가지가 함께 지켜져야 한다: + +- 다국어 JSON 컬럼을 추적하는 모델은 `translatableTrackableFields` 를 선언한다 — 미선언 시 + 운영자 수정이 컬럼 전체 항목으로 기록되어 로케일 단위 보존이 불가능해진다. +- 언어팩 주입·제거처럼 시스템이 수행하는 저장은 시딩 컨텍스트(`user_overrides.seeding` 바인딩) + 안에서 실행한다 — 바인딩 없이 저장하면 주입 자체가 "사용자 수정"으로 오인 기록되어 이후 + 시더 재실행의 컬럼 갱신이 영구히 얼어붙는다. + +### 실패한 업데이트의 상태 복원 + +`language-pack:update` 가 설치 트랜잭션 이후(활성화 단계)에 실패하면 팩 상태를 이전 상태로 +복원한다. 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의 백엔드 번역이 +안내 없이 기본 로케일로 폴백된다. + +### 같은 프로세스 활성화와 필터 인스턴스 + +시더 번역 필터 클로저는 boot 시점의 injector→registry **인스턴스**를 캡처한다. 활성 팩 목록 +캐시를 갱신할 때는 싱글톤을 유지한 채 `LanguagePackRegistry::invalidate()` 로 내부 캐시만 +비운다 — `forgetInstance` 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시가 남아, 같은 +프로세스에서 활성화된 신규 팩이 시더 필터에 보이지 않는다(오류 없이 해당 로케일만 미주입). + ### IDV 도메인 lang pack 커버리지 `config/core.php` 의 본인인증 SSoT 4개 블록 중 lang pack seed 대상은 1개: @@ -450,7 +492,20 @@ G7 는 일본어 번들 언어팩 12종을 공식 제공합니다. ko 원본을 - 통화/숫자 단위 변환 금지 (런타임 처리) - 공식체(です·ます) 강제, 친근체 금지 - 한글 미포함 value(영문/숫자/기호만) 는 번역 스킵 → 원문 유지 -- 고정 용어집 50항목으로 일관성 강제 (예: `관리자 → 管理者`, `장바구니 → カート`) +- 고정 용어집으로 일관성 강제 (예: `관리자 → 管理者`, `장바구니 → カート`) + +### 용어집 등록 의무 + +번역 결과의 표기는 고정 용어집이 정한다. 용어집에 없는 도메인 용어는 어간만 번역되어 +**원본 로케일 문자가 값에 그대로 남는다** — 예외도 오류도 나지 않고, 키 대칭 검사도 값은 보지 않으므로 +그 로케일로 화면을 열기 전까지 드러나지 않는다. + +- 새 도메인 용어를 도입하면 번역 빌드 **전에** 용어집에 등록한다 +- 이미 생성된 값을 손으로 고쳤다면 용어집도 함께 고친다 — 용어집을 두면 다음 키에서 같은 오역이 되풀이된다 +- 같은 용어는 팩 전체에서 한 표기로 통일한다 (용어집이 단일 출처) + +정적 검사가 번역 값의 원본 로케일 문자 잔존을 검출한다. 매니페스트와 CHANGELOG, +그리고 한국어 주석은 대상이 아니다. ### 설치 및 활성화 diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index e4b0904f..7109d3b3 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.8] - 2026-08-22 +## [1.0.8] - 2026-08-24 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 1e790bd5..5e0cb145 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,13 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.3] - 2026-08-24 + +### Fixed + +- 배송업체 이름(한진·롯데·로젠)이 일본어 화면에서 "택배" 부분만 한국어로 표시되던 문제를 수정했습니다. 관리자 배송정책 화면과 초기 배송업체 데이터 모두 일본어 표기로 통일했습니다. +- 모듈 소개 문구의 제품명이 한국어로 표시되던 문제를 수정했습니다. + ## [1.1.2] - 2026-08-22 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json index 74d49a26..c741e2fd 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json @@ -16,9 +16,9 @@ }, "carrier": { "cj": "CJ大韓通運", - "hanjin": "韓進택배", - "lotte": "ロッテ택배", - "logen": "ロジェン택배", + "hanjin": "ハンジン宅配", + "lotte": "ロッテ宅配", + "logen": "ロジェン宅配", "ups": "UPS", "fedex": "FedEx", "dhl": "DHL", diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index 297a61d3..44b5c565 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.1.2", + "version": "1.1.3", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/manifest.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/manifest.json index cfc77ecc..9c62bbf9 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/manifest.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/manifest.json @@ -1,4 +1,4 @@ { "name": "eコマース", - "description": "그누보드7 eコマース モジュール - 商品、注文、決済管理" + "description": "グヌボード7 eコマース モジュール - 商品、注文、決済管理" } diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/shipping_carriers.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/shipping_carriers.json index d286b74c..e4b536e8 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/shipping_carriers.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/shipping_carriers.json @@ -3,13 +3,13 @@ "name": "CJ大韓通運" }, "hanjin": { - "name": "韓進택배" + "name": "ハンジン宅配" }, "lotte": { - "name": "ロッテ택배" + "name": "ロッテ宅配" }, "logen": { - "name": "ロジェン택배" + "name": "ロジェン宅配" }, "ups": { "name": "UPS" 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 new file mode 100644 index 00000000..001e936e --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +이 언어팩의 모든 주요 변경사항을 기록합니다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. + +## [1.0.0] - 2026-08-24 + +### Added + +- 비즈뿌리오 메시지 발송 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩 초기 제공 +- 관리자 환경설정 문구(비즈뿌리오 아이디·비밀번호·발신번호·알림톡 발신프로필키·결과 수신 주소, 검수 모드와 운영 전환 경고)의 일본어 번역 +- 알림 편집 창에 통합된 알림톡 템플릿·문자(SMS) 섹션의 제목·안내 문구와 승인 여부 배지(승인됨 / 미승인)의 일본어 번역 +- 검수자 전달 의견 입력란의 라벨·안내 문구의 일본어 번역 +- 알림톡 템플릿 작성·검수 신청·승인 관리 화면 문구의 일본어 번역 +- 템플릿 통합 관리 화면과 발송 이력 화면 문구의 일본어 번역 +- 발송 결과 안내 문구의 일본어 번역 +- 템플릿 작성 시 입력값 오류 안내의 일본어 번역 — 항목 이름까지 일본어로 표시됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php new file mode 100644 index 00000000..61dc2267 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php @@ -0,0 +1,6 @@ + [], + 'description' => [], +]; 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 new file mode 100644 index 00000000..432bcd5f --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php @@ -0,0 +1,160 @@ + [ + 'sms' => 'SMS', + 'lms' => 'LMS', + 'alimtalk' => 'アラートトーク', + ], + 'channel_group' => [ + 'text' => '文字', + 'alimtalk' => '通知トーク', + ], + 'status' => [ + 'pending' => '待機中', + 'sent' => '送信中', + 'success' => '成功', + 'failed' => '失敗', + ], + 'source' => [ + 'auto' => '自動', + 'manual' => '手動', + 'bulk' => '一括', + ], + 'result_category' => [ + 'success' => '成功', + 'retry' => '再試行', + 'permanent_failure' => '永久失敗', + 'balance_low' => '残高不足', + ], + 'channels' => [ + 'source_label' => 'ビズプリオ', + 'sms' => [ + 'name' => 'ビズプリオ 文字', + 'description' => 'ビズプリオを通じてテキスト(SMS/LMS)で通知を送信します。', + ], + 'alimtalk' => [ + 'name' => 'ビズプリオ アラートトーク', + 'description' => 'ビズプリオを通じてカカオアラートトークで通知を送信します。', + ], + ], + 'readiness' => [ + 'sms_credentials_missing' => 'ビズプリオのアイディとパスワードを設定してください。', + 'sms_sender_number_missing' => '発信番号を設定してください。', + 'alimtalk_api_key_missing' => 'カカオ管理API キーを設定してください。', + 'alimtalk_sender_key_missing' => 'アラートトーク発信プロフィールキーを設定してください。', + ], + 'settings' => [ + 'bizppurio_id_attribute' => 'ビズプリオアイディ', + 'password_attribute' => 'パスワード', + 'sender_number_attribute' => '発信番号', + ], + 'webhook' => [ + 'received' => 'レポートを受け取りました。', + ], + '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' => 'アラートトーク発信プロフィールキーを先に設定してください。', + 'image_unreadable' => 'アップロードした画像ファイルを読み込めません。', + ], + 'send_skipped' => [ + 'alimtalk_template_not_approved' => '承認されたアラートトークテンプレートがないか、アラートトーク送信がオフになっているため送信をスキップしました。(通知タイプ: :type)', + 'sms_template_missing' => 'SMS本文が設定されていないため送信をスキップしました。(通知タイプ: :type)', + 'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)', + 'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)', + ], + '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' => 'テンプレートコードを採番できませんでした。しばらくしてからもう一度お試しください。', + ], + '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' => '本文', + 'comment' => '審査担当者への伝達事項', + '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とパスワードが正しいです。', + 'failed' => '認証の確認に失敗しました。詳細な理由をご確認ください。', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php new file mode 100644 index 00000000..8309d4c5 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php @@ -0,0 +1,55 @@ + '成功', + '2000' => 'メッセージが無効です', + '3001' => '認証情報が無効です(Basic)', + '3002' => 'トークンが無効です(期限切れ·廃止)', + '3003' => 'IPが無効です', + '3004' => 'アカウントが無効です', + '3005' => '認証情報が無効です(Bearer)', + '3006' => 'アカウントが存在しません', + '3007' => 'アカウントパスワードが無効です', + '3009' => 'アカウントが停止状態です', + '3010' => 'アクセス許可IPが一致しません', + '3011' => '不明なエラー(bizppurio)', + '3013' => '完了処理されていないメッセージ', + '4100' => '成功', + '4400' => '電波の弱い地域', + '4401' => '電源オフ', + '4402' => 'ストレージ超過', + '4410' => '不正な番号', + '4414' => '結番·停止', + '4420' => 'その他端末エラー', + '4430' => 'スパム', + '4431' => '送信制限受信拒否(スパム)', + '4443' => 'スパムブロック', + '5002' => 'リクエストが多すぎます', + '5003' => '一時的な送信エラー', + '5004' => '一時的な送信エラー', + '5005' => '一時的な送信エラー', + '6600' => '成功', + '6603' => '電波の弱い地域', + '6604' => '電源オフ', + '6606' => '不正な番号', + '6621' => 'メッセージ長超過', + '6641' => 'スパムブロック', + '7000' => '成功', + '7103' => '発信プロフィールキーが無効です', + '7106' => '削除された発信キー', + '7107' => 'ブロックされた発信キー', + '7204' => 'メッセージ内容がテンプレートと不一致です', + '7206' => 'シリアルナンバー形式が不一致です', + '7306' => 'カカオシステムエラー', + '7307' => '処理遅延', + '7308' => '電話番号エラー', + '7320' => '受信ブロック', + '7325' => '変数長超過', + '7421' => 'タイムアウト', + '7436' => 'ウォレット残高不足(アラートトーク)', + '7437' => 'メッセージリクエスト失敗', + '7523' => '080受信拒否(スパム)', + '9000' => '一時的なシステムエラー', + '9070' => '残高不足(SMS)', + '9071' => '後払い限度額超過', +]; 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 new file mode 100644 index 00000000..d7360f5e --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json @@ -0,0 +1,304 @@ +{ + "name": "ビズプリオ メッセージ発送", + "description": "ビズプリオ 連動 SMS/LMS・カカオ アラートトーク 発送プラグインです。", + "settings": { + "title": "ビズプリオ メッセージ発送 設定", + "description": "ビズプリオ 連動情報と発送 設定を管理します。", + "save": "保存", + "saving": "保存 中...", + "save_success": "設定が保存されました。", + "save_failed": "設定 保存に失敗しました。", + "test_mode": { + "label": "検査モード", + "hint": "検査モードでは ビズプリオ 検査ドメインで発送されます。オフにすると運用ドメインで実際に発送されます。", + "account_notice": "検査と運用は別の ビズプリオ アカウントで運営することをお勧めします。" + }, + "live_mode_warning_title": "運用環境で発送されます", + "live_mode_warning_body": "検査モードがオフになっているため、実際の顧客にSMS・アラートトークが発送され、発送費用が請求されます。検査段階では検査モードをオンにしてください。", + "sections": { + "api": { + "title": "API 連動", + "description": "発送システムとカカオ管理システムに共通に使用する連動情報です。" + }, + "sending": { + "title": "発送 設定", + "description": "文字・アラートトーク発送に使用する発信情報です。" + } + }, + "fields": { + "bizppurio_id": { + "label": "ビズプリオ ID", + "hint": "発送・カカオ管理に共通に使用する ビズプリオ ID です。" + }, + "password": { + "label": "ビズプリオモジュールパスワード", + "hint": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)" + }, + "connection_check": { + "label": "接続確認", + "hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)", + "button": "接続確認", + "checking": "確認中...", + "success": "認証が正常に確認されました。IDとパスワードが正しいです。", + "failed": "認証確認に失敗しました。", + "unsaved_changes": "変更内容を先に保存してください。" + }, + "api_key": { + "label": "API キー", + "hint": "API キーは ビズプリオ カスタマーセンターに ID とともに申し込むと確認後、発行されます。" + }, + "sender_number": { + "label": "発信番号", + "hint": "文字・アラートトーク発送に使用する発信電話番号です。" + }, + "sender_key": { + "label": "アラートトーク 発信プロフィール キー", + "hint": "アラートトーク 発送・テンプレート確認に使用する発信プロフィール キー(40文字)です。" + } + }, + "report": { + "section_title": "レポート受信 設定", + "hint": "ビズプリオ は文字・アラートトーク発送後、成功/失敗の結果をこのアドレスに転送(URL PUSH)するレポート受信方式を提供します。以下のアドレスを ビズプリオ ビジネスチーム(または管理コンソールのレポート受信 設定)に登録してください。", + "note": "登録すると発送結果が履歴に自動記録され、実際に到達したかどうかを確認できます。登録前は発送リクエストまで確認され、最終結果(成功/失敗)は更新されません。", + "copy": "コピー", + "copied": "レポート受信アドレスがクリップボードにコピーされました。" + }, + "tabs": { + "connection": "環境設定", + "templates": "通知テンプレート管理" + }, + "preparation": { + "intro": "文字・カカオ アラートトークを発送するには、まず ビズプリオ コンソールで以下の事前準備を完了する必要があります。", + "sms_label": "文字(SMS/LMS)", + "sms_sender": "発信番号を登録してください。", + "kakao_label": "カカオ アラートトーク", + "kakao_channel": "カカオトーク ビジネスチャネルを作成し、発信プロフィールを登録してください。", + "kakao_template": "アラートトークテンプレートは管理者の通知設定(ビズプリオタブ)で作成して審査を申請します。", + "kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。", + "console_link": "ビズプリオ コンソールを開く" + } + }, + "channels": { + "bizppurio_tab": "ビズプリオ" + }, + "binding": { + "variables_hint": "提供変数(クリックすると本文のカーソル位置に挿入されます)" + }, + "banner": { + "not_ready": "発送に必要な 設定が完了していません。", + "setup_action": "設定する", + "test_mode": "検査モードです — 実際の発送は行われません。" + }, + "dispatch_result": { + "column_header": "文字・アラートトーク 結果", + "inspection_label": "検査", + "low_balance": "残高不足", + "fallback": "SMS 代替発送 {status}", + "detail_title": "文字・アラートトーク 発送 結果", + "channel_label": "発送チャネル: {channel}", + "sent_content_label": "実際の発送内容", + "sent_content_hint": "アラートトーク は承認確定されたテンプレート内容で発送され、上記の\"本文\"と異なる場合があります。" + }, + "editor": { + "data_source": { + "dispatch_results": "ビズプリオ送信結果" + } + }, + "template": { + "tab_guide": "各通知の[編集]でアラートトークのテンプレートとSMS本文をまとめて作成し、審査を申請してください。カカオの承認後にアラートトークが自動送信され、代替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本文を編集", + "sms_label": "SMS", + "on": "使用", + "off": "未使用", + "alimtalk_disabled": "送信停止中" + }, + "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": "保存して審査申請", + "request_comment": "審査担当者への伝達事項", + "request_comment_placeholder": "例: #{name}=山田太郎, #{order_number}=20260823-0001", + "request_comment_hint": "審査申請時にカカオの審査担当者へ一緒に送られます(任意・500文字以内)。カカオの審査ガイドは本文の変数ごとに例示値を求めるため、変数ごとの例を書いておくと差し戻しを減らせます。保存はされず、申請時にのみ送信されます。", + "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": "閉じる" + }, + "approval": { + "approved": "承認済み", + "not_approved": "未承認" + }, + "section": { + "alimtalk_title": "アラートトークテンプレート", + "sms_title": "SMS", + "alimtalk_enabled": "アラートトーク送信を使用", + "locked_hint": "審査中または承認済みのテンプレートは内容を変更できません。変更するには先に[申請取消]または[編集(承認取消)]を押してください。", + "saved_toast": "通知テンプレートを保存しました。", + "loading": "テンプレートを読み込み中…" + } + }, + "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": { + "readiness": { + "title": "アラートトークテンプレートを使用するには、以下の設定が必要です", + "go_settings": "環境設定に移動", + "missing_label": "未設定項目", + "api_key_missing": "カカオ管理API キー", + "sender_key_missing": "アラートトーク送信プロフィールキー", + "note": "上記項目を環境設定タブで入力すると、テンプレートの作成・審査申請が可能になります。実際の送信は検査モードをオフにして運用に切り替えた後、承認されたテンプレートのみ使用できます。" + }, + "list": { + "refresh": "更新" + }, + "link_type": { + "WL": "ウェブリンク", + "AL": "アプリリンク", + "DS": "配送追跡", + "BK": "ボットキーワード", + "MD": "メッセージ転送", + "AC": "チャネル追加", + "BC": "相談トーク転換", + "BT": "ボット転換", + "TN": "電話をかける", + "MP": "地図表示", + "P1": "画像セキュア送信", + "P2": "個人情報利用", + "P3": "ワンクリック決済" + } + } +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json new file mode 100644 index 00000000..942bb45f --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json @@ -0,0 +1,30 @@ +{ + "identifier": "g7-plugin-sirsoft-message_bizppurio-ja", + "namespace": "g7", + "vendor": "sirsoft", + "name": { + "ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩", + "en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack", + "ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック" + }, + "description": { + "ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩 (번들)", + "en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack (bundled)", + "ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック(バンドル)" + }, + "version": "1.0.0", + "license": "MIT", + "scope": "plugin", + "target_identifier": "sirsoft-message_bizppurio", + "locale": "ja", + "locale_name": "Japanese", + "locale_native_name": "日本語", + "text_direction": "ltr", + "g7_version": ">=7.0.0-beta.4", + "requires": { + "target_version": null, + "depends_on_core_locale": true + }, + "github_url": "", + "github_changelog_url": "" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json new file mode 100644 index 00000000..8d37fc29 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "Bizppurio メッセージ発送", + "description": "Bizppurio 連動 SMS/LMS・カカオ アラート トーク発送プラグインです。コア通知システムチャネルとして文字・アラートトークを発送し、発送結果を webhook で受信します。" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json new file mode 100644 index 00000000..950f445d --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json @@ -0,0 +1,18 @@ +{ + "bizppurio_balance_low": { + "definition": { + "name": "Bizssprrio残高不足", + "description": "Bizssprrio ウォレット残高が不足しており、SMS/アラートトーク送信に失敗した場合、管理者に送信されます" + }, + "templates": { + "mail": { + "subject": "[{app_name}] Bizssprrio 残高が不足しています", + "body": "{name}様、Bizssprrio ウォレット残高が不足しているため、{channel_label} 送信に失敗しました (コード: {result_code})。チャージ後、送信が正常化されます。" + }, + "database": { + "subject": "Bizssprrio残高不足", + "body": "Bizssprrio残高不足により、{channel_label} 送信に失敗しました (コード: {result_code})。" + } + } + } +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json new file mode 100644 index 00000000..db0ca18e --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json @@ -0,0 +1,18 @@ +{ + "sirsoft-message_bizppurio": { + "name": "ビズプリオ メッセージ発送", + "description": "ビズプリオ メッセージ発送プラグインが提供する権限" + }, + "sirsoft-message_bizppurio.messaging": { + "name": "メッセージ発送", + "description": "メッセージ発送ドメイン権限 (閲覧·管理)" + }, + "sirsoft-message_bizppurio.messaging.view": { + "name": "メッセージ閲覧", + "description": "発送履歴·アラートトークテンプレート閲覧 (モニタリング)" + }, + "sirsoft-message_bizppurio.messaging.manage": { + "name": "メッセージ管理", + "description": "環境設定·アラートトークテンプレート登録/検証·イベント連動管理" + } +} diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index dab376af..5209359c 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -4,11 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.7] - 2026-08-22 +## [1.0.7] - 2026-08-24 ### Added - 환경설정 SEO 탭의 기본 공유 이미지(og:image) 라벨·설명 번역을 추가했습니다. +- 알림 설정 화면의 「사이트맵 재생성 완료」・「사이트맵 재생성 실패」 알림 이름 번역을 추가했습니다. ## [1.0.6] - 2026-08-22 diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index d6435cd2..83ae197c 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1862,7 +1862,9 @@ "types": { "welcome": "会員登録ウェルカム", "reset_password": "パスワード再設定", - "password_changed": "パスワード変更通知" + "password_changed": "パスワード変更通知", + "sitemap_regenerated": "サイトマップ再生成完了", + "sitemap_regenerate_failed": "サイトマップ再生成失敗" }, "channels_label": "チャネル:", "edit_modal": { diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md index 4dadeb0c..b8efdd12 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.2] - 2026-08-24 + +### Fixed + +- 첫 화면 소개 문구의 제품명이 한국어로 표시되던 문제를 수정했습니다. + ## [1.1.1] - 2026-08-19 ### Added diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/home.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/home.json index b3117745..f4647833 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/home.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/home.json @@ -1,7 +1,7 @@ { "welcome": { "title": "いらっしゃいませ!", - "description": "그누보드7 コミュニティへようこそ。", + "description": "グヌボード7 コミュニティへようこそ。", "login_prompt": "ログインしてコミュニティに参加してください。", "greeting": "こんにちは、{{name}}さん!" }, diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json index 70dc6acc..40396a18 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 template (sirsoft-basic) Japanese language pack (bundled)", "ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)" }, - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "scope": "template", "target_identifier": "sirsoft-basic", diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 0d390800..06a07438 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -4,13 +4,18 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.0] - 2026-08-22 +## [1.1.0] - 2026-08-24 ### Added - 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.) - 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다. +### Changed + +- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다. +- 게시판 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다. + ## [1.0.5] - 2026-08-22 ### Security 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/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts new file mode 100644 index 00000000..6f3604c5 --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts @@ -0,0 +1,138 @@ +// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio) +// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다. +/** + * 게시판 알림 템플릿 편집 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (코어 admin_basic 모달과 동형, 면 식별자만 다름) + * + * 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록: + * - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞) + * - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이) + * - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다 + * (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일. + * + * 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은 + * 기대값을 함께 고치면 통과한다. + * + * @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections + */ + +import { describe, it, expect } from 'vitest'; +import modal from '../../../layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json'; + +type Node = Record; +const PREFIX = 'board_'; +const STATE_KEY = 'board_notification_template_form_modal'; +const MODAL_ID = 'modal_board_notification_template_edit'; +const REFETCH_ID = 'boardNotificationDefinitions'; + +const findById = (node: unknown, id: string): Node | null => { + if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; } + if (node && typeof node === 'object') { + if ((node as Node).id === id) return node as Node; + for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; } + } + return null; +}; +const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => { + if (Array.isArray(node)) { + const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id); + if (idx >= 0) return { arr: node, idx }; + for (const n of node) { const r = findParentArray(n, id); if (r) return r; } + return null; + } + if (node && typeof node === 'object') { + for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; } + } + return null; +}; +const evalIf = (expr: string, scope: Record): boolean => { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + const names = Object.keys(scope); + // eslint-disable-next-line no-new-func + return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n]))); +}; +const scope = (channel: string, hidden: boolean | undefined, extra: Record = {}) => ({ + availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } }, + _global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } }, +}); + +const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId']; + +describe('게시판 알림 템플릿 편집 모달 — 확장 슬롯 2종', () => { + it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => { + const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`); + expect(loc).toBeTruthy(); + const { arr, idx } = loc!; + expect(arr[idx].type).toBe('extension_point'); + expect(arr[idx].name).toBe('notification_template_form_sections'); + expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`); + expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`); + }); + + it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const right = footer.children[1].children as Node[]; + expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']); + expect(right[1].name).toBe('notification_template_form_footer_actions'); + expect(JSON.stringify(right[0])).toContain('$t:common.cancel'); + expect(JSON.stringify(right[2])).toContain('$t:common.save'); + }); + + it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => { + const ep = findById(modal, id)!; + expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort()); + expect(ep.props.modalId).toBe(MODAL_ID); + expect(ep.props.stateKey).toBe(STATE_KEY); + expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID); + expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`); + expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`); + expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`); + expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`); + }); +}); + +describe('게시판 알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => { + const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info']; + + it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node.if, `${id} if`).toBeTruthy(); + expect(evalIf(node.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(node.if, scope('mail', undefined))).toBe(true); + expect(evalIf(node.if, scope('sms', false))).toBe(true); + }); + + it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_click_url_input`)!; + expect(evalIf(node.if, scope('database', undefined))).toBe(true); + expect(evalIf(node.if, scope('mail', undefined))).toBe(false); + expect(evalIf(node.if, scope('database', true))).toBe(false); + }); + + it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_variables_info`)!; + const noVars = { ...scope('mail', undefined) } as Node; + noVars._global[STATE_KEY].definition = { variables: [] }; + expect(evalIf(node.if, noVars)).toBe(false); + }); + + it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const preview = footer.children[0].children[0] as Node; + const right = footer.children[1].children as Node[]; + const cancel = right[0]; + const save = right[2]; + for (const b of [preview, save]) { + expect(evalIf(b.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(b.if, scope('mail', undefined))).toBe(true); + } + expect(cancel.if).toBeUndefined(); + }); + + it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => { + for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node, id).toBeTruthy(); + expect(String(node.if ?? '')).not.toContain('hidden_template_editor'); + } + }); +}); diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json index 830721d2..2cd46c2c 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json @@ -500,8 +500,7 @@ ] } ], - "props": { - } + "props": {} } ] } @@ -528,6 +527,21 @@ } ] }, + { + "id": "board_template_form_sections_ext", + "comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.", + "type": "extension_point", + "name": "notification_template_form_sections", + "props": { + "definition": "{{_global.board_notification_template_form_modal?.definition}}", + "template": "{{_global.board_notification_template_form_modal?.template}}", + "channel": "{{_global.board_notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_board_notification_template_edit", + "stateKey": "board_notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.board_notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "boardNotificationDefinitions" + } + }, { "id": "board_template_lang_tabs", "type": "basic", @@ -559,7 +573,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "board_template_subject_input", @@ -613,7 +628,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "board_template_body_input", @@ -668,13 +684,14 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "board_template_click_url_input", "type": "basic", "name": "Div", - "if": "{{_global.board_notification_template_form_modal?.template?.channel === 'database'}}", + "if": "{{(_global.board_notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "children": [ { "type": "basic", @@ -724,7 +741,7 @@ "id": "board_template_variables_info", "type": "basic", "name": "Div", - "if": "{{(_global.board_notification_template_form_modal?.definition?.variables ?? []).length > 0}}", + "if": "{{((_global.board_notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "props": { "className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4" }, @@ -841,17 +858,18 @@ "target": "modal_board_notification_template_preview" } ], - "onError": [ - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{error.message}}" - } - } - ] + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] }, @@ -879,6 +897,21 @@ } ] }, + { + "id": "board_template_form_footer_ext", + "comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.", + "type": "extension_point", + "name": "notification_template_form_footer_actions", + "props": { + "definition": "{{_global.board_notification_template_form_modal?.definition}}", + "template": "{{_global.board_notification_template_form_modal?.template}}", + "channel": "{{_global.board_notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_board_notification_template_edit", + "stateKey": "board_notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.board_notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "boardNotificationDefinitions" + } + }, { "type": "basic", "name": "Button", @@ -978,7 +1011,8 @@ ] } } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] } 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..68641e77 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,12 +4,17 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.2.0] - 2026-08-22 +## [1.2.0] - 2026-08-24 ### Added - 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.) +### Changed + +- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다. +- 이커머스 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다. + ### Fixed - 카테고리 페이지의 공유 미리보기 이미지(og:image)가 어떤 경우에도 채워지지 않던 문제를 수정했습니다. 이제 카테고리에 등록된 첫 이미지가 사용됩니다. 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/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts new file mode 100644 index 00000000..5bde5a12 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts @@ -0,0 +1,138 @@ +// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio) +// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다. +/** + * 이커머스 알림 템플릿 편집 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (코어 admin_basic 모달과 동형, 면 식별자만 다름) + * + * 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록: + * - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞) + * - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이) + * - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다 + * (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일. + * + * 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은 + * 기대값을 함께 고치면 통과한다. + * + * @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections + */ + +import { describe, it, expect } from 'vitest'; +import modal from '../../../layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json'; + +type Node = Record; +const PREFIX = 'ecommerce_'; +const STATE_KEY = 'ecommerce_notification_template_form_modal'; +const MODAL_ID = 'modal_ecommerce_notification_template_edit'; +const REFETCH_ID = 'ecommerceNotificationDefinitions'; + +const findById = (node: unknown, id: string): Node | null => { + if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; } + if (node && typeof node === 'object') { + if ((node as Node).id === id) return node as Node; + for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; } + } + return null; +}; +const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => { + if (Array.isArray(node)) { + const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id); + if (idx >= 0) return { arr: node, idx }; + for (const n of node) { const r = findParentArray(n, id); if (r) return r; } + return null; + } + if (node && typeof node === 'object') { + for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; } + } + return null; +}; +const evalIf = (expr: string, scope: Record): boolean => { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + const names = Object.keys(scope); + // eslint-disable-next-line no-new-func + return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n]))); +}; +const scope = (channel: string, hidden: boolean | undefined, extra: Record = {}) => ({ + availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } }, + _global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } }, +}); + +const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId']; + +describe('이커머스 알림 템플릿 편집 모달 — 확장 슬롯 2종', () => { + it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => { + const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`); + expect(loc).toBeTruthy(); + const { arr, idx } = loc!; + expect(arr[idx].type).toBe('extension_point'); + expect(arr[idx].name).toBe('notification_template_form_sections'); + expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`); + expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`); + }); + + it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const right = footer.children[1].children as Node[]; + expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']); + expect(right[1].name).toBe('notification_template_form_footer_actions'); + expect(JSON.stringify(right[0])).toContain('$t:common.cancel'); + expect(JSON.stringify(right[2])).toContain('$t:common.save'); + }); + + it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => { + const ep = findById(modal, id)!; + expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort()); + expect(ep.props.modalId).toBe(MODAL_ID); + expect(ep.props.stateKey).toBe(STATE_KEY); + expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID); + expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`); + expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`); + expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`); + expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`); + }); +}); + +describe('이커머스 알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => { + const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info']; + + it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node.if, `${id} if`).toBeTruthy(); + expect(evalIf(node.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(node.if, scope('mail', undefined))).toBe(true); + expect(evalIf(node.if, scope('sms', false))).toBe(true); + }); + + it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_click_url_input`)!; + expect(evalIf(node.if, scope('database', undefined))).toBe(true); + expect(evalIf(node.if, scope('mail', undefined))).toBe(false); + expect(evalIf(node.if, scope('database', true))).toBe(false); + }); + + it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_variables_info`)!; + const noVars = { ...scope('mail', undefined) } as Node; + noVars._global[STATE_KEY].definition = { variables: [] }; + expect(evalIf(node.if, noVars)).toBe(false); + }); + + it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const preview = footer.children[0].children[0] as Node; + const right = footer.children[1].children as Node[]; + const cancel = right[0]; + const save = right[2]; + for (const b of [preview, save]) { + expect(evalIf(b.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(b.if, scope('mail', undefined))).toBe(true); + } + expect(cancel.if).toBeUndefined(); + }); + + it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => { + for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node, id).toBeTruthy(); + expect(String(node.if ?? '')).not.toContain('hidden_template_editor'); + } + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json index bab7fc2f..5387c06d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json @@ -500,8 +500,7 @@ ] } ], - "props": { - } + "props": {} } ] } @@ -528,6 +527,21 @@ } ] }, + { + "id": "ecommerce_template_form_sections_ext", + "comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.", + "type": "extension_point", + "name": "notification_template_form_sections", + "props": { + "definition": "{{_global.ecommerce_notification_template_form_modal?.definition}}", + "template": "{{_global.ecommerce_notification_template_form_modal?.template}}", + "channel": "{{_global.ecommerce_notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_ecommerce_notification_template_edit", + "stateKey": "ecommerce_notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.ecommerce_notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "ecommerceNotificationDefinitions" + } + }, { "id": "ecommerce_template_lang_tabs", "type": "basic", @@ -559,7 +573,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "ecommerce_template_subject_input", @@ -613,7 +628,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "ecommerce_template_body_input", @@ -668,13 +684,14 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "ecommerce_template_click_url_input", "type": "basic", "name": "Div", - "if": "{{_global.ecommerce_notification_template_form_modal?.template?.channel === 'database'}}", + "if": "{{(_global.ecommerce_notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "children": [ { "type": "basic", @@ -724,7 +741,7 @@ "id": "ecommerce_template_variables_info", "type": "basic", "name": "Div", - "if": "{{(_global.ecommerce_notification_template_form_modal?.definition?.variables ?? []).length > 0}}", + "if": "{{((_global.ecommerce_notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "props": { "className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4" }, @@ -841,17 +858,18 @@ "target": "modal_ecommerce_notification_definition_preview" } ], - "onError": [ - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{error.message}}" - } - } - ] + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] }, @@ -879,6 +897,21 @@ } ] }, + { + "id": "ecommerce_template_form_footer_ext", + "comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.", + "type": "extension_point", + "name": "notification_template_form_footer_actions", + "props": { + "definition": "{{_global.ecommerce_notification_template_form_modal?.definition}}", + "template": "{{_global.ecommerce_notification_template_form_modal?.template}}", + "channel": "{{_global.ecommerce_notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_ecommerce_notification_template_edit", + "stateKey": "ecommerce_notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.ecommerce_notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "ecommerceNotificationDefinitions" + } + }, { "type": "basic", "name": "Button", @@ -978,7 +1011,8 @@ ] } } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] } 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/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 6962e78e..5f6a6480 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.0] - 2026-08-22 +## [1.1.0] - 2026-08-24 ### Added diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md new file mode 100644 index 00000000..8b062986 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +이 프로젝트의 모든 주요 변경사항을 기록합니다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. + +## [1.0.0] - 2026-08-24 + +### Added + +#### 문자·알림톡 발송 + +- 비즈뿌리오 연동 문자(SMS/LMS)·카카오 알림톡 발송 플러그인 최초 릴리즈. 회원가입·주문 등 코어 알림에 자동 연결되어 메일과 같은 방식으로 발송됩니다. +- 비즈뿌리오 계정 정보와 발신번호를 입력하는 환경설정 화면을 제공합니다. +- 검수 환경과 운영 환경을 구분해 운영합니다. 설치 직후에는 검수 환경이 기본이며, 환경설정에서 운영 환경으로 전환하면 그때부터 실제 고객에게 발송됩니다. + +#### 알림톡 템플릿 작성·검수 + +- 알림 설정의 '비즈뿌리오' 통합 탭에서 각 알림의 [편집]을 누르면, 알림 템플릿 편집 창 안에서 카카오 알림톡 템플릿을 직접 작성해 검수를 신청할 수 있습니다. +- 승인된 템플릿은 그 내용으로 자동 발송됩니다. 승인 전에는 알림톡이 발송되지 않습니다. +- 기본형·부가정보형·채널추가형·복합형과 강조표기·이미지·아이템리스트 등 카카오 전 유형을 지원하며, 버튼과 바로연결도 함께 등록할 수 있습니다. +- 검수 결과는 30분 주기로 자동 확인되며, 편집 창의 [새로고침]으로 즉시 확인할 수 있습니다. +- 검수를 신청할 때 변수 예시값처럼 검수자에게 전달할 의견을 함께 적어 보낼 수 있습니다. +- 반려되면 사유를 같은 창에서 확인하고 수정 후 다시 신청합니다. +- 알림 목록의 각 행에 승인 여부(승인됨 / 미승인·세부 상태)와 문자 설정 요약이 표시됩니다. +- 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다. + +#### 문자(SMS) 설정 + +- 같은 편집 창에서 알림별로 알림톡 실패 시 대체 SMS 사용 여부, SMS 단독 발송 여부, 문자 본문을 함께 설정합니다. +- 하단 [저장] 한 번으로 알림톡 템플릿·문자 설정·수신자 규칙이 모두 저장됩니다. +- 문자 본문은 언어별로 입력하며 회원이 사용하는 언어로 발송됩니다. 해당 언어가 비어 있으면 기본 언어로 발송됩니다. +- [SMS 단독]을 선택한 알림은 템플릿이 승인되어 있어도 알림톡을 보내지 않고 문자로만 발송합니다. +- 검수중·승인된 템플릿은 내용이 잠깁니다. 승인 취소는 즉시 알림톡 발송이 중단됨을 같은 창에서 확인한 뒤 진행합니다. + +#### 템플릿 관리 + +- 플러그인 설정의 '알림 템플릿 관리' 화면에서 모든 알림의 템플릿 상태를 한눈에 보고 검색·필터·수정·삭제할 수 있습니다. +- 좁은 화면에서는 목록이 카드 형태로 전환되어 모바일에서도 상태를 확인할 수 있습니다. + +#### 발송 결과·이력 + +- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록합니다. +- 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. +- 요청 과다로 인한 일시적 발송 실패도 사유가 함께 표시됩니다. + +#### 안내 + +- 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치하면 자동 복원됩니다. +- 한국어·영어를 기본 제공하며, 일본어는 번들 언어팩으로 제공됩니다. +- 코어 7.0.6 이상이 필요합니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/LICENSE b/plugins/_bundled/sirsoft-message_bizppurio/LICENSE new file mode 100644 index 00000000..4e94ed38 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/LICENSE @@ -0,0 +1,47 @@ +프로그램 명칭 : 그누보드7용 비즈뿌리오 메시징 플러그인 (sirsoft-message_bizppurio) + +저작자 : (주)에스아이알소프트 + +----- MIT 라이선스 (한국어 번역) -------------------------------------------------------- + +MIT 라이선스 + +Copyright (c) 2026 (주)에스아이알소프트 + +이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게 +소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는 +판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음 +조건을 따라야 합니다: + +위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야 +합니다. + +소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지 +않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에 +국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의 +사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타 +책임에 대해 책임을 지지 않습니다. + +----- MIT License (English Original) -------------------------------------------------------- + +The MIT License (MIT) + +Copyright (c) 2026 SIRSOFT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/README.md b/plugins/_bundled/sirsoft-message_bizppurio/README.md new file mode 100644 index 00000000..17f8a1b9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/README.md @@ -0,0 +1,267 @@ +# Bizppurio Messaging Plugin for G7 + +

+ Version + G7 + PHP + License +

+ +비즈뿌리오(Bizppurio)를 연동해 문자(SMS/LMS)와 카카오 알림톡을 발송하는 G7 플러그인입니다. + +G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가입·주문 등 코어/모듈이 발화하는 알림을 문자와 알림톡으로도 자동 발송합니다. 발송 결과는 비즈뿌리오가 보내는 webhook 통보로 수신해 성공/실패와 실패 사유를 발송 이력에 기록합니다. + +[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 관리](#알림톡-템플릿-관리) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트) + +--- + +## 주요 기능 + +- 문자(SMS/LMS) 발송 — 본문 길이에 따라 SMS/LMS 자동 선택 +- 카카오 알림톡 발송 — 승인된 템플릿의 본문·버튼·바로연결·강조표기·아이템리스트·대표링크까지 반영 +- 알림톡 미승인·발송 불가 시 문자로 자동 대체발송(옵션) +- 회원·비회원 대상 알림에 문자 채널 연동 (비회원은 주문 시 입력한 연락처 사용) +- 비즈뿌리오 webhook 리포트 수신으로 발송 결과(성공/실패/사유) 자동 기록 +- 검수(테스트) 모드 — 실제 발송 없이 화면·흐름 검증 +- 지갑 잔액 부족·후불 한도 초과 시 관리자 알림 (반복 발송 방지 쿨다운 적용) +- 관리자 "알림 발송 이력" 화면에 문자·알림톡 결과 통합 표시 +- 알림별 카카오 알림톡 템플릿을 관리자 화면에서 직접 작성 → 검수 신청 → 승인 후 자동 발송 +- 검수 결과(승인·반려)를 30분 주기로 자동 확인하고, 반려 사유를 화면에서 확인해 수정 후 재신청 + +--- + +## 요구 사항 + +| 구분 | 항목 | 내용 | +|------|------|------| +| 플랫폼 | G7 | `>= 7.0.6` | +| 플랫폼 | PHP | `^8.2` | +| 사전 준비 | 비즈뿌리오 계정 | 가입 + API 사용 승인 | +| 사전 준비 | 문자 발송 | 발신번호 사전 등록 (비즈뿌리오 콘솔) | +| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 (템플릿은 이 플러그인 화면에서 작성·검수 신청) | + +> 운영 모드로 전환하려면 비즈뿌리오 아이디·비밀번호·API 키·발신번호가 모두 입력되어야 하며, 이 시점부터 **실제 발송과 비용이 발생**합니다. + +--- + +## 설치 + +플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다. + +```text +plugins/sirsoft-message_bizppurio +``` + +프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다. + +```bash +npm install +npm run build +``` + +그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널이 알림 설정에 등록됩니다. + +--- + +## 관리자 설정 + +관리자 플러그인 설정 화면에서 비즈뿌리오 계정 정보를 입력합니다. + +| 설정 | 필수 여부 | 설명 | +|------|:---:|------| +| 검수 모드 | - | 활성화 시 실제 발송 없이 검수용으로만 동작. 발송 이력에 "검수" 라벨로 표시되어 실제 장애와 구분됨 | +| 비즈뿌리오 아이디 / 비밀번호 | 운영 시 필수 | 비즈뿌리오 계정 로그인 정보 | +| API 키 | 운영 시 필수 | 비즈뿌리오 API 인증에 사용 | +| 발신번호 | 운영 시 필수 | 문자 발송용 발신번호. 비즈뿌리오 콘솔에 사전 등록된 번호만 사용 가능 | +| 알림톡 발신프로필 키 | 알림톡 사용 시 필수 | 카카오 알림톡 발송에 사용할 발신프로필 키 | +| 잔액부족 알림 재발송 간격(초) | 선택 (기본 3600) | 잔액 부족/한도 초과 실패 시 관리자 알림의 최소 재발송 간격. 대량 실패 시 반복 발송 방지 | + +> 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다. 비밀번호·API 키·발신프로필 키는 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다. + +--- + +## webhook(발송 결과 리포트) 등록 + +비즈뿌리오가 발송 결과를 통보할 URL을 비즈뿌리오 콘솔에 등록해야 발송 결과(성공/실패/사유)가 발송 이력에 자동 기록됩니다. + +> **이 등록을 하지 않으면 발송 자체는 되지만 성공/실패 여부를 확인할 수 없습니다.** + +```text +https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook +``` + +정확한 URL은 관리자 플러그인 설정 화면의 환경설정 탭에서도 복사할 수 있습니다. + +--- + +## 발송 흐름 + +```text +코어/모듈이 알림 발화 (예: 회원가입, 주문 완료) +→ 알림 설정에서 켜 둔 문자·알림톡 채널로 발송 작업 큐잉 +→ 알림톡: 승인된 템플릿의 저장 내용으로 발송, 발송 불가 시 문자로 대체발송(옵션) +→ 문자: 본문 길이에 따라 SMS/LMS 자동 선택 +→ 비즈뿌리오 발송 API 호출 +→ 비즈뿌리오가 webhook 으로 결과(성공/실패/사유) 통보 +→ 발송 이력에 결과 기록, 실패 시 사유·결과코드 함께 기록 +``` + +일시적 오류(카카오 시스템 오류, 처리 지연, 게이트웨이 오류 등)로 실패한 경우 자동으로 재시도합니다. 지갑 잔액 부족·후불 한도 초과는 재시도 대상이 아니며, 즉시 실패 처리와 함께 관리자에게 알림이 발송됩니다. + +--- + +## 알림톡 템플릿 관리 + +카카오 알림톡 템플릿의 **작성·검수 신청·취소·삭제를 관리자 화면에서 직접** 수행합니다. 비즈뿌리오 콘솔을 따로 열 필요가 없습니다. + +작성 위치는 두 곳입니다. + +- **알림 설정 > 비즈뿌리오 탭** — 알림 항목의 **[편집]** 을 누르면 알림 템플릿 편집 창 안에 **알림톡 템플릿** 섹션과 **문자(SMS)** 섹션이 함께 열립니다. 알림톡 본문·유형·버튼, 대체 SMS/SMS 단독 여부와 문자 본문, 수신자 규칙을 한 창에서 고치고 하단 **[저장]** 한 번으로 모두 저장합니다(알림톡 검수는 **[저장 후 검수 신청]** — 작성 폼 하단의 **검수자 전달 의견**에 변수 예시값 등을 적으면 신청과 함께 카카오 검수자에게 전달됩니다). 이 채널에서는 코어의 제목/본문 입력이 숨겨지고 알림톡 본문이 그 자리를 대신합니다. 알림 목록의 각 행 하단에는 승인 여부(**승인됨** / **미승인 (세부 상태)**)와 문자 설정 요약이 표시됩니다. 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다. +- **플러그인 설정 > 알림 템플릿 관리** — 전체 알림의 템플릿 상태를 한 화면에서 보고 검색·필터링합니다(자체 작성/SMS 본문 모달). + +기본 흐름은 **작성 → 검수 신청 → (카카오 승인) → 자동 발송** 입니다. 승인된 시점의 내용이 발송 기준으로 저장되며, 발송할 때마다 카카오를 조회하지 않습니다. + +메시지 유형(기본형·부가정보형·채널추가형·복합형)과 강조 유형(없음·강조표기·이미지·아이템리스트), 버튼(최대 5개)·바로연결(최대 10개)을 지원합니다. 본문에는 `#{변수}` 형식으로 알림 변수를 넣을 수 있습니다. + +이미지 강조 유형에 쓸 이미지는 화면에서 직접 업로드하며, 카카오 규격에 따라 **jpg/png · 500KB 이하 · 가로 500px 이상 · 가로:세로 2:1** 비율이어야 합니다. 규격에 맞지 않으면 업로드 단계에서 사유와 함께 거부됩니다. + +| 상태 | 발송 가능 | 설명 | +|:---:|:---:|------| +| 미작성 | ❌ | 템플릿 내용을 아직 작성하지 않은 상태 | +| 작성중 | ❌ | 저장했으나 검수를 신청하기 전 상태 (내용 수정 가능) | +| 검수중 | ❌ | 카카오 검수 진행 중. 내용을 고치려면 먼저 [신청 취소] | +| 승인 | ✅ | 검수 승인 완료 — 이 알림이 발생하면 알림톡이 발송됩니다 | +| 반려 | ❌ | 카카오 검수 반려. 화면에서 사유를 확인하고 수정 후 재신청 | +| 중지 | ❌ | 카카오에서 사용 중지된 템플릿 | +| 차단 | ❌ | 카카오에서 차단된 템플릿 | +| 휴면 | ❌ | 장기 미사용으로 휴면 전환. 화면의 [휴면 해제]로 복구 | + +검수 결과는 30분 주기로 자동 확인하며, 편집 창의 [새로고침]으로 즉시 확인할 수도 있습니다. + +검수중·승인된 템플릿은 편집 창에서 내용이 잠기고 요약만 표시됩니다. 검수중이면 [신청 취소] 후, 승인된 템플릿은 [수정 (승인 취소)] 를 눌러 승인을 먼저 취소해야 고칠 수 있습니다. **승인을 취소하면 그 즉시 해당 알림의 알림톡 발송이 중단**되므로(대체 SMS 는 설정에 따라 계속 발송), 같은 창의 확인 박스에서 확인 후 진행합니다. + +### 문자(SMS) 본문 + +알림마다 문자 본문을 따로 입력합니다(편집 창의 문자(SMS) 섹션). 쓰임은 두 가지이고 본문은 하나를 공유하며, 카카오 검수 대상이 아니므로 [저장] 즉시 반영됩니다. + +- **대체 SMS** — 알림톡 발송이 실패했을 때(수신 거부·미가입 등) 같은 번호로 문자를 대신 보냅니다. +- **SMS 단독** — 알림톡을 쓰지 않고 문자로만 보냅니다. 이 항목을 켜면 템플릿이 승인 상태여도 알림톡을 보내지 않습니다. + +문자 본문은 **언어별로 입력**하며, 회원이 사용하는 언어의 본문으로 발송됩니다. 해당 언어의 본문이 비어 있으면 기본 언어 본문으로 발송하고, 기본 언어도 비어 있으면 그 알림의 문자 발송을 건너뜁니다. 알림톡 본문은 카카오가 승인한 원문 그대로만 발송할 수 있어 언어 구분이 없습니다. + +--- + +## 발송 결과 코드 + +비즈뿌리오/카카오가 반환하는 결과 코드는 4가지로 분류되어 처리됩니다. + +| 분류 | 처리 방침 | +|:---:|------| +| 성공 | 발송 완료 | +| 재시도 (일시 오류) | 자동 재시도 대상 (예: 카카오 시스템 오류, 처리 지연, 게이트웨이 오류) | +| 잔액 부족 | 즉시 실패 처리 + 관리자 자체 알림 | +| 영구 실패 | 즉시 실패 처리, 재시도하지 않음 | + +주요 코드 예시: + +| 코드 | 분류 | 사유 | +|:---:|------|------| +| `1000` `4100` `6600` `7000` | 성공 | 발송/리포트 성공 | +| `9070` | 잔액 부족 | 잔액 부족(문자) | +| `9071` | 잔액 부족 | 후불 한도 초과 | +| `7436` | 잔액 부족 | 지갑 잔액 부족(알림톡) | +| `4400` | 영구 실패 | 음영 지역 | +| `7103` | 영구 실패 | 발신 프로필 키 무효 | + +발송 이력 화면에는 `사유 (코드)` 형식(예: "음영 지역 (4400)")으로 표시됩니다. 전체 코드 목록은 `lang/ko/result_codes.php` / `lang/en/result_codes.php`에 정의되어 있으며, lang에 없는 코드는 코드만 표시됩니다. + +--- + +## 가용 훅 (Hook) + +다른 모듈이나 플러그인에서 아래 훅에 연결해 잔액부족 상황을 확장 처리할 수 있습니다. + +### 액션 훅 + +| 훅 이름 | 시점 | 인수 | +|------|------|------| +| `sirsoft-message_bizppurio.balance.low` | 잔액 부족·한도 초과로 발송 실패 시 (쿨다운 내 최초 1회) | `string $resultCode, string $channel` | + +### 훅 등록 예시 + +```php +use App\Extension\HookManager; + +HookManager::addAction( + 'sirsoft-message_bizppurio.balance.low', + function (string $resultCode, string $channel) { + // 예: 잔액 부족 시 Slack으로도 별도 알림 + SlackNotifier::send("비즈뿌리오 잔액 부족: 채널={$channel}, 코드={$resultCode}"); + }, + priority: 10 +); +``` + +`$resultCode`는 잔액 부족(`9070` 문자 / `7436` 알림톡) 또는 후불 한도 초과(`9071`) 코드입니다. 이 훅은 관리자 자체 알림(잔액부족/후불한도초과 안내)을 발화하는 지점과 동일하며, 채널별 쿨다운(기본 3600초) 동안 한 번만 실행됩니다. + +--- + +## API + +전체 엔드포인트 레퍼런스는 [docs/api/README.md](docs/api/README.md)를 참고하세요. + +| 문서 | 내용 | +|------|------| +| [webhook.md](docs/api/webhook.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 조회 | + +### 권한 + +| 권한 | 설명 | +|------|------| +| `sirsoft-message_bizppurio.messaging.view` | 발송 이력, 알림톡 템플릿, 발송 결과 조회 | +| `sirsoft-message_bizppurio.messaging.manage` | 알림톡 템플릿 작성·검수 신청·신청/승인 취소·상태 동기화·삭제, 대체 SMS 설정 등 관리 작업 | + +--- + +## 삭제 시 동작 + +플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다. + +--- + +## 보안 및 운영 참고 + +- 비즈뿌리오 비밀번호, API 키, 알림톡 발신프로필 키는 외부에 노출하지 마세요. 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다. +- 운영 모드 전환 전 검수 모드에서 문자·알림톡 발송 흐름을 먼저 확인하세요. 운영 모드는 실제 발송과 비용이 발생합니다. +- webhook URL을 비즈뿌리오 콘솔에 등록하지 않으면 발송 결과 확인이 불가능합니다. +- 검수와 운영은 별도의 비즈뿌리오 계정 사용을 권장합니다. +- 지갑 잔액/후불 한도를 주기적으로 확인하세요. 부족 시 관리자 알림이 발송되지만, 알림 자체도 같은 채널(문자/알림톡)을 사용하지 않는 별도 채널(예: 사이트 내 알림, 메일)로 함께 받는 것을 권장합니다. + +--- + +## 테스트 + +플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다. + +```bash +php artisan test plugins/sirsoft-message_bizppurio/tests +``` + +프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다. + +```bash +npm install +npm run test:run +npm run build +``` + +--- + +## 라이선스 + +MIT \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/components.json b/plugins/_bundled/sirsoft-message_bizppurio/components.json new file mode 100644 index 00000000..42c0ba08 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/components.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "identifier": "sirsoft-message_bizppurio", + "version": "1.0.0", + "components": { + "basic": [], + "composite": [], + "layout": [] + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/composer.json b/plugins/_bundled/sirsoft-message_bizppurio/composer.json new file mode 100644 index 00000000..22992fd2 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/composer.json @@ -0,0 +1,22 @@ +{ + "name": "plugins/sirsoft-message_bizppurio", + "description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin for G7 platform by sirsoft", + "type": "library", + "version": "1.0.0", + "license": "MIT", + "authors": [ + { + "name": "sirsoft", + "email": "contact@sirsoft.com" + } + ], + "require": { + "php": "^8.2", + "ext-json": "*" + }, + "autoload": { + "psr-4": { + "Plugins\\Sirsoft\\MessageBizppurio\\": ["src/", "./"] + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json new file mode 100644 index 00000000..9fab46fb --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json @@ -0,0 +1,23 @@ +{ + "_meta": { + "version": "1.0.0", + "description": "비즈뿌리오 메시징 플러그인 환경설정 기본값 및 프론트엔드 스키마. 크리덴셜(password/api_key/sender_key)은 sensitive + expose:false 로 프론트 노출을 차단한다. 관리자 설정 화면은 코어 /api/admin/plugins/{id}/settings 로 직접 조회하므로 window.G7Config 노출이 불필요하여 전 필드 expose:false." + }, + "defaults": { + "is_test_mode": true, + "bizppurio_id": "", + "password": "", + "api_key": "", + "sender_number": "", + "sender_key": "", + "balance_low_notify_cooldown": 3600 + }, + "frontend_schema": { + "is_test_mode": { "expose": false }, + "bizppurio_id": { "expose": false }, + "password": { "expose": false, "sensitive": true }, + "api_key": { "expose": false, "sensitive": true }, + "sender_number": { "expose": false }, + "sender_key": { "expose": false, "sensitive": true } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php new file mode 100644 index 00000000..49a4ec70 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php @@ -0,0 +1,73 @@ +bigIncrements('id')->comment('발송 이력 PK'); + $table->string('refkey', 32)->comment('우리 부여 키 (webhook 매칭용, UTF-8 최대 32byte)'); + $table->string('messagekey', 64)->nullable()->comment('비즈뿌리오 부여 키 (발송 응답 messagekey)'); + $table->string('channel', 20)->comment('발송 채널: sms / lms / alimtalk (DispatchChannel enum)'); + $table->string('media', 10)->nullable()->comment('webhook MEDIA — 실제 발송 유형: SMS / LMS / KAT(알림톡)'); + $table->string('to_number', 20)->comment('수신 전화번호 (숫자만)'); + $table->string('to_name', 100)->nullable()->comment('수신자명 (발송 시점 스냅샷)'); + $table->foreignId('to_user_id')->nullable()->comment('회원이면 users FK, 비회원이면 null') + ->constrained('users', indexName: 'bizppurio_dispatch_user_fk')->nullOnDelete(); + $table->text('content')->comment('발송 본문(SMS) 또는 템플릿 참조(알림톡)'); + $table->json('request_payload')->nullable()->comment('실제 비즈뿌리오 API 전송 요청 payload (개인식별 정보 제외 — to/refkey/type/본문 텍스트는 다른 컬럼과 중복이라 제외, account/from/senderkey/templatecode/버튼 등 요소만 저장)'); + $table->string('notification_type', 100)->nullable()->comment('코어 notification_definitions.type 참조 (어느 알림). 수동발송 시 null'); + $table->unsignedBigInteger('notification_log_id')->nullable()->comment('코어 notification_logs.id 연결 표식 (A-2). 코어 알림 발송 이력 화면에서 결과를 이 행에 붙이기 위한 매칭 키. 코어 테이블 무수정 — 연결 표식은 비즈뿌리오 쪽에만 둔다'); + $table->string('status', 20)->default('pending')->comment('발송 상태: pending / sent / success / failed (DispatchStatus enum)'); + $table->string('result_code', 10)->nullable()->comment('결과 코드 (발송응답 or 리포트)'); + $table->string('result_message', 255)->nullable()->comment('결과 사유 원본 (result_codes lang 해석 전)'); + $table->string('fallback_status', 20)->nullable()->comment('대체발송 결과: 성공/실패/없음 (webhook TELRES)'); + $table->string('source', 10)->default('auto')->comment('발송 출처: auto / manual / bulk (DispatchSource enum, 1차 auto)'); + $table->boolean('is_test_mode')->nullable()->comment('발송 시점 검수 모드 여부 (null=컬럼 신설 이전 이력)'); + $table->timestamp('sent_at')->nullable()->comment('발송 시각'); + $table->timestamp('reported_at')->nullable()->comment('webhook 리포트 수신 시각 (replay 멱등 판정)'); + $table->json('raw_payload')->nullable()->comment('webhook 원본 페이로드'); + $table->timestamps(); + + $table->unique('refkey', 'bizppurio_dispatch_refkey_unique'); + $table->index('channel', 'bizppurio_dispatch_channel_idx'); + $table->index('status', 'bizppurio_dispatch_status_idx'); + $table->index('notification_type', 'bizppurio_dispatch_notif_type_idx'); + $table->index('sent_at', 'bizppurio_dispatch_sent_at_idx'); + $table->index('to_user_id', 'bizppurio_dispatch_user_idx'); + $table->index(['status', 'sent_at'], 'bizppurio_dispatch_status_sent_idx'); + $table->index('notification_log_id', 'bizppurio_dispatch_notif_log_idx'); + }); + + if (DB::getDriverName() === 'mysql') { + Schema::table('bizppurio_dispatches', function (Blueprint $table) { + $table->comment('비즈뿌리오 문자·알림톡 발송 이력'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bizppurio_dispatches'); + } +}; 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 new file mode 100644 index 00000000..2642b4a2 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..280895ca --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -0,0 +1,19 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 6 · **엔드포인트 수**: 21 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 2 | +| [dispatch-results.md](dispatch-results.md) | `dispatch-results` | 2 | +| [report.md](report.md) | `report` | 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 new file mode 100644 index 00000000..16bfe23a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md @@ -0,0 +1,131 @@ +# Alimtalk Templates API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 알림톡 템플릿 작성 모달의 참조 조회(카테고리·발신프로필) 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 실시간 템플릿 목록/상세 화면(구 Phase 5)은 DB 기반 라이프사이클(templates.md)로 대체되어 제거됐습니다 +4. kapi 실패는 422 + errors.bizppurio_message(카카오 사유 원문) 규약을 따릅니다 +5. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +``` + +--- + +작성 모달 참조 조회(카테고리·발신프로필) 전용 문서다. 알림톡 템플릿 작성 모달의 카테고리 셀렉트·발신프로필 +셀렉트가 소비하는 kapi(카카오 관리 API) 조회를 프록시한다. 템플릿의 등록·검수·승인 라이프사이클 API 는 +[templates.md](templates.md) 를 참조. + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@categories` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| 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": "구매" } + ] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + +템플릿 등록에 사용할 카카오 카테고리 전체(대분류·소분류)를 조회한다(`data.categories`). 작성 모달의 +카테고리 셀렉트가 소비하며, 선택된 소분류 코드가 템플릿 생성/수정의 `content.categoryCode` 값이 된다. +kapi 자격증명 미설정·호출 실패 시 422 + `errors.bizppurio_message`(카카오 사유 원문) 를 반환한다. + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@profiles` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| profiles | array | `[{...}]` | kapi `/v3/kakao/profile/use` 응답 `data.success` 배열(조회 실패 프로필 `fail` 은 제외). 각 항목: `senderKey`(발신프로필 키), `name`(프로필명), `status`, `block`, `dormant`, `categoryCode` 등 kapi 원형 필드 | + +**응답 예시** + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "profiles": [ + { "senderKey": "05aa099bcbc5220a8c0b2...", "name": "@우리상점", "status": "A", "block": false, "dormant": false } + ] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + +발신프로필(사용중) 상태 정보를 조회한다(`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 new file mode 100644 index 00000000..24b69d66 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md @@ -0,0 +1,334 @@ +# Dispatch Results API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Dispatch Results 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.lookup` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@lookup` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notification_log_ids | body | array | 아니오 | max 100 | notification log 식별자 배열 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "notification_log_ids": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| results | array | `[]` | 요청한 `notification_log_ids` 각각의 비즈뿌리오 발송 결과. 해당 알림 로그로 발송된 건이 없으면 빈 배열 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "results": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +코어 "알림 발송 이력" 화면에 얹은 결과 컬럼(layout_extensions overlay)이 소비하는 조회 API 다. +현재 페이지에 표시된 코어 알림 로그 id 배열(`notification_log_ids`, 최대 100)을 넘기면, 그 로그에 +연결된 비즈뿌리오 발송 결과를 **로그 id 를 키로 하는 맵**으로 한 번에 돌려준다(행마다 개별 호출하지 +않아 N+1 을 피한다). 코어 알림 로그 테이블은 수정하지 않으며, 연결 표식은 비즈뿌리오 쪽 +(`bizppurio_dispatches.notification_log_id`)에만 둔다. + +매칭되지 않는 로그 id(메일·사이트내알림 등 비-비즈뿌리오 발송)는 결과 맵에서 제외된다 — 화면에서는 +그 행의 결과 컬럼이 빈 셀이 된다. 결과에는 전화번호 등 민감정보를 포함하지 않는다(결과 컬럼은 상태· +사유만 표시). + +**응답 필드** (`data.results` — 키는 `notification_log_id`, 값은 결과 객체) + +| 필드 | 타입 | 용도 | +| --- | --- | --- | +| status | string\|null | 발송 상태: `sent` / `success` / `failed` (DispatchStatus) | +| status_label | string\|null | 로케일 상태 라벨 (예: "성공", "실패", "발송중") | +| result_code | string\|null | 결과 코드 (발송응답 또는 webhook 리포트). 리포트 미수신 시 null | +| result_label | string\|null | `사유 (코드)` 표시 라벨 (예: "음영 지역 (4400)"). 코드 없으면 null | +| is_low_balance | boolean | 잔액 부족(9070 문자 / 7436 알림톡) 여부 | +| fallback_status | string\|null | SMS 대체발송 결과 (webhook TELRES). 없으면 null | +| channel | string\|null | 발송 채널: `sms` / `lms` / `alimtalk` | +| content | string\|null | 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 `notification_logs.body`(대체발송용 코어 템플릿 값)와 달리 카카오 승인 템플릿의 실제 내용이므로, 화면은 이 값을 채널별로 구분해 별도 노출한다 | + +**응답 예시** + +```json +{ + "success": true, + "message": "성공", + "data": { + "results": { + "128": { + "status": "failed", + "status_label": "실패", + "result_code": "4400", + "result_label": "음영 지역 (4400)", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + } + } + } +} +``` + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.recent` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@recent` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| results | object | `{"21":{"status":"pending","status_label":"대기","result_cod…` | `notification_log_id` 를 키로 하는 결과 맵. 각 값은 `status`(pending/sent/failed) · `status_label`(현재 로케일 라벨) · `result_code`(비즈뿌리오 결과 코드) · 실패 사유를 담는다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "results": { + "21": { + "status": "pending", + "status_label": "대기", + "result_code": null, + "result_label": null, + "is_low_balance": false, + "fallback_status": null, + "channel": "alimtalk" + }, + "20": { + "status": "success", + "status_label": "성공", + "result_code": "7000", + "result_label": "성공 (7000)", + "is_low_balance": false, + "fallback_status": null, + "channel": "alimtalk" + }, + "19": { + "status": "failed", + "status_label": "실패", + "result_code": "7206", + "result_label": "검수되지 않은 템플릿 (7206)", + "is_low_balance": false, + "fallback_status": null, + "channel": "alimtalk" + }, + "18": { + "status": "failed", + "status_label": "실패", + "result_code": "7436", + "result_label": "지갑 잔액 부족(알림톡) (7436)", + "is_low_balance": true, + "fallback_status": "실패", + "channel": "alimtalk" + }, + "17": { + "status": "success", + "status_label": "성공", + "result_code": "7000", + "result_label": "성공 (7000)", + "is_low_balance": false, + "fallback_status": "성공", + "channel": "alimtalk" + }, + "16": { + "status": "success", + "status_label": "성공", + "result_code": "7000", + "result_label": "성공 (7000)", + "is_low_balance": false, + "fallback_status": null, + "channel": "alimtalk" + }, + "15": { + "status": "failed", + "status_label": "실패", + "result_code": "6603", + "result_label": "음영 지역 (6603)", + "is_low_balance": false, + "fallback_status": null, + "channel": "lms" + }, + "14": { + "status": "success", + "status_label": "성공", + "result_code": "6600", + "result_label": "성공 (6600)", + "is_low_balance": false, + "fallback_status": null, + "channel": "lms" + }, + "13": { + "status": "failed", + "status_label": "실패", + "result_code": "9999", + "result_label": "9999", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "12": { + "status": "failed", + "status_label": "실패", + "result_code": "9070", + "result_label": "잔액 부족(문자) (9070)", + "is_low_balance": true, + "fallback_status": null, + "channel": "sms" + }, + "11": { + "status": "success", + "status_label": "성공", + "result_code": "4100", + "result_label": "성공 (4100)", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "10": { + "status": "pending", + "status_label": "대기", + "result_code": null, + "result_label": null, + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "9": { + "status": "sent", + "status_label": "발송중", + "result_code": null, + "result_label": null, + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "8": { + "status": "failed", + "status_label": "실패", + "result_code": "4410", + "result_label": "잘못된 번호 (4410)", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "7": { + "status": "failed", + "status_label": "실패", + "result_code": "4400", + "result_label": "음영 지역 (4400)", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "6": { + "status": "success", + "status_label": "성공", + "result_code": "4100", + "result_label": "성공 (4100)", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + +알림 발송 이력 화면이 최근 발송 건의 비즈뿌리오 결과를 한 번에 받아 결과 컬럼에 주입할 때 사용한다. +`lookup` 이 지정한 로그 ID 만 조회하는 것과 달리, 이 엔드포인트는 최근 구간을 서버가 정해 돌려주므로 +화면이 조회 대상을 미리 알 필요가 없다. 결과는 `notification_log_id` 를 키로 하는 맵이며, +비즈뿌리오로 발송되지 않은 알림은 키 자체가 없다 — 화면은 키 부재를 "해당 없음" 으로 그린다. + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md new file mode 100644 index 00000000..1ebe76ef --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md @@ -0,0 +1,80 @@ +# Report API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Report 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/report-url + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.report.url` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/report-url HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| url | string | `https://api.example.com/api/plugins/sirsoft-message_bizppurio/webhook` | 비즈뿌리오 콘솔에 등록할 발송 결과 리포트 수신 URL (현재 사이트 기준 절대 URL) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "data": { + "url": "https://api.example.com/api/plugins/sirsoft-message_bizppurio/webhook" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | + + + +**설명** + +비즈뿌리오 발송 결과(리포트)를 수신할 콜백 URL을 관리자 설정 페이지에 표시하기 위해 반환하는 엔드포인트입니다. 관리자 환경설정 화면(`admin/plugin_settings.json`)의 `report_url` data_source 가 이 값을 조회해 "리포트 수신 설정" 카드에 readonly 로 표시하고, 운영자가 그대로 복사해 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 URL PUSH 수신 주소로 등록합니다. + +URL 은 `url()` 헬퍼가 아니라 `config('app.url')` 을 기준으로 조합합니다 — 리버스 프록시 뒤 PHP-FPM 환경에서 요청 host 가 `localhost` 로 떨어질 수 있어, 운영자가 관리하는 설정값을 신뢰 소스로 삼아 항상 정식 도메인을 노출합니다. + +관리자 인증(`auth:sanctum`)과 `core.plugins.read` 권한이 필요하며, 토큰 누락·만료는 401, 권한 부족은 403 으로 응답합니다. + +※ 실제 리포트 수신 처리(`POST /webhook`)는 후속 단계에서 제공됩니다. 본 엔드포인트는 표시용 주소 조회만 담당합니다. + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md new file mode 100644 index 00000000..e957e91a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md @@ -0,0 +1,1064 @@ +# Templates API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +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 자격증명 없이 실측 불가) +``` + +--- + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/templates-readiness + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.readiness` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/templates-readiness HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| api_key_set | boolean | `false` | 카카오 관리 API 키(`api_key`) 저장 여부 — 값 자체는 sensitive 라 노출하지 않고 저장 여부만 내려준다 | +| sender_key_set | boolean | `false` | 알림톡 발신프로필 키(`sender_key`) 저장 여부 | +| ready | boolean | `false` | 두 값이 모두 저장되어 템플릿 작성·검수 신청이 가능한 상태인지 (알림 설정 탭 readiness 배너가 소비) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "data": { + "api_key_set": "{MASKED}", + "sender_key_set": false, + "ready": false + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + +카카오 관리에 필요한 자격증명(`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 | +| comment | body | string | 아니오 | 500자 이내 (앞뒤 공백 제거, 비어 있으면 미전달) | 검수자 전달 의견 — kapi `template/request` 의 comment 로 그대로 실린다. 행에는 저장하지 않는다 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/templates/4/request HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json +Authorization: Bearer {YOUR_TOKEN} + +{ + "comment": "변수 예시: #{name}=홍길동, #{order_number}=20260823-0001" +} +``` + +**응답 필드** (`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 | comment 500자 초과 / 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` 은 초기화된다. +요청 본문의 `comment`(선택, ≤500)는 ③ 의 kapi request 에 검수자 전달 의견으로 그대로 실리며 행에는 저장하지 않는다 — +카카오 심사 가이드가 본문 변수마다 요구하는 '예시 텍스트' 를 검수자에게 전할 유일한 통로다(kapi `template/add` 에는 +예시 필드가 없다). 500자 초과는 kapi 호출 전에 422 로 끝나므로 행 상태와 선점은 바뀌지 않는다. + + +### 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/token.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/token.md new file mode 100644 index 00000000..0180514e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/token.md @@ -0,0 +1,74 @@ +# Token API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 Token(연결 확인) 엔드포인트 레퍼런스입니다 +2. POST /admin/token/check — 저장된 계정/비밀번호로 즉시 재인증(캐시 우회) +3. 성공 시 200 + 새 토큰을 캐시에 반영, 실패 시 422 + errors 에 비즈뿌리오 원문 사유(bizppurio_message) + result_code +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + +### POST /api/plugins/sirsoft-message_bizppurio/admin/token/check + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.token.check` +- **인증/권한**: `auth:sanctum` + `admin` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +_요청 파라미터 없음. 저장된 플러그인 설정(`bizppurio_id`, `password`)을 사용합니다._ + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/token/check HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_성공 응답은 `data` 없이 메시지만 반환합니다._ + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다." +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 자격증명 미설정(계정/비밀번호 공란) 또는 비즈뿌리오 인증 실패 — `errors.bizppurio_message` 에 비즈뿌리오 실패 사유 원문, `errors.result_code` 에 비즈뿌리오 응답 결과코드(있으면) 동반. 비즈뿌리오 서버 연결 자체가 실패(타임아웃·DNS 등)한 경우도 422(연결 실패 안내 메시지, `result_code` 없음) | + + + +**설명** + +관리자가 설정 화면에서 저장한 비즈뿌리오 아이디·비밀번호가 실제로 유효한지 그 자리에서 확인하는 "연결 확인" 버튼이 호출하는 엔드포인트입니다. `BizppurioTokenService::verifyCredentials()` 가 캐시를 거치지 않고 매번 `/v1/token` 을 새로 호출해 재검증하며, 성공 시 새로 발급된 토큰을 캐시(TTL 23시간)에 반영해 확인 직후의 발송이 이 토큰을 그대로 재사용하게 합니다(불필요한 재발급 방지). + +실패 시 `BizppurioApiException` 을 422 로 변환합니다 — 응답 `message` 는 고정 안내(`token_check.failed`)이고, 비즈뿌리오가 준 실패 사유 원문(응답 `description` 이 있으면 `token_issue_failed_with_reason` 형태로 조립된 문장)은 관리자 전용 진단 정보로 `errors.bizppurio_message` 에 담습니다(예외 원문을 메시지 키 자리에 전달하지 않는 예외→응답 매핑 규정). 응답 `errors.result_code` 는 비즈뿌리오 결과코드(예: `3007`)이며, HTTP 전송 자체가 실패한 경우 `null` 입니다. + +비즈뿌리오 서버 자체에 연결할 수 없는 경우(타임아웃·DNS 실패 등)는 `BizppurioApiException` 이 아닌 `ConnectionException` 으로 던져지므로 별도 catch 하여 `error.connection_failed` 메시지와 함께 422 로 응답합니다(`result_code` 없이 500 대신 매끄러운 실패 안내). + +프론트 화면(`admin/plugin_settings.json` "연결 확인" 필드)은 저장하지 않은 변경사항(`_local.hasChanges`)이 있으면 이 API 를 호출하지 않고 "변경사항을 먼저 저장해주세요" toast 만 표시합니다 — 저장 전 값으로 확인하면 실제 저장된 자격증명과 다른 결과가 나올 수 있기 때문입니다. + +조회가 아닌 재인증(쓰기 성격의 외부 API 호출)이므로 `messaging.view` 가 아닌 `messaging.manage` 권한을 요구합니다. \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md new file mode 100644 index 00000000..2e390b3d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md @@ -0,0 +1,96 @@ +# Webhook API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Webhook 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/plugins/sirsoft-message_bizppurio/webhook + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.webhook` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController@handle` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| 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 | 알림톡 발송 결과 코드 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/webhook HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "DEVICE": "예시값", + "CMSGID": "예시값", + "MSGID": "예시값", + "PHONE": "010-1234-5678", + "MEDIA": "예시값", + "RESULT": "예시값", + "REFKEY": "예시값", + "TELRES": "예시값", + "KAORES": "예시값" +} +``` + +**응답 필드** (`data` 내부) + +이 엔드포인트는 `data` 를 반환하지 않는다. 비즈뿌리오 URL PUSH 규약상 수신 사실만 알리면 되므로, +성공·실패와 무관하게 `success: true` + 수신 확인 메시지만 담은 200 봉투를 돌려준다. +리포트 해석 결과(성공/실패·사유)는 `bizppurio_dispatches` 행에 기록되며 응답에는 싣지 않는다. + +**응답 예시** + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "success": true, + "message": "발송 결과 리포트를 수신했습니다." +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +비즈뿌리오가 문자·알림톡 발송 결과를 URL PUSH 로 통보하는 리포트 수신 엔드포인트다. 운영자가 이 주소를 비즈뿌리오에 등록하면(환경설정 화면의 리포트 수신 주소), 발송 후 결과가 이 엔드포인트로 전송된다. + +- **인증**: 코어 토큰/IDV 미들웨어를 라우트 레벨에서 제외하고, 인증을 IP 화이트리스트로 대체한다. 화이트리스트 밖 IP 는 403. IP 화이트리스트는 `plugin.php::getMiddleware()` 에서 이 라우트명(`api.plugins.sirsoft-message_bizppurio.webhook`)으로 self-gate 선언하며, 코어 게이트가 요청 시점에 부착한다(라우트 파일 직접 부착 아님). +- **처리**: `REFKEY` 로 발송 이력을 조회한다. 없으면(위조/미매칭) 200 으로 흡수한다. 이미 리포트가 반영된 이력(`reported_at` 존재)이면 replay 로 판정해 멱등 처리한다. 그 외에는 `RESULT` 코드를 분류(성공/실패/잔액부족)해 상태를 전이하고 `media`·`fallback_status`·`raw_payload`·`reported_at` 을 기록한다. +- **잔액부족**: `RESULT` 가 9070(문자)/7436(알림톡)이면 이력을 실패로 뒤집고 관리자에게 자체 알림을 1회 발송한다. +- 응답은 항상 200 이다(비즈뿌리오가 실패 응답을 재전송하지 않도록). replay 멱등이 중복 처리를 막는다. + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php new file mode 100644 index 00000000..d798ced0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php @@ -0,0 +1,10 @@ + [], + 'description' => [], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php new file mode 100644 index 00000000..abafde00 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -0,0 +1,187 @@ + [ + 'sms' => 'SMS', + 'lms' => 'LMS', + 'alimtalk' => 'Alimtalk', + ], + + // 채널 그룹 라벨 (SMS/LMS 를 "문자" 로 묶음 — 잔액부족 알림 등 채널군 표기용) + 'channel_group' => [ + 'text' => 'SMS', + 'alimtalk' => 'Alimtalk', + ], + + // 발송 상태 (DispatchStatus enum) + 'status' => [ + 'pending' => 'Pending', + 'sent' => 'Sending', + 'success' => 'Success', + 'failed' => 'Failed', + ], + + // 발송 출처 (DispatchSource enum) + 'source' => [ + 'auto' => 'Automatic', + 'manual' => 'Manual', + 'bulk' => 'Bulk', + ], + + // 결과코드 분류 (ResultCategory enum) + 'result_category' => [ + 'success' => 'Success', + 'retry' => 'Retry', + 'permanent_failure' => 'Permanent Failure', + 'balance_low' => 'Insufficient Balance', + ], + + // 알림 채널 메타 (core.notification.filter_available_channels) + 'channels' => [ + 'source_label' => 'Bizppurio', + 'sms' => [ + 'name' => 'Bizppurio SMS', + 'description' => 'Send notifications as SMS/LMS text messages via Bizppurio.', + ], + 'alimtalk' => [ + 'name' => 'Bizppurio Alimtalk', + 'description' => 'Send notifications as Kakao Alimtalk messages via Bizppurio.', + ], + ], + + // 채널 준비 상태 사유 (core.notification.channel_readiness) + 'readiness' => [ + 'sms_credentials_missing' => 'Please set the Bizppurio ID and password.', + 'sms_sender_number_missing' => 'Please set the sender number.', + 'alimtalk_api_key_missing' => 'Please set the Kakao management API key.', + 'alimtalk_sender_key_missing' => 'Please set the Alimtalk sender profile key.', + ], + + // 설정 검증 — 운영(live) 환경 필수 자격증명 항목 라벨 (validation.attributes 병합용) + 'settings' => [ + 'bizppurio_id_attribute' => 'Bizppurio ID', + 'password_attribute' => 'Password', + 'sender_number_attribute' => 'Sender Number', + ], + + // webhook(URL PUSH) 리포트 수신 + 'webhook' => [ + 'received' => 'Report received.', + ], + + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) + 'error' => [ + 'credentials_missing' => 'Please set the Bizppurio ID and password first.', + 'token_issue_failed' => 'Failed to issue the Bizppurio authentication token.', + 'token_issue_failed_with_reason' => 'Failed to issue the Bizppurio authentication token. (:reason)', + 'send_failed' => 'Failed to send the message.', + 'send_retryable' => 'Message delivery temporarily failed. (code: :code)', + 'invalid_response' => 'Unable to parse the Bizppurio response.', + 'connection_failed' => 'Unable to connect to the Bizppurio server. Please try again later.', + '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.', + '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_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 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.', + ], + + // 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', + 'comment' => 'reviewer comment', + '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) + 'token_check' => [ + 'success' => 'Authentication verified successfully. The ID and password are correct.', + 'failed' => 'Authentication check failed. See the detailed reason.', + ], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php new file mode 100644 index 00000000..7ebca552 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php @@ -0,0 +1,76 @@ + 'Success', + '2000' => 'Message is invalid', + '3001' => 'Invalid authentication (Basic)', + '3002' => 'Invalid token (expired/revoked)', + '3003' => 'Invalid IP', + '3004' => 'Account is invalid', + '3005' => 'Invalid authentication (Bearer)', + '3006' => 'Account does not exist', + '3007' => 'Invalid account password', + '3009' => 'Account suspended', + '3010' => 'IP not in allowlist', + '3011' => 'Unknown error (Bizppurio)', + '3013' => 'Message not completed', + '5002' => 'Too many requests', + '5003' => 'Temporary delivery error', + '5004' => 'Temporary delivery error', + '5005' => 'Temporary delivery error', + '9000' => 'Temporary system error', + '9070' => 'Insufficient balance (SMS)', + '9071' => 'Postpaid limit exceeded', + + // ── SMS report ─────────────────────────────────────── + '4100' => 'Success', + '4400' => 'Out of service area', + '4401' => 'Power off', + '4402' => 'Storage full', + '4410' => 'Invalid number', + '4414' => 'Disconnected/suspended number', + '4420' => 'Other device error', + '4430' => 'Spam', + '4431' => 'Delivery-restricted opt-out (spam)', + '4443' => 'Spam blocked', + + // ── LMS report ─────────────────────────────────────── + '6600' => 'Success', + '6603' => 'Out of service area', + '6604' => 'Power off', + '6606' => 'Invalid number', + '6621' => 'Message length exceeded', + '6641' => 'Spam blocked', + + // ── Alimtalk report ────────────────────────────────── + '7000' => 'Success', + '7103' => 'Invalid sender profile key', + '7106' => 'Deleted sender key', + '7107' => 'Blocked sender key', + '7204' => 'Message content does not match template', + '7206' => 'Serial number format mismatch', + '7306' => 'Kakao system error', + '7307' => 'Processing delayed', + '7308' => 'Phone number error', + '7320' => 'Receiver blocked', + '7325' => 'Variable length exceeded', + '7421' => 'Timeout', + '7436' => 'Insufficient wallet balance (Alimtalk)', + '7437' => 'Message request failed', + '7523' => '080 opt-out (spam)', +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php new file mode 100644 index 00000000..14c72a84 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php @@ -0,0 +1,10 @@ + [], + 'description' => [], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php new file mode 100644 index 00000000..03bfeb60 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -0,0 +1,187 @@ + [ + 'sms' => 'SMS', + 'lms' => 'LMS', + 'alimtalk' => '알림톡', + ], + + // 채널 그룹 라벨 (SMS/LMS 를 "문자" 로 묶음 — 잔액부족 알림 등 채널군 표기용) + 'channel_group' => [ + 'text' => '문자', + 'alimtalk' => '알림톡', + ], + + // 발송 상태 (DispatchStatus enum) + 'status' => [ + 'pending' => '대기', + 'sent' => '발송중', + 'success' => '성공', + 'failed' => '실패', + ], + + // 발송 출처 (DispatchSource enum) + 'source' => [ + 'auto' => '자동', + 'manual' => '수동', + 'bulk' => '대량', + ], + + // 결과코드 분류 (ResultCategory enum) + 'result_category' => [ + 'success' => '성공', + 'retry' => '재시도', + 'permanent_failure' => '영구 실패', + 'balance_low' => '잔액 부족', + ], + + // 알림 채널 메타 (core.notification.filter_available_channels) + 'channels' => [ + 'source_label' => '비즈뿌리오', + 'sms' => [ + 'name' => '비즈뿌리오 문자', + 'description' => '비즈뿌리오를 통해 문자(SMS/LMS)로 알림을 발송합니다.', + ], + 'alimtalk' => [ + 'name' => '비즈뿌리오 알림톡', + 'description' => '비즈뿌리오를 통해 카카오 알림톡으로 알림을 발송합니다.', + ], + ], + + // 채널 준비 상태 사유 (core.notification.channel_readiness) + 'readiness' => [ + 'sms_credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 설정하세요.', + 'sms_sender_number_missing' => '발신번호를 설정하세요.', + 'alimtalk_api_key_missing' => '카카오 관리 API 키를 설정하세요.', + 'alimtalk_sender_key_missing' => '알림톡 발신프로필 키를 설정하세요.', + ], + + // 설정 검증 — 운영(live) 환경 필수 자격증명 항목 라벨 (validation.attributes 병합용) + 'settings' => [ + 'bizppurio_id_attribute' => '비즈뿌리오 아이디', + 'password_attribute' => '비밀번호', + 'sender_number_attribute' => '발신번호', + ], + + // webhook(URL PUSH) 리포트 수신 + 'webhook' => [ + 'received' => '리포트를 수신했습니다.', + ], + + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) + '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' => '알림톡 발신프로필 키를 먼저 설정하세요.', + 'image_unreadable' => '업로드한 이미지 파일을 읽을 수 없습니다.', + ], + + // 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨) + 'send_skipped' => [ + 'alimtalk_template_not_approved' => '승인된 알림톡 템플릿이 없거나 알림톡 발송이 꺼져 있어 발송을 건너뛰었습니다. (알림 유형: :type)', + 'sms_template_missing' => 'SMS 본문이 설정되지 않아 발송을 건너뛰었습니다. (알림 유형: :type)', + 'recipient_phone_missing' => '수신자 전화번호가 없어 발송을 건너뛰었습니다. (알림 유형: :type)', + 'message_body_empty' => '발송 본문이 비어 있어 발송을 건너뛰었습니다. (알림 유형: :type)', + ], + + // 알림 템플릿 라이프사이클 (#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' => '템플릿 코드를 채번하지 못했습니다. 잠시 후 다시 시도하세요.', + ], + + // 알림 템플릿 검증 (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' => '본문', + 'comment' => '검수자 전달 의견', + '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 응답) + 'token_check' => [ + 'success' => '인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다.', + 'failed' => '인증 확인에 실패했습니다. 상세 사유를 확인해 주세요.', + ], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php new file mode 100644 index 00000000..cfd19c83 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php @@ -0,0 +1,79 @@ + '성공', + '2000' => '메시지가 유효하지 않음', + '3001' => '인증 정보 무효(Basic)', + '3002' => '토큰 무효(만료·폐기)', + '3003' => 'IP 무효', + '3004' => '계정이 유효하지 않음', + '3005' => '인증 정보 무효(Bearer)', + '3006' => '계정이 존재하지 않음', + '3007' => '계정 암호 무효', + '3009' => '계정 중지 상태', + '3010' => '접속 허용 IP 불일치', + '3011' => '알 수 없는 오류(비즈뿌리오)', + '3013' => '완료 처리되지 않은 메시지', + '5002' => '요청 과다', + '5003' => '일시적 전송 오류', + '5004' => '일시적 전송 오류', + '5005' => '일시적 전송 오류', + '9000' => '일시적 시스템 오류', + '9070' => '잔액 부족(문자)', + '9071' => '후불 한도 초과', + + // ── SMS 리포트 ──────────────────────────────────────── + '4100' => '성공', + '4400' => '음영 지역', + '4401' => '전원 꺼짐', + '4402' => '저장 매체 초과', + '4410' => '잘못된 번호', + '4414' => '결번·정지', + '4420' => '기타 단말 오류', + '4430' => '스팸', + '4431' => '발송 제한 수신거부(스팸)', + '4443' => '스팸 차단', + + // ── LMS 리포트 ──────────────────────────────────────── + '6600' => '성공', + '6603' => '음영 지역', + '6604' => '전원 꺼짐', + '6606' => '잘못된 번호', + '6621' => '메시지 길이 초과', + '6641' => '스팸 차단', + + // ── 알림톡 리포트 ───────────────────────────────────── + '7000' => '성공', + '7103' => '발신 프로필 키 무효', + '7106' => '삭제된 발신 키', + '7107' => '차단된 발신 키', + '7204' => '메시지 내용이 템플릿과 불일치', + '7206' => '시리얼넘버 형식 불일치', + '7306' => '카카오 시스템 오류', + '7307' => '처리 지연', + '7308' => '전화번호 오류', + '7320' => '수신 차단', + '7325' => '변수 길이 초과', + '7421' => '타임아웃', + '7436' => '지갑 잔액 부족(알림톡)', + '7437' => '메시지 요청 실패', + '7523' => '080 수신거부(스팸)', +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/package.json b/plugins/_bundled/sirsoft-message_bizppurio/package.json new file mode 100644 index 00000000..3a8d1cf9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/package.json @@ -0,0 +1,22 @@ +{ + "name": "@plugins/sirsoft-message_bizppurio", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest", + "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", + "jsdom": "^25.0.0", + "typescript": "^5.6.0", + "vite": "^5.4.14", + "vitest": "^2.1.0" + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json new file mode 100644 index 00000000..1e163be5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json @@ -0,0 +1,32 @@ +{ + "identifier": "sirsoft-message_bizppurio", + "vendor": "sirsoft", + "name": { + "ko": "비즈뿌리오 메시지 발송", + "en": "Bizppurio Messaging" + }, + "version": "1.0.0", + "description": { + "ko": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.", + "en": "Bizppurio SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook." + }, + "license": "MIT", + "g7_version": ">=7.0.6", + "dependencies": { + "modules": {}, + "plugins": {} + }, + "github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio", + "github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio/blob/main/CHANGELOG.md", + "assets": { + "js": { + "entry": "resources/js/index.ts", + "output": "dist/js/plugin.iife.js" + }, + "handlers": true + }, + "loading": { + "strategy": "global", + "priority": 100 + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php new file mode 100644 index 00000000..3722d29f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -0,0 +1,495 @@ + 'Sirsoft', + 'license' => 'MIT', + 'homepage' => 'https://sir.kr', + 'keywords' => ['bizppurio', 'sms', 'lms', 'alimtalk', 'kakao', 'messaging', 'notification'], + ]; + } + + /** + * 플러그인 활성화 — 기존 회원 알림에 sms·alimtalk template 을 즉시 증강. + * + * SeedChannelTemplatesListener 는 코어/모듈이 알림 정의를 *시딩할 때* 필터 훅으로 증강한다. + * 그러나 플러그인 활성화 시점에는 코어/모듈 정의가 이미 시딩돼 있어(우리 채널 없이) 다음 + * 재시딩까지 alimtalk template 이 생기지 않는다. 따라서 활성화 시 코어·활성 모듈의 알림 + * 정의를 재시드해, 이제 활성화된 우리 필터를 통과시켜 기존 정의에도 채널을 즉시 반영한다. + * + * 재시드는 모두 user_overrides 보존 upsert(멱등)이며 코어·모듈 파일을 수정하지 않는다. + * 실패는 로그만 남기고 활성화 자체는 막지 않는다(발송·연동은 채널 등록만으로도 동작하며, + * template 은 다음 정상 재시딩에도 수렴). + * + * 리스너 선등록: PluginManager::activatePlugin() 은 DB status 를 active 로 바꾸기 + * '전에' 이 activate() 를 호출한다. 그런데 registerPluginHookListeners() 의 active 가드는 + * status=active 인 플러그인의 리스너만 등록하므로, 이 시점엔 SeedChannelTemplatesListener 가 + * 아직 등록되지 않았다. 그대로 재시딩하면 시딩 필터에 우리 리스너가 편승하지 못해 + * sms·alimtalk 채널 template 이 붙지 않는다(실서버 회귀). 따라서 재시딩 전에 이 리스너를 + * 명시적으로 선등록한다. register() 는 동일 process 내 중복 등록을 막으므로 멱등하다. + * + * @return bool 활성화 성공 여부 + */ + public function activate(): bool + { + try { + // 재시딩이 편승할 시딩 필터 리스너를 status=active 전환 이전에 명시적으로 등록한다. + HookListenerRegistrar::register(SeedChannelTemplatesListener::class, $this->getIdentifier()); + + app(NotificationDefinitionSeeder::class)->run(); + app(ModuleManager::class)->resyncAllActiveDeclarativeArtifacts(); + } catch (\Throwable $e) { + Log::warning('[sirsoft-message_bizppurio] 알림 채널 template 증강 재시드 실패', [ + 'error' => $e->getMessage(), + ]); + } + + return true; + } + + /** + * 플러그인 비활성화 — 이 플러그인 소속 관리자 메뉴 잔재 정리. + * + * 이 플러그인은 관리자 메뉴를 만들지 않는다(화면 배치 결정 2026-07-14 — 진입은 코어 + * 소유 설정 페이지 하나로 통일). 다만 이전 버전(getAdminMenus 사용 시기)에 생성된 + * 메뉴 row 가 DB 에 남아 있을 수 있어, 비활성화 시 currentSlugs=[] 로 cleanupStaleMenus + * 를 호출해 잔재를 청소한다. 재활성화 시 아무 메뉴도 만들지 않으므로 정상 무메뉴 상태로 + * 수렴한다(멱등). 자식 메뉴 + role_menus 피벗은 helper 가 cascade 처리. + * + * SeedChannelTemplatesListener 의 HookListenerRegistrar 등록 이력 캐시를 함께 지운다 — + * activate() 가 register() 로 등록한 뒤(process-wide idempotency 캐시, source::class 키), + * deactivate/uninstall 후 재활성화해도 이 캐시가 "이미 등록됨"으로 남아있으면 재등록이 + * 조용히 skip 되어 재시딩 시 sms·alimtalk 채널이 다시 붙지 않는다(회귀). 코어 + * HookListenerRegistrar 에는 특정 키 1건만 지우는 API 가 없고(clear() 는 전체 초기화라 + * 다른 확장의 등록 상태까지 날아감), 코어 수정 없이 이 플러그인 내부에서만 해결하기 + * 위해 리플렉션으로 private static $registered 캐시에서 이 키만 직접 제거한다. + * + * @return bool 비활성화 성공 여부 + */ + public function deactivate(): bool + { + app(ExtensionMenuSyncHelper::class)->cleanupStaleMenus( + ExtensionOwnerType::Plugin, + $this->getIdentifier(), + currentSlugs: [], + ); + + $this->forgetSeedChannelTemplatesListenerRegistration(); + + return true; + } + + /** + * HookListenerRegistrar::$registered 캐시에서 SeedChannelTemplatesListener 등록 이력만 제거합니다. + * + * 코어 HookListenerRegistrar 는 개별 키 삭제 API 를 제공하지 않으므로(clear() 는 전체 + * 초기화), 코어를 수정하지 않고 이 캐시를 조작하기 위해 리플렉션을 사용한다. 실패해도 + * (리플렉션 예외 등) 치명적이지 않으므로 조용히 무시한다 — 최악의 경우 이번 재활성화만 + * 채널 재시딩이 안 붙고, 운영자가 다시 활성화하면 정상화된다. + */ + private function forgetSeedChannelTemplatesListenerRegistration(): void + { + try { + $ref = new \ReflectionClass(HookListenerRegistrar::class); + $prop = $ref->getProperty('registered'); + $registered = $prop->getValue(); + $key = $this->getIdentifier().'::'.SeedChannelTemplatesListener::class; + unset($registered[$key]); + $prop->setValue(null, $registered); + } catch (\Throwable $e) { + Log::warning('[sirsoft-message_bizppurio] SeedChannelTemplatesListener 등록 캐시 초기화 실패', [ + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 플러그인 제거 — 메뉴 잔재 안전망(정상 흐름은 deactivate 가 먼저 처리) + sms·alimtalk 채널 잔재 정리. + * + * SeedChannelTemplatesListener 가 코어/게시판/이커머스 소유 알림 정의에 필터 훅으로 끼워넣은 + * sms·alimtalk template 은, 그 정의가 우리 소유가 아니므로 PluginManager 의 범용 알림 정의 + * 정리(cleanupStaleDefinitions('plugin', ...))에 걸리지 않는다. 코어는 이 채널의 존재나 + * 소유자를 몰라야 하므로, 코어를 수정하지 않고 우리가 직접 우리 채널 상수 기준으로 정리한다. + * + * "데이터도 함께 삭제" 옵션과 무관하게 항상 정리한다 — sms·alimtalk 은 이 플러그인 없이는 + * 발송이 불가능한 죽은 설정이라 보존할 가치가 없고, 재설치 시 activate() 가 재시딩하며 + * 자동으로 복원된다. + * + * 반대로 우리 소유 테이블(bizppurio_templates·bizppurio_dispatches)은 여기서 손대지 않는다. + * 코어 PluginManager 가 "데이터도 함께 삭제" 를 선택했을 때만 rollbackMigrations() 로, + * 그것도 uninstall() 호출 **이전에** 제거하기 때문이다. 여기에 dropIfExists 를 더하면 + * 테이블 정리 책임이 두 곳으로 갈라지고, 데이터 보존을 선택한 운영자의 테이블까지 지운다. + * + * @return bool 제거 성공 여부 + */ + public function uninstall(): bool + { + $this->deactivate(); + $this->cleanupChannelContributions(); + + return true; + } + + /** + * sms·alimtalk 채널 template 및 definitions.channels 배열에서 우리 채널 잔재를 정리합니다. + * + * 삭제 대상 채널은 우리가 이미 아는 채널 상수(RegisterNotificationChannelsListener::CHANNEL_IDS) + * 이므로, 각 정의가 원래 어떤 채널로 구성돼 있었는지 사전 지식 없이 "현재 DB 채널 목록 − + * 우리 채널" 만으로 남길 목록을 계산할 수 있다. template 삭제는 코어 공개 메서드 + * NotificationSyncHelper::cleanupStaleTemplates() 를 그대로 사용해 로깅·모델 이벤트 등 + * 코어 도메인 규칙을 그대로 따른다. + */ + private function cleanupChannelContributions(): void + { + $myChannels = RegisterNotificationChannelsListener::CHANNEL_IDS; + $helper = app(NotificationSyncHelper::class); + + $definitionIds = DB::table('notification_templates') + ->whereIn('channel', $myChannels) + ->distinct() + ->pluck('definition_id'); + + foreach ($definitionIds as $definitionId) { + $current = DB::table('notification_templates') + ->where('definition_id', $definitionId) + ->pluck('channel') + ->all(); + $keep = array_values(array_diff($current, $myChannels)); + + $helper->cleanupStaleTemplates($definitionId, $keep); + + $definition = NotificationDefinition::find($definitionId); + if ($definition) { + $definition->channels = $keep; + $definition->save(); + } + } + } + + /** + * 플러그인 권한 목록 반환 (계층 구조) + * + * PluginManager 가 1레벨(플러그인 노드) → 2레벨(카테고리) → 3레벨(개별 권한) 트리로 등록. + * 모든 권한은 admin 역할에 매핑. + * + * 권한 분할 의도: + * - view: 발송 이력·알림톡 템플릿 조회 (모니터링) + * - manage: 환경설정·템플릿 등록/검수·이벤트 연동 (운영) + * + * @return array 권한 정의 배열 (categories 계층 구조) + */ + public function getPermissions(): array + { + return [ + 'name' => [ + 'ko' => '비즈뿌리오 메시지 발송', + 'en' => 'Bizppurio Messaging', + ], + 'description' => [ + 'ko' => '비즈뿌리오 메시지 발송 플러그인이 제공하는 권한', + 'en' => 'Permissions provided by the Bizppurio Messaging plugin', + ], + 'categories' => [ + [ + 'identifier' => 'messaging', + 'name' => ['ko' => '메시지 발송', 'en' => 'Messaging'], + 'description' => [ + 'ko' => '메시지 발송 도메인 권한 (조회·관리)', + 'en' => 'Messaging domain permissions (view, manage)', + ], + 'permissions' => [ + [ + 'action' => 'view', + 'name' => ['ko' => '메시지 조회', 'en' => 'View Messaging'], + 'description' => [ + 'ko' => '발송 이력·알림톡 템플릿 조회 (모니터링)', + 'en' => 'View dispatch history and alimtalk templates (monitoring)', + ], + 'type' => 'admin', + 'roles' => ['admin'], + ], + [ + 'action' => 'manage', + 'name' => ['ko' => '메시지 관리', 'en' => 'Manage Messaging'], + 'description' => [ + 'ko' => '환경설정·알림톡 템플릿 등록/검수·이벤트 연동 관리', + 'en' => 'Manage settings, alimtalk template registration/inspection, and event bindings', + ], + 'type' => 'admin', + 'roles' => ['admin'], + ], + ], + ], + ], + ]; + } + + /** + * 플러그인 설정 스키마 반환 + * + * 관리자 설정 페이지 UI 를 동적으로 생성하는 데 사용됩니다. 크리덴셜(비밀번호·API 키)은 + * sensitive 로 마킹하여 마스킹하며, frontend_schema(defaults.json)에서 expose:false 로 + * 프론트 노출을 차단합니다. + * + * 발송 시스템(account)과 카카오 관리 시스템(bizId)의 식별자는 동일한 '비즈뿌리오 아이디' + * 이므로 bizppurio_id 단일 필드로 받는다. + * + * @return array 설정 스키마 + */ + public function getSettingsSchema(): array + { + return [ + 'is_test_mode' => [ + 'type' => 'boolean', + 'default' => true, + 'label' => ['ko' => '검수 모드', 'en' => 'Test Mode'], + 'hint' => [ + 'ko' => '검수 모드를 끄면 운영 환경으로 발송됩니다. 발송 API 도메인이 환경에 따라 분기됩니다.', + 'en' => 'Turn off test mode to send in the production environment. The sending API domain differs by environment.', + ], + 'required' => false, + ], + 'bizppurio_id' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '비즈뿌리오 아이디', 'en' => 'Bizppurio ID'], + 'hint' => [ + 'ko' => '발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다.', + 'en' => 'The Bizppurio account ID used for both sending and Kakao management.', + ], + 'required' => false, + ], + 'password' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '비밀번호', 'en' => 'Password'], + 'hint' => [ + 'ko' => '발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다.', + 'en' => 'The Bizppurio password used to issue the sending token.', + ], + 'sensitive' => true, + 'required' => false, + ], + 'api_key' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => 'API 키', 'en' => 'API Key'], + 'hint' => [ + 'ko' => '카카오 관리(알림톡 템플릿·발신프로필)에 사용하는 API 키입니다. 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다.', + 'en' => 'The API key used for Kakao management (alimtalk templates, sender profiles).', + ], + 'sensitive' => true, + 'required' => false, + ], + 'sender_number' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '발신번호', 'en' => 'Sender Number'], + 'hint' => [ + 'ko' => '문자·알림톡 발송에 사용하는 발신 전화번호입니다.', + 'en' => 'The sender phone number used for SMS and alimtalk delivery.', + ], + 'required' => false, + ], + 'sender_key' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '알림톡 발신프로필 키', 'en' => 'Alimtalk Sender Profile Key'], + 'hint' => [ + 'ko' => '알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다.', + 'en' => 'The 40-character sender profile key used for alimtalk delivery and template lookup.', + ], + 'sensitive' => true, + 'required' => false, + ], + ]; + } + + /** + * 플러그인 설정 기본값 반환 (하위 호환) + * + * 신규 설치 기본값은 config/settings/defaults.json 의 defaults 섹션이 1순위이며, + * 본 메서드는 하위 호환 경로로 동일 값을 반환한다. + * + * @return array 기본 설정값 + */ + public function getConfigValues(): array + { + return [ + 'is_test_mode' => true, + 'bizppurio_id' => '', + 'password' => '', + 'api_key' => '', + 'sender_number' => '', + 'sender_key' => '', + ]; + } + + /** + * 이 플러그인이 등록할 알림 정의/템플릿 선언을 반환합니다. + * + * Phase 1 범위: 비즈뿌리오 지갑 잔액부족 시 관리자에게 보내는 자체 알림 1건. + * (webhook 결과코드 9070/7436 감지 시 Phase 4 에서 이 알림을 발화) + * + * ※ 3영역(코어/게시판/이커머스) 알림톡 채널 기본 body 시드는 알림톡 채널 등록(Phase 3)· + * 탭 연동(Phase 6)과 강결합이므로 Phase 6 으로 이관한다. 그때 본 메서드에 정의를 추가한다. + * + * @return array> + */ + public function getNotificationDefinitions(): array + { + return [ + [ + 'type' => 'bizppurio_balance_low', + 'hook_prefix' => 'sirsoft-message_bizppurio', + 'name' => [ + 'ko' => '비즈뿌리오 잔액 부족', + 'en' => 'Bizppurio Balance Low', + ], + 'description' => [ + 'ko' => '비즈뿌리오 지갑 잔액이 부족해 문자/알림톡 발송이 실패했을 때 관리자에게 발송', + 'en' => 'Sent to admin when a message fails due to insufficient Bizppurio wallet balance', + ], + 'channels' => ['mail', 'database'], + 'hooks' => ['sirsoft-message_bizppurio.balance.low'], + 'variables' => [ + ['key' => 'name', 'description' => '수신자(관리자) 이름'], + ['key' => 'app_name', 'description' => '사이트 이름'], + ['key' => 'result_code', 'description' => '결과 코드(9070 문자 / 7436 알림톡)'], + ['key' => 'channel_label', 'description' => '발송 채널(문자/알림톡)'], + ['key' => 'settings_url', 'description' => '메시징 환경설정 URL'], + ['key' => 'site_url', 'description' => '사이트 URL'], + ], + 'templates' => [ + [ + 'channel' => 'mail', + 'recipients' => [['type' => 'role', 'value' => 'admin']], + 'subject' => [ + 'ko' => '[{app_name}] 비즈뿌리오 잔액이 부족합니다', + 'en' => '[{app_name}] Bizppurio balance is insufficient', + ], + 'body' => [ + 'ko' => '{name}님, 비즈뿌리오 지갑 잔액이 부족하여 {channel_label} 발송이 실패했습니다 (코드: {result_code}). 충전 후 발송이 정상화됩니다.', + 'en' => 'Dear {name}, a {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}). Delivery resumes after recharging.', + ], + ], + [ + 'channel' => 'database', + 'recipients' => [['type' => 'role', 'value' => 'admin']], + 'subject' => [ + 'ko' => '비즈뿌리오 잔액 부족', + 'en' => 'Bizppurio balance low', + ], + 'body' => [ + 'ko' => '비즈뿌리오 잔액 부족으로 {channel_label} 발송이 실패했습니다 (코드: {result_code}).', + 'en' => 'A {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}).', + ], + ], + ], + ], + ]; + } + + /** + * 훅 리스너 목록 반환 + * + * - RegisterNotificationChannelsListener: 채널 등록/readiness/3영역 노출(Phase 3) + * - SeedChannelTemplatesListener: 회원 알림에 sms·alimtalk template 증강(Phase 6 결정 D — 시딩 필터 훅) + * - GuestPhoneExtractListener: 비회원 주문 전화번호 주입(Phase 3) + * - LinkNotificationLogListener: 코어 알림 로그↔dispatch 연결(A-2 — 발송 이력 결과 주입 연결고리) + * - ValidateBizppurioSettingsListener: 환경설정 검증 + * - InvalidateTokenOnSettingsSaveListener: 설정 저장 시 인증 토큰 캐시 무효화 + * + * @return array + */ + public function getHookListeners(): array + { + return [ + RegisterNotificationChannelsListener::class, + SeedChannelTemplatesListener::class, + GuestPhoneExtractListener::class, + BalanceLowNotificationDataListener::class, + LinkNotificationLogListener::class, + ValidateBizppurioSettingsListener::class, + InvalidateTokenOnSettingsSaveListener::class, + ]; + } + + /** + * 스케줄 작업 목록 반환 (#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) + * + * webhook(URL PUSH) 리포트 수신 엔드포인트에만 IP 화이트리스트를 부착한다. + * 라우트 파일에서 직접 부착하지 않고 코어 게이트(ExtensionMiddlewareGate)가 + * 요청 시점에 라우트 이름을 대조해 매칭될 때만 실행한다. + * + * @return array> + */ + public function getMiddleware(): array + { + return [ + [ + 'class' => BizppurioWebhookIpWhitelist::class, + 'groups' => ['api'], + 'targets' => [ + 'api.plugins.sirsoft-message_bizppurio.webhook', + ], + ], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json new file mode 100644 index 00000000..fd8e707c --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json @@ -0,0 +1,210 @@ +{ + "target_layout": "admin_notification_log_list", + "comment": "코어 알림 발송 이력 화면에 비즈뿌리오 발송 결과 컬럼을 얹는다(A-2). 코어 화면·코어 앱·코어 테이블은 전혀 건드리지 않는다 — 연결 표식은 우리 dispatch(bizppurio_dispatches.notification_log_id)에만 있고, 현재 페이지의 코어 로그 id 배열로 결과를 배치 조회(dispatchResults)해 row.id 로 매칭한다. 매칭 안 되는 행(메일·DB 등 비-비즈뿌리오)은 빈 셀. 컬럼은 코어 datagrid columns 배열 끝에 _append 로 1개만 추가(전체 컬럼 재정의 안 함).", + "data_sources": [ + { + "id": "dispatchResults", + "label_key": "$t:sirsoft-message_bizppurio.editor.data_source.dispatch_results", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "loading_strategy": "progressive", + "fallback": { + "data": { + "results": {} + } + } + } + ], + "injections": [ + { + "target_id": "notification_log_datagrid", + "position": "inject_props", + "comment": "코어 datagrid columns 끝에 비즈뿌리오 결과 컬럼 1개만 추가. columns 는 정적 배열이라 _append 적용 가능.", + "props": { + "expandChildren": { + "_append": [ + { + "comment": "행 토글(펼침) 안에도 비즈뿌리오 결과를 넣는다. 비즈뿌리오 발송 행(결과 매칭됨)일 때만 노출 — 메일·사이트내알림 등 매칭 안 되는 행에선 아무것도 안 보인다.", + "type": "basic", + "name": "Div", + "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]}}", + "props": { + "className": "p-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-comment-dots", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm font-medium text-gray-800 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.detail_title" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex flex-wrap items-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" + }, + "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? $t('sirsoft-message_bizppurio.dispatch_result.inspection_label') : (dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_low_balance === true}}", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-orange-50" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.low_balance" + }, + { + "type": "basic", + "name": "Span", + "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-700 dark:text-sky-100" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}" + } + ] + }, + { + "comment": "알림톡 실제 발송 내용 — 코어 '본문'(notification_logs.body)은 SMS 대체발송용 코어 템플릿 값이라 실제 카카오 발송 내용(승인 템플릿 전체)과 다르다. row.channel==='alimtalk'이고 실제 발송 내용이 있을 때만 별도 노출해 혼동을 방지한다.", + "type": "basic", + "name": "Div", + "if": "{{row.channel === 'alimtalk' && !!(dispatchResults?.data?.results ?? {})[row.id]?.content}}", + "props": { + "className": "pt-2 border-t border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.sent_content_label" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-sm text-gray-700 dark:text-gray-200 whitespace-pre-line admin-card" + }, + "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.content}}" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.sent_content_hint" + } + ] + } + ] + } + ] + }, + "columns": { + "_append": [ + { + "field": "bizppurio_result", + "header": "$t:sirsoft-message_bizppurio.dispatch_result.column_header", + "width": "200px", + "sortable": false, + "required": false, + "comment": "컬럼(헤더 포함) 자체를 탭별로 노출 제어. 컬럼 정의는 datagrid props 로 페이지 컨텍스트에서 평가되므로(셀과 달리) query 접근 가능. 메일·사이트내알림(mail/database) 탭이면 hidden=true 로 컬럼 숨김. 전체·sms·lms·알림톡 탭에선 표시.", + "hidden": "{{['mail','database'].includes(query.channel ?? '')}}", + "cellChildren": [ + { + "comment": "비즈뿌리오 발송 행(row.channel 이 sms/lms/alimtalk)에서만 결과 표시. 메일·사이트내알림(mail/database) 행은 셀을 비운다. 셀 렌더 컨텍스트는 row/value 만 있고 query 는 없으므로(코어 DataGrid renderCellChildren 계약) 탭이 아니라 row.channel 로 판별한다.", + "type": "basic", + "name": "Div", + "if": "{{['sms','lms','alimtalk'].includes(row.channel)}}", + "children": [ + { + "type": "basic", + "name": "Span", + "if": "{{!(dispatchResults?.data?.results ?? {})[row.id]}}", + "props": { + "className": "text-gray-300 dark:text-gray-600" + }, + "text": "-" + }, + { + "type": "basic", + "name": "Div", + "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]}}", + "props": { + "className": "flex flex-col gap-1 items-start" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" + }, + "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? $t('sirsoft-message_bizppurio.dispatch_result.inspection_label') : (dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_low_balance === true}}", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-orange-50" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.low_balance" + }, + { + "type": "basic", + "name": "Span", + "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-700 dark:text-sky-100" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}" + } + ] + } + ] + } + ] + } + ] + } + } + } + ], + "priority": 330 +} 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 new file mode 100644 index 00000000..28055f82 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json @@ -0,0 +1,195 @@ +{ + "extension_point": "notification_definition_row_footer", + "comment": "알림 설정 '비즈뿌리오' 통합 탭의 각 알림 행 하단 상태 요약(제품 결정 2026-08-23 — 버튼 없음, 조작은 코어 [편집] 모달로 일원화). activeChannel==='alimtalk'(통합 탭 정체성)일 때만 노출. 요약 데이터는 bizppurioTemplates(각 탭 오버레이가 등록·조회, GET templates/map)에서 def.type 으로 읽는다. 1줄: 알림톡 승인 2단 배지 + 신청/동기화일 + 발송 사용 여부 / 2줄: 문자(SMS) 대체·단독 사용 여부 + 본문 미리보기.", + "priority": 320, + "components": [ + { + "id": "bizppurio_row_lifecycle", + "type": "basic", + "name": "Div", + "if": "{{extensionPointProps.activeChannel === 'alimtalk'}}", + "props": { + "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 flex-wrap gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-comment-dots", + "className": "text-sm text-teal-600 dark:text-teal-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm font-medium text-gray-700 dark:text-gray-200 flex-shrink-0" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_label" + }, + { + "type": "basic", + "name": "Span", + "comment": "승인 여부 2단 배지(제품 결정 2026-08-23): 1차 = 승인됨/미승인, 2차(괄호) = 세부 상태(미작성·작성중·검수중·반려·휴면·중지·차단). approved 는 세부 없이 단독 표기.", + "props": { + "className": "{{'inline-flex items-center gap-1 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') === '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' : (((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' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "text": "{{$t('sirsoft-message_bizppurio.template.approval.' + ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'approved' ? 'approved' : 'not_approved'))}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{(((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') !== 'approved'}}", + "props": { + "className": "font-normal opacity-80" + }, + "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')) + ')'}}" + } + ] + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.synced_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at ?? '').slice(5, 10)}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled === false)}}", + "props": { + "className": "text-xs px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_disabled" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-comment-sms", + "className": "text-sm text-gray-500 dark:text-gray-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm font-medium text-gray-700 dark:text-gray-200 flex-shrink-0" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.sms_label" + }, + { + "id": "bizppurio_row_sms_fallback", + "type": "basic", + "name": "Div", + "comment": "fallback_sms 상태 배지 — 사용(초록)/미사용(회색). 문구 대신 배지(제품 결정 2026-08-23, 행의 활성·기본·승인 배지와 일관).", + "props": { + "className": "flex-center gap-1 flex-shrink-0" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms" + }, + { + "id": "bizppurio_row_sms_fallback_badge", + "type": "basic", + "name": "Span", + "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]?.fallback_sms_enabled) ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + }, + "text": "{{$t('sirsoft-message_bizppurio.template.row.' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled) ? 'on' : 'off'))}}" + } + ] + }, + { + "id": "bizppurio_row_sms_only", + "type": "basic", + "name": "Div", + "comment": "sms_only 상태 배지 — 사용(초록)/미사용(회색). 문구 대신 배지(제품 결정 2026-08-23, 행의 활성·기본·승인 배지와 일관).", + "props": { + "className": "flex-center gap-1 flex-shrink-0" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.sms_only" + }, + { + "id": "bizppurio_row_sms_only_badge", + "type": "basic", + "name": "Span", + "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]?.sms_only) ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + }, + "text": "{{$t('sirsoft-message_bizppurio.template.row.' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only) ? 'on' : 'off'))}}" + } + ] + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body === true}}", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 truncate max-w-md" + }, + "text": "{{$t('sirsoft-message_bizppurio.template.row.sms_body_prefix') + ' ' + (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '')}}" + }, + { + "id": "bizppurio_row_sms_body_missing", + "type": "basic", + "name": "Span", + "comment": "본문 미설정 = 값 없음 — '발송 사용 안 함' 칩과 같은 외곽선 스타일.", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body !== true}}", + "props": { + "className": "text-xs px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 flex-shrink-0" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.sms_body_missing" + } + ] + } + ] + } + ] +} 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 new file mode 100644 index 00000000..e0720fd0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json @@ -0,0 +1,202 @@ +{ + "target_layout": "admin_board_settings", + "comment": "[게시판 면 — notification_tab_core.json 과 동일 패턴, target/컴포넌트 id 만 게시판 전용] 알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3, 제품 결정 2026-08-23 개정). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회 — 행 하단 상태 요약(notification_row_footer.json)과 [편집] 모달 섹션(notification_template_form_sections.json)이 공유한다 ③ 작성 셀렉트용 카테고리·발신프로필 데이터소스. 알림톡 작성/검수/SMS 본문 편집 UI 는 코어 [편집] 모달에 주입되므로 이 오버레이는 더 이상 모달을 등록하지 않는다.", + "data_sources": [ + { + "id": "bizppurioTemplates", + "comment": "행 라이프사이클 표시용 요약 맵(notification_type 키). auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지, 비즈뿌리오 탭 진입 시 init_actions 로 조회.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates/map", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "fallback": { + "data": { + "templates": {} + } + } + }, + { + "id": "bizppurioCategories", + "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 자격증명 미설정 422 는 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" + } + }, + "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 + } + } + } + ], + "injections": [ + { + "target_id": "board_notif_channel_content", + "position": "prepend_child", + "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))}}", + "props": { + "className": "mt-4 mb-3 space-y-2" + }, + "children": [ + { + "id": "bizppurio_board_banner_not_ready", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false}}", + "props": { + "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.not_ready", + "props": { + "className": "text-sm text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2" + } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" + }, + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-message_bizppurio/settings" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.banner.setup_action" + } + ] + }, + { + "id": "bizppurio_board_banner_test_mode", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true}}", + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-flask", + "size": "sm", + "className": "text-amber-600 dark:text-amber-400" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.test_mode", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + } + } + ] + } + ] + }, + { + "id": "bizppurio_board_alimtalk_guide", + "comment": "비즈뿌리오 탭 상시 안내 박스 — 알림톡 템플릿을 시스템에서 작성→검수 신청→승인 후 발송됨을 안내. alimtalk 탭 정체성일 때만.", + "type": "basic", + "name": "Div", + "if": "{{(query.channel) === 'alimtalk'}}", + "props": { + "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.tab_guide", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + } + } + ] + } + ] + } + ], + "init_actions": [ + { + "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "priority": 320 +} 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 new file mode 100644 index 00000000..b8096b0d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json @@ -0,0 +1,202 @@ +{ + "target_layout": "admin_settings", + "comment": "알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3, 제품 결정 2026-08-23 개정). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회 — 행 하단 상태 요약(notification_row_footer.json)과 [편집] 모달 섹션(notification_template_form_sections.json)이 공유한다 ③ 작성 셀렉트용 카테고리·발신프로필 데이터소스. 알림톡 작성/검수/SMS 본문 편집 UI 는 코어 [편집] 모달에 주입되므로 이 오버레이는 더 이상 모달을 등록하지 않는다.", + "data_sources": [ + { + "id": "bizppurioTemplates", + "comment": "행 라이프사이클 표시용 요약 맵(notification_type 키). auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지, 비즈뿌리오 탭 진입 시 init_actions 로 조회.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates/map", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "fallback": { + "data": { + "templates": {} + } + } + }, + { + "id": "bizppurioCategories", + "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 자격증명 미설정 422 는 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" + } + }, + "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 + } + } + } + ], + "injections": [ + { + "target_id": "notif_channel_content", + "position": "prepend_child", + "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))}}", + "props": { + "className": "mt-4 mb-3 space-y-2" + }, + "children": [ + { + "id": "bizppurio_banner_not_ready", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false}}", + "props": { + "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.not_ready", + "props": { + "className": "text-sm text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2" + } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" + }, + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-message_bizppurio/settings" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.banner.setup_action" + } + ] + }, + { + "id": "bizppurio_banner_test_mode", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true}}", + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-flask", + "size": "sm", + "className": "text-amber-600 dark:text-amber-400" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.test_mode", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + } + } + ] + } + ] + }, + { + "id": "bizppurio_alimtalk_guide", + "comment": "비즈뿌리오 탭 상시 안내 박스 — 알림톡 템플릿을 시스템에서 작성→검수 신청→승인 후 발송됨을 안내. alimtalk 탭 정체성일 때만.", + "type": "basic", + "name": "Div", + "if": "{{(query.channel) === 'alimtalk'}}", + "props": { + "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.tab_guide", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + } + } + ] + } + ] + } + ], + "init_actions": [ + { + "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "priority": 320 +} 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 new file mode 100644 index 00000000..c2c52518 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json @@ -0,0 +1,202 @@ +{ + "target_layout": "admin_ecommerce_settings", + "comment": "[이커머스 면 — notification_tab_core.json 과 동일 패턴, target/컴포넌트 id 만 이커머스 전용] 알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3, 제품 결정 2026-08-23 개정). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회 — 행 하단 상태 요약(notification_row_footer.json)과 [편집] 모달 섹션(notification_template_form_sections.json)이 공유한다 ③ 작성 셀렉트용 카테고리·발신프로필 데이터소스. 알림톡 작성/검수/SMS 본문 편집 UI 는 코어 [편집] 모달에 주입되므로 이 오버레이는 더 이상 모달을 등록하지 않는다.", + "data_sources": [ + { + "id": "bizppurioTemplates", + "comment": "행 라이프사이클 표시용 요약 맵(notification_type 키). auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지, 비즈뿌리오 탭 진입 시 init_actions 로 조회.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates/map", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "fallback": { + "data": { + "templates": {} + } + } + }, + { + "id": "bizppurioCategories", + "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 자격증명 미설정 422 는 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" + } + }, + "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 + } + } + } + ], + "injections": [ + { + "target_id": "ecommerce_notif_channel_content", + "position": "prepend_child", + "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))}}", + "props": { + "className": "mt-4 mb-3 space-y-2" + }, + "children": [ + { + "id": "bizppurio_ecommerce_banner_not_ready", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false}}", + "props": { + "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.not_ready", + "props": { + "className": "text-sm text-red-800 dark:text-red-200" + } + } + ], + "props": { + "className": "flex-center gap-2" + } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" + }, + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-message_bizppurio/settings" + } + } + ], + "text": "$t:sirsoft-message_bizppurio.banner.setup_action" + } + ] + }, + { + "id": "bizppurio_ecommerce_banner_test_mode", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true}}", + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-flask", + "size": "sm", + "className": "text-amber-600 dark:text-amber-400" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.banner.test_mode", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + } + } + ] + } + ] + }, + { + "id": "bizppurio_ecommerce_alimtalk_guide", + "comment": "비즈뿌리오 탭 상시 안내 박스 — 알림톡 템플릿을 시스템에서 작성→검수 신청→승인 후 발송됨을 안내. alimtalk 탭 정체성일 때만.", + "type": "basic", + "name": "Div", + "if": "{{(query.channel) === 'alimtalk'}}", + "props": { + "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.tab_guide", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + } + } + ] + } + ] + } + ], + "init_actions": [ + { + "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "priority": 320 +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_footer_actions.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_footer_actions.json new file mode 100644 index 00000000..ceae5d10 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_footer_actions.json @@ -0,0 +1,797 @@ +{ + "extension_point": "notification_template_form_footer_actions", + "comment": "코어 알림 템플릿 [편집] 모달 푸터에 주입되는 통합 저장 버튼(제품 결정 2026-08-23 — 저장은 하나로). 채널 메타 hidden_template_editor 로 코어 [저장]/[미리보기]가 숨겨진 자리에서, [저장] 한 번으로 ① 알림톡 템플릿(draft/rejected 만 내용 갱신) + 발송 설정·SMS 본문 ② 수신자 규칙이 바뀐 경우에만 코어 채널 템플릿을 순차 저장한다(앞 단계 실패 시 중단). [저장 후 검수 신청]은 알림톡 본문이 있고 수정 가능한 상태에서만 노출된다 — 카카오 검수는 알림톡 템플릿만 대상이며 SMS 는 검수 없이 즉시 반영된다. 더블클릭 재진입은 click if + disabled 로 막고, 중복 검수 신청의 SSoT 는 서버 원자 선점이다.", + "priority": 320, + "components": [ + { + "id": "bizppurio_tpl_footer_actions", + "type": "basic", + "name": "Div", + "if": "{{extensionPointProps.channel === 'alimtalk'}}", + "props": { + "className": "flex gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "if": "{{(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '') && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "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:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false) || !((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request", + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null, + "bz_tpl_modal.errors": null, + "bz_sms_modal.isSaving": true + } + }, + { + "comment": "① 행 없음 + 본문 입력 → POST(생성). 검수 신청은 생성 응답의 id 로 이어간다.", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", + "if": "{{!(_global.bz_tpl_modal?.id) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}", + "content": "{{_global.bz_tpl_modal?.content}}", + "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request", + "params": { + "method": "POST", + "body": { + "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + } + }, + "onSuccess": [ + { + "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).", + "handler": "apiCall", + "auth_required": true, + "target": "{{extensionPointProps.saveEndpoint}}", + "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "method": "PUT", + "body": { + "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}", + "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}", + "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}", + "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.", + "handler": "sequence", + "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ] + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "② 행 있음 + draft/rejected + 본문 입력 → PUT(내용 갱신).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "if": "{{!!(_global.bz_tpl_modal?.id) && (['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}", + "params": { + "method": "PUT", + "body": { + "content": "{{_global.bz_tpl_modal?.content}}", + "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request", + "params": { + "method": "POST", + "body": { + "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + } + }, + "onSuccess": [ + { + "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).", + "handler": "apiCall", + "auth_required": true, + "target": "{{extensionPointProps.saveEndpoint}}", + "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "method": "PUT", + "body": { + "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}", + "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}", + "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}", + "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.", + "handler": "sequence", + "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ] + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "flex-center gap-2 text-sm px-4 py-2 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false) || !((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "if": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}", + "props": { + "name": "spinner", + "className": "animate-spin text-sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.isSaving ?? false) ? '$t:common.saving' : '$t:common.save'}}" + } + ], + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": true, + "bz_tpl_modal.error": null, + "bz_tpl_modal.errors": null, + "bz_sms_modal.isSaving": true + } + }, + { + "comment": "① 행 없음 + 본문 입력 → POST(생성). 검수 신청은 생성 응답의 id 로 이어간다.", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates", + "if": "{{!(_global.bz_tpl_modal?.id) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.bz_tpl_modal?.notification_type}}", + "content": "{{_global.bz_tpl_modal?.content}}", + "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).", + "handler": "apiCall", + "auth_required": true, + "target": "{{extensionPointProps.saveEndpoint}}", + "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "method": "PUT", + "body": { + "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}", + "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}", + "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}", + "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.", + "handler": "sequence", + "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ] + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "② 행 있음 + draft/rejected + 본문 입력 → PUT(내용 갱신).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}", + "if": "{{!!(_global.bz_tpl_modal?.id) && (['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}", + "params": { + "method": "PUT", + "body": { + "content": "{{_global.bz_tpl_modal?.content}}", + "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).", + "handler": "apiCall", + "auth_required": true, + "target": "{{extensionPointProps.saveEndpoint}}", + "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "method": "PUT", + "body": { + "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}", + "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}", + "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}", + "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.", + "handler": "sequence", + "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ] + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "③ 본문 미입력(SMS 만 설정) 또는 내용 잠금 상태(검수중/승인 등) → delivery upsert(발송 설정·SMS 본문만, 행 없으면 draft 생성).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_tpl_modal?.notification_type}}", + "if": "{{!((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '') || (!!(_global.bz_tpl_modal?.id) && !(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')))}}", + "params": { + "method": "PUT", + "body": { + "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}", + "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}" + } + }, + "onSuccess": [ + { + "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).", + "handler": "apiCall", + "auth_required": true, + "target": "{{extensionPointProps.saveEndpoint}}", + "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "method": "PUT", + "body": { + "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}", + "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}", + "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}", + "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + }, + { + "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.", + "handler": "sequence", + "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast", + "type": "success" + } + }, + { + "handler": "closeModal" + } + ] + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false, + "bz_sms_modal.isSaving": false, + "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}", + "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}" + } + } + ] + } + ] + } + } + ] + } + ] + } + ] +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_sections.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_sections.json new file mode 100644 index 00000000..db0a8721 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_sections.json @@ -0,0 +1,2714 @@ +{ + "extension_point": "notification_template_form_sections", + "comment": "코어 알림 템플릿 [편집] 모달 본문에 주입되는 비즈뿌리오 섹션(제품 결정 2026-08-23 — 행 하단 별도 버튼/모달 폐지, 편집 모달 통합). 3면(코어/게시판/이커머스)이 같은 extension_point 를 노출하므로 파일 1본으로 공유한다. ① 알림톡 템플릿: 승인 2단 배지 + 상태 액션(새로고침/신청 취소/승인 취소 후 수정/휴면 해제) + 작성 폼(draft/rejected) 또는 잠금 요약 ② 문자(SMS): 대체/단독 토글 + 로케일별 본문(검수 대상 아님). 저장은 notification_template_form_footer_actions 의 통합 [저장]이 담당한다. 상태는 _global.bz_tpl_modal / bz_sms_modal / bz_tpl_ui / bz_tpl_upload 에 담고 섹션 마운트(lifecycle.onMount) 시 요약 맵(bizppurioTemplates)으로 시딩한다. 코어 제목/본문 입력은 채널 메타 hidden_template_editor 로 코어 모달이 스스로 숨긴다.", + "priority": 320, + "components": [ + { + "id": "bizppurio_tpl_sections", + "type": "basic", + "name": "Div", + "if": "{{extensionPointProps.channel === 'alimtalk'}}", + "props": { + "className": "space-y-4" + }, + "lifecycle": { + "onMount": [ + { + "handler": "sequence", + "comment": "코어 [편집] 모달이 열릴 때(섹션 마운트) 요약 맵으로 상태를 시딩하고, 행이 있으면 상세(content)를 받아온다. 카테고리·발신프로필은 작성 셀렉트용(자격증명 미설정 422 는 suppress).", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_upload": { + "uploading": false, + "error": null + }, + "bz_tpl_ui": { + "confirmCancelApproval": false + }, + "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": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status ?? 'draft'}}", + "has_content": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)}}", + "loading": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}", + "template_code": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.template_code ?? null}}", + "alimtalk_enabled": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) ? ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled) !== false) : true}}", + "inspection_detail": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []}}", + "isSaving": false, + "error": null, + "errors": null, + "request_comment": "", + "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": "" + } + } + }, + "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}}", + "fallback_sms_enabled": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}", + "sms_only": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only)}}", + "isSaving": false + } + } + }, + { + "comment": "상세(content) 조회 — 행 id 는 요약 맵(데이터소스)에서 읽는다. 같은 sequence 안에서 setState 로 막 넣은 _global 값은 다음 액션의 if/target 해석 시점에 반영되지 않는다(엔진: 디스패치 시점 컨텍스트).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}", + "params": { + "method": "GET" + }, + "if": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}", + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "{{response.data?.template?.status}}", + "bz_tpl_modal.has_content": "{{!!(response.data?.template?.content)}}", + "bz_tpl_modal.loading": false, + "bz_tpl_modal.template_code": "{{response.data?.template?.template_code ?? null}}", + "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 ?? {})))}}" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.loading": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioCategories" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioProfiles" + } + } + ] + } + } + ], + "onUnmount": [ + { + "comment": "모달이 닫히면(언마운트) 폼 상태를 비운다 — 다음 오픈은 항상 시딩 전(READY=false)에서 시작해 이전 알림의 값이 보이거나 저장되는 창을 없앤다.", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal": null, + "bz_sms_modal": null + } + } + ] + }, + "children": [ + { + "id": "bizppurio_tpl_alimtalk_section", + "type": "basic", + "name": "Div", + "props": { + "className": "rounded-lg border border-teal-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 space-y-3" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between flex-wrap gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-comment-dots", + "className": "text-base text-teal-600 dark:text-teal-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "H4", + "props": { + "className": "text-sm font-semibold text-gray-800 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.template.section.alimtalk_title" + }, + { + "type": "basic", + "name": "Span", + "comment": "승인 여부 2단 배지(제품 결정 2026-08-23): 1차 = 승인됨/미승인, 2차(괄호) = 세부 상태(미작성·작성중·검수중·반려·휴면·중지·차단). approved 는 세부 없이 단독 표기.", + "props": { + "className": "{{'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : ((((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'stopped' || (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "text": "{{$t('sirsoft-message_bizppurio.template.approval.' + ((((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'approved' ? 'approved' : 'not_approved'))}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{(((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') !== 'approved'}}", + "props": { + "className": "font-normal opacity-80" + }, + "text": "{{'(' + $t('sirsoft-message_bizppurio.template.status.' + (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten')) + ')'}}" + } + ] + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.synced_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at ?? '').slice(5, 10)}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-1.5" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "if": "{{!!(_global.bz_tpl_modal?.template_code)}}", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 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:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.btn_refresh", + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}", + "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}}/sync", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "comment": "상세(content) 조회 — 행 id 는 요약 맵(데이터소스)에서 읽는다. 같은 sequence 안에서 setState 로 막 넣은 _global 값은 다음 액션의 if/target 해석 시점에 반영되지 않는다(엔진: 디스패치 시점 컨텍스트).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}", + "params": { + "method": "GET" + }, + "if": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}", + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "{{response.data?.template?.status}}", + "bz_tpl_modal.has_content": "{{!!(response.data?.template?.content)}}", + "bz_tpl_modal.loading": false, + "bz_tpl_modal.template_code": "{{response.data?.template?.template_code ?? null}}", + "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 ?? {})))}}" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.loading": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.synced_toast", + "type": "success" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "if": "{{_global.bz_tpl_modal?.status === 'requested'}}", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 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:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.btn_cancel_request", + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}", + "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}}/cancel-request", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "draft" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.cancel_request_toast", + "type": "success" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "if": "{{_global.bz_tpl_modal?.status === 'dormant'}}", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 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:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.btn_release", + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}", + "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}}/release", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "comment": "상세(content) 조회 — 행 id 는 요약 맵(데이터소스)에서 읽는다. 같은 sequence 안에서 setState 로 막 넣은 _global 값은 다음 액션의 if/target 해석 시점에 반영되지 않는다(엔진: 디스패치 시점 컨텍스트).", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}", + "params": { + "method": "GET" + }, + "if": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}", + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "{{response.data?.template?.status}}", + "bz_tpl_modal.has_content": "{{!!(response.data?.template?.content)}}", + "bz_tpl_modal.loading": false, + "bz_tpl_modal.template_code": "{{response.data?.template?.template_code ?? null}}", + "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 ?? {})))}}" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.loading": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.row.released_toast", + "type": "success" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "if": "{{_global.bz_tpl_modal?.status === 'approved' && !(_global.bz_tpl_ui?.confirmCancelApproval ?? false)}}", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 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:cursor-not-allowed border-amber-400 text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-gray-700" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_approved", + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_ui.confirmCancelApproval": true + } + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Label", + "comment": "알림톡 발송 사용 — 통합 [저장]으로 함께 저장된다(즉시 저장 아님).", + "props": { + "className": "inline-flex items-center gap-1.5 cursor-pointer select-none" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "autoBinding": false, + "checked": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}", + "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.alimtalk_enabled": "{{$event.target.checked}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-300" + }, + "text": "$t:sirsoft-message_bizppurio.template.section.alimtalk_enabled" + } + ] + }, + { + "type": "basic", + "name": "Div", + "comment": "승인 취소 확인(인라인 — 모달 중첩 대신). 확정 시 kapi cancel_approval → draft 복귀 — 발송 게이트 즉시 차단(대체 SMS 는 SMS 설정대로 계속).", + "if": "{{(_global.bz_tpl_ui?.confirmCancelApproval ?? false) && _global.bz_tpl_modal?.status === 'approved'}}", + "props": { + "className": "p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-start gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-triangle-exclamation", + "className": "text-base text-amber-500 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-sm text-amber-900 dark:text-amber-100" + }, + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_tpl_modal?.notification_name ?? ''}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex justify-end gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 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:cursor-not-allowed", + "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}" + }, + "text": "$t:common.cancel", + "actions": [ + { + "type": "click", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_ui.confirmCancelApproval": false + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "if": "{{_global.bz_tpl_modal?.status === 'approved'}}", + "props": { + "type": "button", + "className": "text-xs px-2.5 py-1 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_tpl_modal?.isSaving ?? false)}}" + }, + "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm", + "actions": [ + { + "type": "click", + "handler": "sequence", + "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}", + "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}}/cancel-approval", + "params": { + "method": "POST" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.status": "draft", + "bz_tpl_ui.confirmCancelApproval": false + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast", + "type": "info" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioTemplates" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "{{error.errors?.bizppurio_message ?? error.message}}", + "type": "error" + } + } + ] + } + ] + } + } + ] + } + ] + } + ] + }, + { + "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", + "if": "{{_global.bz_tpl_modal?.errors && typeof _global.bz_tpl_modal?.errors === 'object'}}", + "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": "Ul", + "props": { + "className": "list-disc pl-5 space-y-0.5" + }, + "children": [ + { + "type": "basic", + "name": "Li", + "iteration": { + "source": "{{Object.values(_global.bz_tpl_modal?.errors ?? {}).flat()}}", + "item_var": "msg" + }, + "props": { + "className": "text-xs text-red-800 dark:text-red-200" + }, + "text": "{{msg}}" + } + ] + } + ] + }, + { + "id": "bizppurio_tpl_loading", + "type": "basic", + "name": "Div", + "comment": "상세(content) 를 받아오는 동안 편집 폼을 열지 않는다 — 시딩 내용으로 저장되거나 입력이 상세 도착에 덮이는 경쟁 차단(Playwright 실측).", + "if": "{{!((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "props": { + "className": "flex-center gap-2 text-xs text-gray-500 dark:text-gray-400 py-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "spinner", + "className": "animate-spin text-sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.template.section.loading" + } + ] + }, + { + "id": "bizppurio_tpl_locked", + "type": "basic", + "name": "Div", + "comment": "검수중/승인/휴면/중지/차단 — 내용 잠금. 요약만 보여주고 수정은 [신청 취소]/[승인 취소 후 수정] 뒤에만 가능(서버 가드 content_locked 와 동일 규칙).", + "if": "{{!(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "props": { + "className": "space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-300 p-3 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700" + }, + "text": "$t:sirsoft-message_bizppurio.template.section.locked_hint" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-3 text-xs text-gray-600 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "text": "{{$t('sirsoft-message_bizppurio.template.form.message_type') + ': ' + $t('sirsoft-message_bizppurio.template.form.templateMessageType_options.' + (_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA'))}}" + }, + { + "type": "basic", + "name": "Span", + "text": "{{$t('sirsoft-message_bizppurio.template.form.emphasize_type') + ': ' + $t('sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.' + (_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE'))}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "text-sm font-mono whitespace-pre-wrap p-3 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-100" + }, + "text": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}" + } + ] + }, + { + "id": "bizppurio_tpl_editor", + "type": "basic", + "name": "Div", + "comment": "작성 폼(구 modal_bizppurio_template 본문을 그대로 이식). draft/rejected(또는 행 없음)에서만 노출.", + "if": "{{(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}", + "props": { + "className": "space-y-4" + }, + "children": [ + { + "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-2" + }, + "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-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2" + }, + "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-2" + } + }, + { + "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-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2" + }, + "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-2" + } + }, + { + "id": "bizppurio_tpl_request_comment", + "type": "basic", + "name": "Div", + "comment": "검수자 전달 의견 — kapi template/request 의 comment(≤500). 카카오 심사 가이드가 요구하는 변수 예시값 등을 검수자에게 전할 유일한 통로(제품 결정 2026-08-23 §18.7). 신청 시에만 전송되며 행에 저장되지 않는다.", + "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.request_comment" + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.request_comment ?? '').length}}/500", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_request_comment", + "rows": 3, + "maxLength": 500, + "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", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.request_comment_placeholder", + "value": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.request_comment": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.request_comment_hint" + } + ] + } + ] + } + ] + }, + { + "id": "bizppurio_tpl_sms_section", + "type": "basic", + "name": "Div", + "comment": "문자(SMS) — 검수 대상 아님(카카오 검수는 알림톡 템플릿만). 대체 SMS/SMS 단독 토글 + 로케일별 본문. 통합 [저장]으로 delivery 필드와 함께 저장된다.", + "props": { + "className": "rounded-lg border border-gray-200 dark:border-gray-700 p-4 space-y-3" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between flex-wrap gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-comment-sms", + "className": "text-base text-gray-500 dark:text-gray-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "H4", + "props": { + "className": "text-sm font-semibold text-gray-800 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.template.section.sms_title" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-3" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "inline-flex items-center gap-1.5 cursor-pointer select-none" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "autoBinding": false, + "checked": "{{!!(_global.bz_sms_modal?.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": "setState", + "params": { + "target": "global", + "bz_sms_modal.fallback_sms_enabled": "{{$event.target.checked}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-300" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms" + } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { + "className": "inline-flex items-center gap-1.5 cursor-pointer select-none" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "autoBinding": false, + "checked": "{{!!(_global.bz_sms_modal?.sms_only)}}", + "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_sms_modal.sms_only": "{{$event.target.checked}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-300" + }, + "text": "$t:sirsoft-message_bizppurio.template.row.sms_only" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "P", + "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-gray-700" + }, + "text": "$t:sirsoft-message_bizppurio.template.sms.hint" + }, + { + "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}}" + } + ] + } + ] + } + ] +} 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/helpers.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts new file mode 100644 index 00000000..f4dc9b40 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts @@ -0,0 +1,139 @@ +/** + * 비즈뿌리오 메시징 플러그인 레이아웃 테스트 공통 헬퍼 + * + * JSON 트리에서 ID/이름 검색 및 핸들러·i18n 키 추출을 위한 유틸리티. + */ + +export type AnyNode = Record & { + id?: string; + name?: string; + type?: string; + children?: AnyNode[]; + slots?: Record; + modals?: Record | AnyNode[]; + actions?: AnyNode[]; + iteration?: { source?: string; item_var?: string; index_var?: string }; + if?: string; + props?: Record; + text?: string; +}; + +/** + * 트리 노드에서 특정 id를 재귀 탐색 (children/slots/modals) + */ +export function findById(node: AnyNode | undefined | null, id: string): AnyNode | null { + if (!node) return null; + if (node.id === id) return node; + + if (Array.isArray(node.children)) { + for (const child of node.children) { + const found = findById(child, id); + if (found) return found; + } + } + + if (node.slots && typeof node.slots === 'object') { + for (const slotChildren of Object.values(node.slots)) { + if (Array.isArray(slotChildren)) { + for (const child of slotChildren) { + const found = findById(child, id); + if (found) return found; + } + } + } + } + + if (node.modals) { + const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals); + for (const m of modalEntries) { + const found = findById(m as AnyNode, id); + if (found) return found; + } + } + + return null; +} + +/** + * 트리 노드에서 특정 name 컴포넌트를 모두 수집 (children/slots/modals) + */ +export function findAllByName(node: AnyNode | undefined | null, name: string): AnyNode[] { + const results: AnyNode[] = []; + if (!node) return results; + if (node.name === name) results.push(node); + + if (Array.isArray(node.children)) { + for (const child of node.children) { + results.push(...findAllByName(child, name)); + } + } + if (node.slots && typeof node.slots === 'object') { + for (const slotChildren of Object.values(node.slots)) { + if (Array.isArray(slotChildren)) { + for (const child of slotChildren) { + results.push(...findAllByName(child, name)); + } + } + } + } + if (node.modals) { + const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals); + for (const m of modalEntries) { + results.push(...findAllByName(m as AnyNode, name)); + } + } + + return results; +} + +/** + * 특정 name 컴포넌트 중 props.name 속성이 주어진 값인 첫 노드를 찾음 (폼 입력 필드 탐색용) + */ +export function findInputByName(node: AnyNode | undefined | null, inputName: string): AnyNode | null { + for (const candidate of [...findAllByName(node, 'Input'), ...findAllByName(node, 'Select')]) { + if ((candidate.props as { name?: string } | undefined)?.name === inputName) { + return candidate; + } + } + return null; +} + +/** + * JSON 문자열에서 사용된 핸들러 이름 모두 수집 + */ +export function collectHandlers(json: unknown): string[] { + const text = JSON.stringify(json); + const matches = text.match(/"handler":\s*"([^"]+)"/g) ?? []; + const names = matches + .map((m) => m.match(/"handler":\s*"([^"]+)"/)?.[1] ?? '') + .filter(Boolean); + return Array.from(new Set(names)); +} + +/** + * JSON 문자열에서 사용된 i18n 키($t:sirsoft-message_bizppurio.*) 모두 수집 + */ +export function collectI18nKeys(json: unknown): string[] { + const text = JSON.stringify(json); + const matches = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; + return Array.from(new Set(matches)); +} + +/** + * `{{...}}` 단일 바인딩 조건식을 주어진 자유변수 스코프로 실제 평가합니다. + * + * 문자열 포함/동일성 단언(`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_log_result.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx new file mode 100644 index 00000000..7491f5aa --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx @@ -0,0 +1,339 @@ +// e2e:allow 검수 라벨 표시(is_test_mode)는 Chrome MCP로 실제 회원가입→SMS 발송→화면 렌더까지 +// 실측 검증 완료(2026-07-24). 정식 E2E는 비즈뿌리오 자격증명·채널 활성화·webhook 등 발송 인프라 +// 의존이 커서 이번 변경 범위를 벗어나며, 별도 계획(plan-e2e-tests)에서 다룰 예정. +/** + * 코어 알림 발송 이력 결과 컬럼 주입 렌더 테스트 (A-2) + * + * notification_log_result.json overlay 는 코어 "알림 발송 이력" 화면(admin_notification_log_list)의 + * DataGrid columns 에 결과 컬럼 1개를 _append 로 얹는다. 코어 화면·코어 앱·코어 테이블 무수정 — + * 현재 페이지의 코어 로그 id 배열로 결과(dispatchResults)를 배치 조회해 row.id 로 매칭한다. + * + * 이 테스트는 overlay 에서 결과 컬럼 정의를 그대로 추출해 실제 렌더한다(구조 검증이 아니라 렌더). + * row 컨텍스트는 iteration 으로 재현하고, dispatchResults 는 mockApi 로 채운다. 각 케이스에서 + * 올바른 배지 텍스트(상태 `사유 (코드)`·잔액부족·대체발송)가 DOM 에 뜨는지, 매칭 안 되는 행은 + * 빈 셀(-)인지 확인한다. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + createLayoutTest, + createMockComponentRegistryWithBasics, + screen, + type MockComponentRegistry, +} from '@core/template-engine/__tests__/utils/layoutTestUtils'; + +import overlay from '../../../extensions/notification_log_result.json'; + +/** overlay 에서 결과 컬럼(field: bizppurio_result)의 cellChildren 을 추출한다. */ +function getResultCellChildren(): any[] { + const inj = (overlay as any).injections.find( + (i: any) => i.target_id === 'notification_log_datagrid' && i.position === 'inject_props', + ); + const column = inj.props.columns._append.find((c: any) => c.field === 'bizppurio_result'); + if (!column) throw new Error('결과 컬럼(bizppurio_result)을 찾지 못함'); + return column.cellChildren; +} + +/** overlay 에서 행 토글(expandChildren) 에 append 된 결과 블록을 추출한다. */ +function getResultExpandChildren(): any[] { + const inj = (overlay as any).injections.find( + (i: any) => i.target_id === 'notification_log_datagrid' && i.position === 'inject_props', + ); + return inj.props.expandChildren._append; +} + +/** 결과 컬럼 cellChildren 을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */ +function buildProbe() { + return { + version: '1.0.0', + layout_name: 'test/a2-dispatch-result', + data_sources: [ + { id: 'notificationLogs', type: 'api', endpoint: '/api/test/logs', method: 'GET', auto_fetch: true }, + { id: 'dispatchResults', type: 'api', endpoint: '/api/test/results', method: 'POST', auto_fetch: true }, + ], + components: [ + { + type: 'basic', + name: 'Div', + iteration: { source: '{{notificationLogs.data?.data ?? []}}', item_var: 'row' }, + props: { 'data-testid': 'result-cell' }, + children: getResultCellChildren(), + }, + ], + }; +} + +/** 행 토글(expandChildren) 결과 블록을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */ +function buildExpandProbe() { + return { + version: '1.0.0', + layout_name: 'test/a2-dispatch-result-expand', + data_sources: [ + { id: 'notificationLogs', type: 'api', endpoint: '/api/test/logs', method: 'GET', auto_fetch: true }, + { id: 'dispatchResults', type: 'api', endpoint: '/api/test/results', method: 'POST', auto_fetch: true }, + ], + components: [ + { + type: 'basic', + name: 'Div', + iteration: { source: '{{notificationLogs.data?.data ?? []}}', item_var: 'row' }, + props: { 'data-testid': 'result-expand' }, + children: getResultExpandChildren(), + }, + ], + }; +} + +let registry: MockComponentRegistry; +beforeEach(() => { + registry = createMockComponentRegistryWithBasics(); +}); +afterEach(() => { + vi.clearAllMocks(); +}); + +/** 로그 행 목록. 셀 탭 가드가 row.channel 을 보므로 채널을 함께 준다(기본 sms). */ +const logs = (ids: number[], channel: string = 'sms') => ({ data: { data: ids.map((id) => ({ id, channel })) } }); + +describe('A-2 결과 컬럼 주입 — overlay 구조', () => { + it('코어 알림 발송 이력 datagrid 에 결과 컬럼을 _append 로 얹는다', () => { + expect((overlay as any).target_layout).toBe('admin_notification_log_list'); + const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid'); + expect(inj.position).toBe('inject_props'); + expect(inj.props.columns._append).toBeTruthy(); + }); + + it('결과는 파라미터 없이 GET 으로 최근 결과 맵을 받는다(타이밍 무관, kginicis 선례)', () => { + // 다른 data_source(notificationLogs)를 params 로 참조하면, params 가 notificationLogs 응답 + // 도착 전에 평가돼 빈 배열이 전송되는 타이밍 결함이 있다(브라우저 실측: 결과 컬럼 전부 빈 셀). + // 파라미터 없이 최근 결과 맵을 받아 row.id 로 매칭한다(로드 순서 무관). + const ds = (overlay as any).data_sources.find((d: any) => d.id === 'dispatchResults'); + expect(ds.method).toBe('GET'); + expect(ds.endpoint).toContain('/dispatch-results/recent'); + expect(ds.params).toBeUndefined(); + expect(ds.if).toBeUndefined(); + }); + + it('결과 컬럼 셀은 비즈뿌리오 발송 행(row.channel)에서만 표시한다(메일·사이트내알림 숨김)', () => { + const cell = getResultCellChildren(); + const guard = cell[0]; + // 셀 렌더 컨텍스트는 row/value 만 있고 query 는 없다(코어 DataGrid renderCellChildren 계약). + // 따라서 탭(query.channel)이 아니라 row.channel 로 비즈뿌리오 발송 행을 판별한다. + expect(guard.if).toContain("['sms','lms','alimtalk'].includes(row.channel)"); + }); + + it('컬럼(헤더 포함)은 메일·사이트내알림 탭에서 hidden 으로 숨긴다', () => { + // 컬럼 정의는 datagrid props 로 페이지 컨텍스트에서 평가되므로 query 접근 가능(셀과 다름). + // 메일·사이트내알림(mail/database) 탭이면 hidden=true → 헤더까지 숨긴다. + const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid'); + const column = inj.props.columns._append.find((c: any) => c.field === 'bizppurio_result'); + expect(column.hidden).toContain("['mail','database'].includes(query.channel"); + }); + + it('행 토글(expandChildren)에도 결과 블록을 append 한다(비즈뿌리오 발송 행만)', () => { + const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid'); + const appended = inj.props.expandChildren._append; + expect(appended).toBeTruthy(); + const block = appended[0]; + // 결과가 매칭된 행일 때만 토글에 노출. + expect(block.if).toContain('dispatchResults?.data?.results'); + expect(JSON.stringify(block)).toContain('dispatch_result.detail_title'); + }); + + it('배지는 다크모드에서 solid 색을 쓴다(색-불투명도 희석 금지)', () => { + const raw = JSON.stringify(overlay); + // /40 등 색-불투명도는 작은 배지에서 다크 배경과 섞여 묽어지므로 solid(dark:bg-*-700 등) 사용. + expect(raw).toContain('dark:bg-green-700'); + expect(raw).toContain('dark:bg-red-700'); + expect(raw).not.toContain('dark:bg-green-900/40'); + }); +}); + +describe('A-2 결과 컬럼 주입 — 렌더', () => { + it('성공 결과는 사유(코드) 라벨을 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([1]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 1: { status: 'success', status_label: '성공', result_label: '성공 (4100)', is_low_balance: false, fallback_status: null } } } }, + }); + await utils.render(); + expect(screen.getByText('성공 (4100)')).toBeInTheDocument(); + utils.cleanup(); + }); + + it('잔액부족 실패는 사유(코드) 라벨과 잔액부족 배지를 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([2]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 2: { status: 'failed', status_label: '실패', result_label: '지갑 잔액 부족 (7436)', is_low_balance: true, fallback_status: null } } } }, + }); + await utils.render(); + // result_label 은 data 값(그대로 렌더). 잔액부족 배지는 is_low_balance=true 조건부 렌더 — + // $t: 라벨은 이 렌더 환경에서 원문 키로 남으므로 그 키 존재로 배지 렌더를 확인한다. + expect(screen.getByText('지갑 잔액 부족 (7436)')).toBeInTheDocument(); + expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.low_balance')).toBeInTheDocument(); + utils.cleanup(); + }); + + it('is_low_balance=false 이면 잔액부족 배지를 렌더하지 않는다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([2]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 2: { status: 'failed', status_label: '실패', result_label: '음영 지역 (4400)', is_low_balance: false, fallback_status: null } } } }, + }); + await utils.render(); + expect(screen.getByText('음영 지역 (4400)')).toBeInTheDocument(); + expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.low_balance')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('대체발송 결과가 있으면 대체발송 배지를 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([3]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 3: { status: 'success', status_label: '성공', result_label: '성공 (7000)', is_low_balance: false, fallback_status: '성공' } } } }, + }); + await utils.render(); + // 대체발송 배지는 fallback_status 존재 시 조건부 렌더 ($t: 라벨은 원문 키로 남음). + expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.fallback')).toBeInTheDocument(); + utils.cleanup(); + }); + + it('비즈뿌리오 발송(sms) 행이지만 결과 미매칭이면 빈 셀(-)을 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([9], 'sms') }); + utils.mockApi('dispatchResults', { response: { data: { results: {} } } }); + await utils.render(); + expect(screen.getByText('-')).toBeInTheDocument(); + utils.cleanup(); + }); + + it('메일 채널 행은 셀 자체를 비운다(빈 셀 - 도 표시 안 함)', async () => { + // row.channel 이 mail 이면 셀 최상위 가드가 false → 셀 내용(빈 셀 - 포함) 전체 미렌더. + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([10], 'mail') }); + utils.mockApi('dispatchResults', { response: { data: { results: {} } } }); + await utils.render(); + expect(screen.queryByText('-')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('검수 모드 발송 건은 상태 라벨(발송중) 대신 검수 라벨을 렌더한다', async () => { + // is_test_mode=true 이면 status='sent'(발송중)이어도 검수 라벨로 대체 표시한다(제품 결정 — + // "발송중" 문구 자체가 검수 모드에서는 오해 소지라 배지 병기가 아니라 라벨 교체). + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([4]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 4: { status: 'sent', status_label: '발송중', result_label: null, is_low_balance: false, fallback_status: null, is_test_mode: true } } } }, + }); + await utils.render(); + // $t('key') 는 이 렌더 환경에서 번역 실패 시 원문 키를 그대로 반환한다($t: 와 동일 폴백). + expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).toBeInTheDocument(); + expect(screen.queryByText('발송중')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('운영 모드 발송 건은 검수 라벨 없이 기존 상태 라벨을 그대로 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([5]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 5: { status: 'sent', status_label: '발송중', result_label: null, is_low_balance: false, fallback_status: null, is_test_mode: false } } } }, + }); + await utils.render(); + expect(screen.getByText('발송중')).toBeInTheDocument(); + expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('is_test_mode 필드가 없는 과거 이력은 검수 라벨 없이 기존 라벨을 렌더한다', async () => { + // 컬럼 신설 이전 이력(is_test_mode 미포함)도 undefined === true 가 false 이므로 안전하게 기존 라벨. + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([6]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 6: { status: 'success', status_label: '성공', result_label: '성공 (4100)', is_low_balance: false, fallback_status: null } } } }, + }); + await utils.render(); + expect(screen.getByText('성공 (4100)')).toBeInTheDocument(); + expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).not.toBeInTheDocument(); + utils.cleanup(); + }); +}); + +describe('A-2 행 토글 — 알림톡 실제 발송 내용', () => { + it('알림톡 채널 + 실제 발송 내용이 있으면 코어 "본문"과 별도로 렌더한다', async () => { + const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([7], 'alimtalk') }); + utils.mockApi('dispatchResults', { + response: { + data: { + results: { + 7: { + status: 'success', + status_label: '성공', + result_label: '성공 (7000)', + is_low_balance: false, + fallback_status: null, + channel: 'alimtalk', + content: '[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.', + }, + }, + }, + }, + }); + await utils.render(); + expect(screen.getByText(/회원가입을 환영합니다/)).toBeInTheDocument(); + utils.cleanup(); + }); + + it('sms 채널은 실제 발송 내용 값이 있어도 렌더하지 않는다(코어 본문과 동일하므로 중복 표시 불필요)', async () => { + const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([8], 'sms') }); + utils.mockApi('dispatchResults', { + response: { + data: { + results: { + 8: { + status: 'success', + status_label: '성공', + result_label: '성공 (4100)', + is_low_balance: false, + fallback_status: null, + channel: 'sms', + content: '문자 본문', + }, + }, + }, + }, + }); + await utils.render(); + expect(screen.queryByText('문자 본문')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('알림톡 채널이지만 실제 발송 내용이 없으면(과거 이력 등) 렌더하지 않는다', async () => { + const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([9], 'alimtalk') }); + utils.mockApi('dispatchResults', { + response: { + data: { + results: { + 9: { + status: 'success', + status_label: '성공', + result_label: '성공 (7000)', + is_low_balance: false, + fallback_status: null, + channel: 'alimtalk', + content: null, + }, + }, + }, + }, + }); + await utils.render(); + expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.sent_content_label')).not.toBeInTheDocument(); + utils.cleanup(); + }); +}); 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 new file mode 100644 index 00000000..77b9e716 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx @@ -0,0 +1,121 @@ +// e2e:allow 3면 패리티는 정규화 문자열 대조 Vitest 로 잠근다(#597 재작성) — 라이프사이클 전이 +// 브라우저 흐름은 코어 면 대표로 tests/Playwright/specs/admin/template-lifecycle.spec.ts 가 담당. +/** + * 게시판·이커머스 알림 설정 '비즈뿌리오' 통합 탭 오버레이 — 3면 패리티 검증 (#597) + * + * 시나리오 매니페스트 exclusions 근거: "3면은 동일 오버레이 패턴(파일 diff 는 id·target 뿐) + * — 라이프사이클 전이는 코어 면으로 대표하고 면 axis 는 렌더 패리티(Vitest)로 잠근다". + * + * 검증 방식: comment 필드 제거 후 JSON 문자열에서 면 고유 토큰(target_layout·target_id· + * 컴포넌트 id 접두)만 코어 형으로 치환하면 코어 오버레이와 완전 동일해야 한다. + * 반대로 데이터소스 id 는 치환 없이도 3면 동일해야 한다 — notification_row_footer.json 과 + * notification_template_form_sections.json(전역 매칭, 3면 공유 1본)이 이 이름들을 공유 참조하기 때문이다. + * 편집 UI(작성 폼·SMS·저장)는 코어 [편집] 모달의 extension_point 에 주입되는 공유 1본이라 + * 면 패리티 대상이 아니다(제품 결정 2026-08-23). + * + * 세 오버레이가 전문 동일하다는 것이 board/ecommerce 면의 화면 효과를 떠받치는 근거다 — + * core 면에서 단언한 것들(탭 통합·행 하단 UI·업로드 잠금·SMS 언어 탭)이 두 면에도 있다는 + * 보장은 이 패리티뿐이고, 그래서 이 파일이 사라지면 두 면은 미측정으로 남는다. + * + * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_summary_only +*/ + +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 { type AnyNode } from './helpers'; + +type OverlayFixture = { + label: string; + overlay: typeof boardOverlay; + targetLayout: string; + targetId: string; + idPrefix: string; +}; + +const FIXTURES: OverlayFixture[] = [ + { + label: '게시판', + overlay: boardOverlay, + targetLayout: 'admin_board_settings', + targetId: 'board_notif_channel_content', + idPrefix: 'bizppurio_board_', + }, + { + label: '이커머스', + overlay: ecommerceOverlay, + targetLayout: 'admin_ecommerce_settings', + targetId: 'ecommerce_notif_channel_content', + idPrefix: 'bizppurio_ecommerce_', + }, +]; + +/** 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; +}; + +const normalizedCore = JSON.stringify(stripComments(coreOverlay)); + +/** 3면 공유 이름(치환 없이 동일해야 하는 축) */ +const SHARED_DATA_SOURCE_IDS = ['bizppurioTemplates', 'bizppurioCategories', 'bizppurioProfiles']; +// 모달·전역 상태(bz_*)는 오버레이가 아니라 3면 공유 1본(notification_template_form_sections.json / +// _footer_actions.json)이 소유한다(제품 결정 2026-08-23 — 편집 모달 통합). 오버레이는 모달을 등록하지 않는다. + +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(); + }); + + it(`배너·안내박스는 target_id=${targetId} 에 prepend_child 로 주입된다`, () => { + const injections = (overlay as { injections?: Array> }).injections ?? []; + expect(injections).toHaveLength(1); + expect(injections[0].target_id).toBe(targetId); + expect(injections[0].position).toBe('prepend_child'); + }); + + 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).toEqual(SHARED_DATA_SOURCE_IDS); + }); + + it('오버레이는 모달을 등록하지 않고, 면 전용 전역 상태(bz_board_* 등)도 만들지 않는다', () => { + expect((overlay as { modals?: AnyNode[] }).modals, `${label} 오버레이 modals`).toBeUndefined(); + const raw = JSON.stringify(overlay); + expect(raw).not.toContain(`bz_${idPrefix.replace('bizppurio_', '')}`); + expect(raw).not.toContain('openModal'); + }); +}); + +describe('3면 패리티 — 공유 축 교차 검증', () => { + it('코어 오버레이도 동일한 공유 데이터소스 id 집합을 갖고 모달은 없다(패리티 기준점 고정)', () => { + const dsIds = ((coreOverlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id); + expect(dsIds).toEqual(SHARED_DATA_SOURCE_IDS); + expect((coreOverlay as { modals?: AnyNode[] }).modals).toBeUndefined(); + }); + + 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..e490fb74 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx @@ -0,0 +1,841 @@ +// e2e:allow 구조 단언 + 조건식 실평가 Vitest(#597) — 브라우저 흐름은 +// tests/Playwright/specs/admin/template-lifecycle*.spec.ts 가 담당(발송 인프라 의존 축은 별도 계획). +// +// 노출 조건·저장 분기는 문자열 동일성이 아니라 `new Function` 실평가로 판정한다 +// (§14.2 T7) — 리터럴 비교는 조건을 잘못 고쳐도 기대값을 함께 고치면 green 이라 회귀를 +// 잡지 못한다. +/** + * 알림 설정 '비즈뿌리오' 통합 탭 — [편집] 모달 통합 UI 구조 검증 (#597, 제품 결정 2026-08-23) + * + * @effects row_footer_shows_status_summary_only, approval_badge_two_tier, edit_modal_hosts_alimtalk_and_sms_sections, + * unified_save_chain_alimtalk_then_core_recipients, compose_modal_switches_conditional_fields_by_type, + * sms_modal_saves_body_via_delivery_upsert, sms_modal_edits_body_per_locale_tab, + * inspection_request_carries_reviewer_comment + * + * 파일 구성(플러그인 resources/extensions): + * - notification_tab_core/board/ecommerce.json (Overlay): 상태 배너(injections) + 안내 박스 + data_sources 3종. + * 모달은 더 이상 등록하지 않는다(작성/SMS/승인취소/반려 모달 폐지). + * - notification_row_footer.json (ExtensionPoint notification_definition_row_footer): 행 하단 **상태 요약만** + * (승인 2단 배지 + 신청/동기화일 + SMS 설정 요약). 버튼 없음 — 조작은 코어 [편집] 모달로 일원화. + * - notification_template_form_sections.json (ExtensionPoint notification_template_form_sections): 코어 [편집] + * 모달 본문에 주입되는 알림톡 템플릿 섹션(작성 폼/잠금 요약/상태 액션) + 문자(SMS) 섹션. 3면 공유 1본. + * - notification_template_form_footer_actions.json (ExtensionPoint notification_template_form_footer_actions): + * 통합 [저장] / [저장 후 검수 신청] — 알림톡 upsert(또는 delivery upsert) → (검수 신청) → 수신자 규칙이 + * 바뀐 경우에만 코어 PUT → 마무리. + * + * 라이프사이클: 미작성(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 sections from '../../../extensions/notification_template_form_sections.json'; +import footerActions from '../../../extensions/notification_template_form_footer_actions.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 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; +const sectionsRoot = { children: (sections as { components?: AnyNode[] }).components ?? [] } as AnyNode; +const actionsRoot = { children: (footerActions as { components?: AnyNode[] }).components ?? [] } as AnyNode; + +/** 요약 맵의 def.type 행 접근 경로(행 하단·섹션이 공유하는 데이터 근거) */ +const MAP_PATH = "bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]"; + +/** text 키로 버튼 노드를 찾는다. */ +const findButtonByTextKey = (root: AnyNode, key: string): AnyNode | undefined => + findAllByName(root, 'Button').find((b) => b.text === `$t:sirsoft-message_bizppurio.${key}`); + +/** JSON 텍스트에서 $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(/'$/, '')), + ])).filter((k) => !k.endsWith('.')); +}; + +const resolve = (root: unknown, path: string): unknown => + path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); + +/** 문자열 리프 전수 수집 */ +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; +}; + +/** 모든 액션 노드(handler 보유 객체)를 재귀 수집한다. */ +const collectActions = (node: unknown, acc: AnyNode[] = []): AnyNode[] => { + if (Array.isArray(node)) node.forEach((c) => collectActions(c, acc)); + else if (node && typeof node === 'object') { + const rec = node as AnyNode; + if (typeof rec.handler === 'string') acc.push(rec); + Object.values(rec).forEach((v) => collectActions(v, acc)); + } + return acc; +}; + +/** + * `{{...}}` 텍스트/조건 식을 실제로 평가한다. `$t` 는 키를 그대로 돌려주는 스텁이라 + * 평가 결과로 "어느 i18n 키가 선택됐는가" 를 판정할 수 있다. + */ +const evalExpr = (expr: string, scope: Record): unknown => { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + const names = Object.keys(scope); + // eslint-disable-next-line no-new-func + const fn = new Function('$t', '$locale', ...names, `return (${body});`); + return fn((k: string) => k, 'ko', ...names.map((n) => scope[n])); +}; + +/** 요약 맵 행 상태 픽스처 (bizppurioTemplates.data.templates[type]) */ +const ROWS: Record | undefined> = { + '행 없음': undefined, + '행만 있고 내용 없음': { id: 1, has_content: false, status: 'draft' }, + draft: { id: 1, has_content: true, status: 'draft', template_code: null }, + requested: { id: 1, has_content: true, status: 'requested', template_code: 'g7_a_1' }, + approved: { id: 1, has_content: true, status: 'approved', template_code: 'g7_a_1' }, + rejected: { id: 1, has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [{ content: '반려 사유' }] }, + dormant: { id: 1, has_content: true, status: 'dormant', template_code: 'g7_a_1' }, + stopped: { id: 1, has_content: true, status: 'stopped', template_code: 'g7_a_1' }, + blocked: { id: 1, has_content: true, status: 'blocked', template_code: 'g7_a_1' }, +}; +const mapScope = (row: Record | undefined) => ({ + bizppurioTemplates: { data: { templates: row ? { welcome: row } : {} } }, + extensionPointProps: { definition: { type: 'welcome' }, activeChannel: 'alimtalk', channel: 'alimtalk' }, +}); + +describe('lifecycle UI — 파일 분리(Overlay vs ExtensionPoint 3종)', () => { + it('overlay 는 target_layout=admin_settings 이고 extension_point·modals 키가 없다(모달 폐지)', () => { + expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings'); + expect((overlay as Record).extension_point).toBeUndefined(); + for (const [label, o] of [['core', coreOverlay], ['board', boardOverlay], ['ecommerce', ecommerceOverlay]] as const) { + expect((o as Record).modals, `${label} 오버레이는 모달을 등록하지 않는다`).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('섹션·푸터 액션은 코어 편집 모달의 extension_point 2종에 주입된다(3면 공유 1본)', () => { + expect((sections as { extension_point?: string }).extension_point).toBe('notification_template_form_sections'); + expect((footerActions as { extension_point?: string }).extension_point).toBe('notification_template_form_footer_actions'); + expect((sections as Record).target_layout).toBeUndefined(); + expect((footerActions as Record).target_layout).toBeUndefined(); + }); + + it('섹션·푸터 액션 루트는 alimtalk 채널 템플릿을 편집할 때만 노출된다(메일·사이트내 알림 모달 무오염)', () => { + for (const root of [findById(sectionsRoot, 'bizppurio_tpl_sections'), findById(actionsRoot, 'bizppurio_tpl_footer_actions')]) { + expect(root).toBeTruthy(); + const cond = (root as { if?: string }).if ?? ''; + expect(evalBinding(cond, { extensionPointProps: { channel: 'alimtalk' } })).toBe(true); + expect(evalBinding(cond, { extensionPointProps: { channel: 'mail' } })).toBe(false); + expect(evalBinding(cond, { extensionPointProps: { channel: '' } })).toBe(false); + } + }); +}); + +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'); + expect((ko as Record).template.tab_guide).toContain('[편집]'); + }); +}); + +describe('lifecycle UI — 행 하단은 상태 요약만 (버튼 0)', () => { + const row = findById(footerRoot, 'bizppurio_row_lifecycle') as AnyNode; + + it('행 UI 는 activeChannel === alimtalk 일 때만 노출된다', () => { + expect(row).toBeTruthy(); + expect(evalBinding((row as { if?: string }).if ?? '', { extensionPointProps: { activeChannel: 'alimtalk' } })).toBe(true); + expect(evalBinding((row as { if?: string }).if ?? '', { extensionPointProps: { activeChannel: 'mail' } })).toBe(false); + }); + + it('행 하단에는 버튼·체크박스·apiCall 이 없다 — 조작은 코어 [편집] 모달로 일원화', () => { + expect(findAllByName(row, 'Button')).toHaveLength(0); + expect(findAllByName(row, 'Checkbox')).toHaveLength(0); + expect(findAllByName(row, 'Toggle')).toHaveLength(0); + expect(collectActions(row).filter((a) => a.handler === 'apiCall')).toHaveLength(0); + expect(JSON.stringify(row)).not.toContain('openModal'); + }); + + it('SMS 요약 줄은 대체/단독 사용 여부와 서버 미리보기(has_sms_body / sms_body_preview)를 읽는다', () => { + const raw = JSON.stringify(row); + expect(raw).toContain(`${MAP_PATH}?.fallback_sms_enabled`); + expect(raw).toContain(`${MAP_PATH}?.sms_only`); + expect(raw).toContain(`${MAP_PATH}?.has_sms_body === true`); + expect(raw).toContain(`${MAP_PATH}?.sms_body_preview`); + expect(raw).toContain('template.row.sms_body_missing'); + expect(raw).not.toContain('.slice(0, 40)'); + }); + + it('SMS 상태 3종은 문구가 아니라 배지다 — 사용=초록/미사용=회색(승인 배지와 같은 모양), 본문 미설정=외곽선 칩 (제품 결정 2026-08-23)', () => { + const APPROVED_SET = 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200'; + const GRAY_SET = 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'; + for (const [id, field] of [['bizppurio_row_sms_fallback_badge', 'fallback_sms_enabled'], ['bizppurio_row_sms_only_badge', 'sms_only']] as const) { + const badge = findById(row, id) as AnyNode; + expect(badge, id).toBeTruthy(); + const cls = String((badge.props as { className: string }).className); + const on = String(evalExpr(cls, mapScope({ id: 1, has_content: true, status: 'draft', [field]: true }))); + const off = String(evalExpr(cls, mapScope({ id: 1, has_content: true, status: 'draft', [field]: false }))); + expect(on).toContain('rounded'); + expect(on).toContain('px-2 py-0.5'); + expect(on).toContain(APPROVED_SET); + expect(off).toContain(GRAY_SET); + expect(evalExpr(String(badge.text), mapScope({ id: 1, [field]: true }))).toBe('sirsoft-message_bizppurio.template.row.on'); + expect(evalExpr(String(badge.text), mapScope({ id: 1, [field]: false }))).toBe('sirsoft-message_bizppurio.template.row.off'); + // 라벨(대체 SMS / SMS 단독)은 배지 앞 형제 + const group = findById(row, id.replace('_badge', '')) as AnyNode; + expect((group.children as AnyNode[])[0].text).toMatch(/template\.row\.(fallback_sms|sms_only)$/); + } + const missing = findById(row, 'bizppurio_row_sms_body_missing') as AnyNode; + expect(missing).toBeTruthy(); + expect(String((missing.props as { className: string }).className)).toContain('border border-gray-300'); + expect(missing.text).toBe('$t:sirsoft-message_bizppurio.template.row.sms_body_missing'); + // 평문 "라벨: 값" 조립은 남지 않는다 + expect(JSON.stringify(row)).not.toContain("+ ': ' +"); + }); +}); + +describe('lifecycle UI — 승인 여부 2단 배지 실평가 (행 하단 + 섹션 헤더 동형)', () => { + /** 배지 = 1차(승인됨/미승인) Span + 2차(괄호 세부) Span 을 품은 Span */ + const badgeOf = (root: AnyNode): AnyNode => { + const badge = findAllByName(root, 'Span').find((s) => String((s.comment as string) ?? '').includes('승인 여부 2단 배지')); + expect(badge, '2단 배지 노드').toBeTruthy(); + return badge as AnyNode; + }; + + const EXPECTED: Record = { + '행 없음': { primary: 'not_approved', detail: 'unwritten' }, + '행만 있고 내용 없음': { primary: 'not_approved', detail: 'unwritten' }, + draft: { primary: 'not_approved', detail: 'draft' }, + requested: { primary: 'not_approved', detail: 'requested' }, + approved: { primary: 'approved', detail: null }, + rejected: { primary: 'not_approved', detail: 'rejected' }, + dormant: { primary: 'not_approved', detail: 'dormant' }, + stopped: { primary: 'not_approved', detail: 'stopped' }, + blocked: { primary: 'not_approved', detail: 'blocked' }, + }; + + /** 섹션 헤더 배지는 상세 GET 으로 갱신되는 모달 상태를 근거로 한다(요약 맵은 모달 안 액션 직후 stale — 실측) */ + const modalScope = (row: Record | undefined) => ({ + _global: { bz_tpl_modal: row ? { id: row.id, status: row.status, has_content: row.has_content } : { id: null } }, + }); + + it.each([ + ['행 하단', footerRoot, mapScope], + ['섹션 헤더', sectionsRoot, modalScope], + ] as const)('%s 배지가 상태 9종을 승인됨 / 미승인(세부) 로 표기한다', (_label, root, scopeOf) => { + const badge = badgeOf(root); + const [primary, detail] = badge.children as AnyNode[]; + for (const [state, row] of Object.entries(ROWS)) { + const scope = scopeOf(row) as Record; + const want = EXPECTED[state]; + expect(evalExpr(String(primary.text), scope), `${state} 1차`).toBe(`sirsoft-message_bizppurio.template.approval.${want.primary}`); + const detailVisible = evalBinding(String(detail.if), scope); + expect(detailVisible, `${state} 2차 노출`).toBe(want.detail !== null); + if (want.detail) { + expect(evalExpr(String(detail.text), scope), `${state} 2차`).toBe(`(sirsoft-message_bizppurio.template.status.${want.detail})`); + } + // 색상 클래스는 승인/검수중/반려/휴면/중지·차단/기타 6계열 — 승인됨만 green + const cls = String(evalExpr(String((badge.props as { className: string }).className), scope)); + expect(cls.includes('bg-green-100'), `${state} green`).toBe(state === 'approved'); + } + }); +}); + +describe('lifecycle UI — [편집] 모달 섹션: 시딩(onMount) + 편집/잠금 분기 실평가', () => { + const root = findById(sectionsRoot, 'bizppurio_tpl_sections') as AnyNode; + const onMount = ((root.lifecycle as { onMount?: AnyNode[] })?.onMount ?? []); + const steps = ((onMount[0]?.params as { actions?: AnyNode[] })?.actions ?? []); + + it('마운트 시 sequence 1개: 상태 시딩 → 상세 GET(행 있을 때) → 카테고리·발신프로필 조회', () => { + expect(onMount).toHaveLength(1); + expect(onMount[0].handler).toBe('sequence'); + expect(steps.map((s) => s.handler)).toEqual(['setState', 'apiCall', 'refetchDataSource', 'refetchDataSource']); + const seed = steps[0].params as Record; + for (const key of ['bz_tpl_modal', 'bz_sms_modal', 'bz_tpl_upload', 'bz_tpl_ui']) expect(seed[key], key).toBeTruthy(); + expect((steps[2].params as { dataSourceId: string }).dataSourceId).toBe('bizppurioCategories'); + expect((steps[3].params as { dataSourceId: string }).dataSourceId).toBe('bizppurioProfiles'); + }); + + it('상세 GET 은 요약 맵의 행 id 로 판정·호출한다(같은 sequence 의 setState 값은 다음 액션 if 에 반영되지 않는다 — 실측)', () => { + const detail = steps[1]; + expect(detail.target).toBe(`/api/plugins/sirsoft-message_bizppurio/admin/templates/{{${MAP_PATH}?.id}}`); + expect(String(detail.if)).not.toContain('_global.bz_tpl_modal'); + expect(evalBinding(String(detail.if), mapScope(ROWS.draft))).toBe(true); + expect(evalBinding(String(detail.if), mapScope(ROWS['행만 있고 내용 없음']))).toBe(true); + expect(evalBinding(String(detail.if), mapScope(ROWS['행 없음']))).toBe(false); + const onSuccess = JSON.stringify(detail.onSuccess); + expect(onSuccess).toContain('bz_tpl_modal.content'); + expect(onSuccess).toContain('bz_tpl_modal.status'); + expect(onSuccess).toContain('bz_tpl_modal.has_content'); + }); + + it('시딩값: SMS 본문·토글은 요약 맵에서, 알림톡 본문 프리필은 코어 alimtalk 템플릿 body 에서(#{var} 변환)', () => { + const seed = steps[0].params as { bz_tpl_modal: Record; bz_sms_modal: Record }; + expect(seed.bz_sms_modal.body).toBe(`{{Object.assign({}, ${MAP_PATH}?.sms_body)}}`); + expect(seed.bz_sms_modal.fallback_sms_enabled).toBe(`{{!!(${MAP_PATH}?.fallback_sms_enabled)}}`); + expect(seed.bz_sms_modal.sms_only).toBe(`{{!!(${MAP_PATH}?.sms_only)}}`); + expect(seed.bz_sms_modal.editLang).toBe('{{$locale}}'); + expect(seed.bz_tpl_modal.has_content).toBe(`{{!!(${MAP_PATH}?.has_content)}}`); + expect(String((seed.bz_tpl_modal.content as Record).templateContent)).toContain("tpl.channel === 'alimtalk'"); + expect(String((seed.bz_tpl_modal.content as Record).templateContent)).toContain(".split('{').join('#{')"); + }); + + const editor = findById(sectionsRoot, 'bizppurio_tpl_editor') as AnyNode; + const locked = findById(sectionsRoot, 'bizppurio_tpl_locked') as AnyNode; + const loadingNotice = findById(sectionsRoot, 'bizppurio_tpl_loading') as AnyNode; + /** 자기 알림 유형으로 시딩이 끝나고 상세 GET 도 끝난 상태(READY) */ + const readyScope = (modal: Record) => ({ + _global: { bz_tpl_modal: { notification_type: 'welcome', loading: false, ...modal } }, + extensionPointProps: { definition: { type: 'welcome' }, channel: 'alimtalk' }, + }); + + it.each([ + ['draft', true], ['rejected', true], [undefined, true], + ['requested', false], ['approved', false], ['dormant', false], ['stopped', false], ['blocked', false], + ])('상태 %s → 작성 폼 노출=%s, 잠금 요약은 그 반대 (READY 상태에서)', (status, editable) => { + const scope = readyScope(status === undefined ? {} : { status }); + expect(evalBinding(String(editor.if), scope)).toBe(editable); + expect(evalBinding(String(locked.if), scope)).toBe(!editable); + }); + + it('READY(자기 유형 시딩 + 상세 GET 완료) 전에는 작성 폼·잠금 요약이 모두 숨고 로딩 안내만 보인다 — 시딩 전/이전 알림 상태로 저장되는 경쟁 차단', () => { + expect(loadingNotice).toBeTruthy(); + const seed = (steps[0].params as { bz_tpl_modal: Record }).bz_tpl_modal; + expect(seed.loading).toBe(`{{!!(${MAP_PATH}?.id)}}`); + expect(seed.notification_type).toBe('{{extensionPointProps.definition?.type}}'); + expect(JSON.stringify(steps[1].onSuccess)).toContain('"bz_tpl_modal.loading":false'); + expect(JSON.stringify(steps[1].onError)).toContain('"bz_tpl_modal.loading":false'); + + const NOT_READY = [ + ['시딩 전(상태 없음)', { _global: {}, extensionPointProps: { definition: { type: 'welcome' } } }], + ['이전 알림의 상태', { _global: { bz_tpl_modal: { notification_type: 'reset_password', status: 'draft', loading: false } }, extensionPointProps: { definition: { type: 'welcome' } } }], + ['상세 GET 진행 중', { _global: { bz_tpl_modal: { notification_type: 'welcome', status: 'draft', loading: true } }, extensionPointProps: { definition: { type: 'welcome' } } }], + ] as const; + for (const [label, scope] of NOT_READY) { + expect(evalBinding(String(editor.if), scope as Record), `${label}: 폼`).toBe(false); + expect(evalBinding(String(locked.if), scope as Record), `${label}: 잠금 요약`).toBe(false); + expect(evalBinding(String(loadingNotice.if), scope as Record), `${label}: 로딩 안내`).toBe(true); + } + for (const status of ['draft', 'requested', 'approved']) { + const ready = readyScope({ status }); + expect(evalBinding(String(loadingNotice.if), ready)).toBe(false); + expect(evalBinding(String(editor.if), ready) || evalBinding(String(locked.if), ready), `${status}: READY 후 둘 중 하나`).toBe(true); + } + }); + + it('모달이 닫히면(언마운트) 폼 상태를 비워 다음 오픈이 시딩 전에서 시작한다', () => { + const root = findById(sectionsRoot, 'bizppurio_tpl_sections') as AnyNode; + const onUnmount = ((root.lifecycle as { onUnmount?: AnyNode[] })?.onUnmount ?? []); + expect(onUnmount).toHaveLength(1); + expect(onUnmount[0].handler).toBe('setState'); + const params = onUnmount[0].params as Record; + expect(params.bz_tpl_modal).toBeNull(); + expect(params.bz_sms_modal).toBeNull(); + }); + + it('작성 폼에는 구 작성 모달의 필드 전부가 이식됐다(유형·강조·본문·버튼·바로연결·업로드)', () => { + const raw = JSON.stringify(editor); + for (const needle of ['templateMessageType', 'templateEmphasizeType', 'bz_template_content', 'buttons', 'quickReplies', 'sirsoft-message_bizppurio.uploadTemplateImage', 'templateImageUrl', 'templateItem']) { + expect(raw, needle).toContain(needle); + } + }); + + it('상태 액션 4종의 노출 조건이 상태별로 정확히 평가된다', () => { + const BUTTONS: Record) => boolean> = { + 'template.row.btn_refresh': (s) => Boolean(s.template_code), + 'template.row.btn_cancel_request': (s) => s.status === 'requested', + 'template.row.btn_release': (s) => s.status === 'dormant', + 'template.row.btn_edit_approved': (s) => s.status === 'approved', + }; + for (const [key, expected] of Object.entries(BUTTONS)) { + const btn = findButtonByTextKey(sectionsRoot, key); + expect(btn, key).toBeTruthy(); + for (const [state, row] of Object.entries(ROWS)) { + if (!row) continue; + const scope = { _global: { bz_tpl_modal: { status: row.status, template_code: row.template_code ?? null }, bz_tpl_ui: { confirmCancelApproval: false } } }; + expect(evalBinding(String(btn?.if), scope), `${key} @ ${state}`).toBe(expected(row)); + } + } + }); + + it('승인 취소는 인라인 확인 박스(모달 중첩 없음) — 확정 시 POST .../cancel-approval 후 draft 로 되돌린다', () => { + const confirmBtn = findButtonByTextKey(sectionsRoot, 'template.cancel_approval.btn_confirm'); + expect(confirmBtn).toBeTruthy(); + const raw = JSON.stringify(confirmBtn); + expect(raw).toContain(`/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/cancel-approval`); + expect(raw).toContain('"bz_tpl_modal.status":"draft"'); + expect(JSON.stringify(sectionsRoot)).not.toContain('openModal'); + expect(JSON.stringify(sectionsRoot)).toContain('template.cancel_approval.warning'); + }); + + it('발송 토글(알림톡 사용·대체 SMS·SMS 단독)은 상태에만 쓰고 즉시 저장하지 않는다(통합 저장 원칙)', () => { + const boxes = findAllByName(sectionsRoot, 'Checkbox'); + const bound = boxes.map((c) => String((c.props as { checked?: string })?.checked ?? '')); + expect(bound.some((b) => b.includes('bz_tpl_modal?.alimtalk_enabled'))).toBe(true); + expect(bound.some((b) => b.includes('bz_sms_modal?.fallback_sms_enabled'))).toBe(true); + expect(bound.some((b) => b.includes('bz_sms_modal?.sms_only'))).toBe(true); + for (const c of boxes) { + expect((c.props as { autoBinding?: boolean }).autoBinding).toBe(false); + const change = (c.actions as AnyNode[]).find((a) => a.type === 'change') as AnyNode; + expect(change.handler, '토글은 setState 만').toBe('setState'); + } + }); + + it('반려 배너는 inspection_detail 스냅샷을 반복 렌더한다(반려 사유 모달 대체)', () => { + const raw = JSON.stringify(sectionsRoot); + expect(raw).toContain('template.form.rejected_banner'); + expect(raw).toContain('_global.bz_tpl_modal?.inspection_detail ?? []'); + }); +}); + +describe('lifecycle UI — 통합 [저장] 체인 (푸터 액션)', () => { + const buttons = findAllByName(actionsRoot, 'Button'); + const saveBtn = buttons.find((b) => !b.text && JSON.stringify(b).includes('$t:common.save')) as AnyNode; + const requestBtn = findButtonByTextKey(actionsRoot, 'template.form.btn_save_request') as AnyNode; + + it('[저장] + [저장 후 검수 신청] 두 버튼이며, 검수 신청은 수정 가능 상태 + 본문 입력이 있을 때만 노출된다', () => { + expect(buttons).toHaveLength(2); + expect(saveBtn).toBeTruthy(); + expect(requestBtn).toBeTruthy(); + expect(saveBtn.if).toBeUndefined(); + const cond = String(requestBtn.if); + const sc = (modal: Record) => ({ _global: { bz_tpl_modal: { notification_type: 'welcome', loading: false, ...modal } }, extensionPointProps: { definition: { type: 'welcome' } } }); + expect(evalBinding(cond, sc({ status: 'draft', content: { templateContent: '본문' } }))).toBe(true); + expect(evalBinding(cond, sc({ status: 'rejected', content: { templateContent: '본문' } }))).toBe(true); + expect(evalBinding(cond, sc({ content: { templateContent: '본문' } })), '행 없음(신규)').toBe(true); + expect(evalBinding(cond, sc({ status: 'draft', content: { templateContent: ' ' } })), '공백 본문').toBe(false); + expect(evalBinding(cond, sc({ status: 'requested', content: { templateContent: '본문' } }))).toBe(false); + expect(evalBinding(cond, sc({ status: 'approved', content: { templateContent: '본문' } }))).toBe(false); + expect(evalBinding(cond, sc({ status: 'draft', loading: true, content: { templateContent: '본문' } })), '상세 GET 전').toBe(false); + }); + + it('두 버튼 모두 클릭 if 재진입 가드 + isSaving/uploading disabled 바인딩을 갖는다', () => { + for (const b of [saveBtn, requestBtn]) { + const click = (b.actions as AnyNode[])[0]; + expect(click.type).toBe('click'); + expect(click.if).toMatch(/!\(_global\.bz_tpl_modal\?\.isSaving \?\? false\)/); + const disabled = String((b.props as { disabled?: string }).disabled); + const ep = { definition: { type: 'welcome' } }; + expect(evalBinding(disabled, { _global: { bz_tpl_modal: { isSaving: true, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: ep })).toBe(true); + expect(evalBinding(disabled, { _global: { bz_tpl_modal: { isSaving: false, notification_type: 'welcome' }, bz_tpl_upload: { uploading: true } }, extensionPointProps: ep })).toBe(true); + expect(evalBinding(disabled, { _global: { bz_tpl_modal: { isSaving: false, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: ep })).toBe(false); + const ready = { _global: { bz_tpl_modal: { isSaving: false, loading: false, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: { definition: { type: 'welcome' } } }; + const notReady = { _global: { bz_tpl_modal: { isSaving: false, loading: true, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: { definition: { type: 'welcome' } } }; + const otherType = { _global: { bz_tpl_modal: { isSaving: false, loading: false, notification_type: 'reset_password' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: { definition: { type: 'welcome' } } }; + expect(evalBinding(disabled, ready), 'READY 후 저장 열림').toBe(false); + expect(evalBinding(disabled, notReady), '상세 GET 전 저장 잠금').toBe(true); + expect(evalBinding(disabled, otherType), '이전 알림 상태로는 저장 잠금').toBe(true); + expect(evalBinding(String(click.if), notReady), '상세 GET 전 클릭 가드').toBe(false); + expect(evalBinding(String(click.if), ready), 'READY 후 클릭 허용').toBe(true); + } + }); + + /** 저장 상태 매트릭스 → 실행돼야 하는 분기 번호(①POST ②PUT ③delivery) */ + const MATRIX: Array<[string, Record, 1 | 2 | 3]> = [ + ['행 없음 + 본문 입력', { content: { templateContent: '본문' } }, 1], + ['행 없음 + 본문 없음(SMS 만)', { content: { templateContent: '' } }, 3], + ['draft + 본문 입력', { id: 1, status: 'draft', content: { templateContent: '본문' } }, 2], + ['rejected + 본문 입력', { id: 1, status: 'rejected', content: { templateContent: '본문' } }, 2], + ['draft + 본문 없음', { id: 1, status: 'draft', content: { templateContent: '' } }, 3], + ['requested(잠금) + 본문 있음', { id: 1, status: 'requested', content: { templateContent: '본문' } }, 3], + ['approved(잠금) + 본문 있음', { id: 1, status: 'approved', content: { templateContent: '본문' } }, 3], + ['dormant(잠금)', { id: 1, status: 'dormant', content: { templateContent: '본문' } }, 3], + ]; + + it.each(MATRIX)('[저장] %s → 분기 정확히 하나만 실행된다', (_label, modal, want) => { + const steps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions; + expect(steps[0].handler).toBe('setState'); + const branches = steps.slice(1); + expect(branches).toHaveLength(3); + const fired = branches.map((b) => evalBinding(String(b.if), { _global: { bz_tpl_modal: modal } })); + expect(fired.filter(Boolean)).toHaveLength(1); + expect(fired.indexOf(true) + 1).toBe(want); + }); + + it('분기 엔드포인트/메서드: ① POST templates ② PUT templates/{id} ③ PUT templates/delivery/{type} — delivery 필드 4종은 셋 다 싣는다', () => { + const steps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1); + expect([steps[0].target, (steps[0].params as { method: string }).method]).toEqual(['/api/plugins/sirsoft-message_bizppurio/admin/templates', 'POST']); + expect([steps[1].target, (steps[1].params as { method: string }).method]).toEqual(['/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}', 'PUT']); + expect([steps[2].target, (steps[2].params as { method: string }).method]).toEqual(['/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_tpl_modal?.notification_type}}', 'PUT']); + for (const s of steps) { + const body = (s.params as { body: Record }).body; + for (const k of ['alimtalk_enabled', 'fallback_sms_enabled', 'sms_only', 'sms_body']) expect(body[k], `${s.comment} ${k}`).toBeTruthy(); + expect(body.sms_body).toBe('{{Object.assign({}, _global.bz_sms_modal?.body)}}'); + } + expect((steps[0].params as { body: Record }).body.content).toBeTruthy(); + expect((steps[1].params as { body: Record }).body.content).toBeTruthy(); + expect((steps[2].params as { body: Record }).body.content, '잠금 상태에서는 content 를 보내지 않는다(422 content_locked 회피)').toBeUndefined(); + }); + + it('각 분기 성공 후: 수신자 규칙이 바뀐 경우에만 코어 PUT(extensionPointProps.saveEndpoint) → 마무리, 아니면 바로 마무리', () => { + const steps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1); + for (const s of steps) { + const tail = s.onSuccess as AnyNode[]; + expect(tail).toHaveLength(2); + const [corePut, skip] = tail; + expect(corePut.handler).toBe('apiCall'); + expect(corePut.target).toBe('{{extensionPointProps.saveEndpoint}}'); + expect((corePut.params as { method: string }).method).toBe('PUT'); + expect(skip.handler).toBe('sequence'); + const changed = { _global: { notification_template_form_modal: { recipients: [{ type: 'role', role_ids: [1] }], template: { recipients: [{ type: 'trigger_user' }] } } }, extensionPointProps: { stateKey: 'notification_template_form_modal' } }; + const same = { _global: { notification_template_form_modal: { recipients: [{ type: 'trigger_user' }], template: { recipients: [{ type: 'trigger_user' }] } } }, extensionPointProps: { stateKey: 'notification_template_form_modal' } }; + expect(evalBinding(String(corePut.if), changed)).toBe(true); + expect(evalBinding(String(skip.if), changed)).toBe(false); + expect(evalBinding(String(corePut.if), same)).toBe(false); + expect(evalBinding(String(skip.if), same)).toBe(true); + // 코어 PUT 본문은 코어 모달의 저장 계약과 같은 4필드, 값은 extensionPointProps.stateKey 로 간접 참조 + const body = (corePut.params as { body: Record }).body; + expect(Object.keys(body).sort()).toEqual(['body', 'click_url', 'recipients', 'subject']); + for (const v of Object.values(body)) expect(v).toContain('_global?.[extensionPointProps.stateKey]'); + // 마무리: 요약 맵 + 면별 정의 목록 재조회 → 토스트 → closeModal + for (const fin of [corePut.onSuccess as AnyNode[], (skip.params as { actions: AnyNode[] }).actions]) { + const handlers = fin.map((a) => a.handler); + expect(handlers).toEqual(['setState', 'refetchDataSource', 'refetchDataSource', 'toast', 'closeModal']); + expect((fin[1].params as { dataSourceId: string }).dataSourceId).toBe('bizppurioTemplates'); + expect((fin[2].params as { dataSourceId: string }).dataSourceId).toBe('{{extensionPointProps.refetchDataSourceId}}'); + } + } + }); + + it('[저장 후 검수 신청]: 알림톡 upsert(①/②) 성공 → POST .../request → 코어 PUT(조건) → 마무리; delivery 전용 분기 ③ 은 없다', () => { + const steps = ((requestBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1); + expect(steps).toHaveLength(2); + const [create, update] = steps; + const createReq = (create.onSuccess as AnyNode[])[0]; + expect(createReq.target).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request'); + const updateReq = (update.onSuccess as AnyNode[])[0]; + expect(updateReq.target).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request'); + for (const req of [createReq, updateReq]) { + expect((req.params as { method: string }).method).toBe('POST'); + expect((req.onSuccess as AnyNode[])[0].target).toBe('{{extensionPointProps.saveEndpoint}}'); + expect(JSON.stringify(req.onSuccess)).toContain('template.form.requested_toast'); + } + }); + + it('[저장 후 검수 신청]의 request 2노드는 검수자 전달 의견(bz_tpl_modal.request_comment)을 comment 로 싣는다 (#597 §18.7)', () => { + const steps = ((requestBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1); + const reqs = steps.map((s) => (s.onSuccess as AnyNode[])[0]); + expect(reqs).toHaveLength(2); + for (const req of reqs) { + const body = (req.params as { body: Record }).body; + expect(Object.keys(body)).toEqual(['comment']); + expect(evalExpr(body.comment, { _global: { bz_tpl_modal: { request_comment: '변수 예시: #{name}=홍길동' } } })).toBe('변수 예시: #{name}=홍길동'); + expect(evalExpr(body.comment, { _global: { bz_tpl_modal: {} } }), '미입력 시 빈 문자열(서버가 미전달로 처리)').toBe(''); + } + // 일반 [저장] 3분기에는 request 호출 자체가 없으므로 comment 도 없다 + const saveSteps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1); + expect(JSON.stringify(saveSteps)).not.toContain('/request"'); + }); + + it('실패 시 isSaving 해제 + 사유(bizppurio_message 우선)·필드 오류 객체를 상태에 싣는다(토스트가 아니라 섹션 배너가 표시)', () => { + const fails = collectActions(actionsRoot).filter((a) => a.handler === 'apiCall').map((a) => JSON.stringify(a.onError)); + expect(fails.length).toBeGreaterThan(0); + for (const f of fails) { + expect(f).toContain('"bz_tpl_modal.isSaving":false'); + expect(f).toContain('error.errors?.bizppurio_message ?? error.message'); + expect(f).toContain('"bz_tpl_modal.errors"'); + } + const raw = JSON.stringify(sectionsRoot); + expect(raw).toContain("Object.values(_global.bz_tpl_modal?.errors ?? {}).flat()"); + }); +}); + +describe('lifecycle UI — 검수자 전달 의견 입력란 (#597 §18.7, 제품 결정 2026-08-23)', () => { + const editor = findById(sectionsRoot, 'bizppurio_tpl_editor') as AnyNode; + const block = findById(editor, 'bizppurio_tpl_request_comment') as AnyNode; + + it('작성 폼(bizppurio_tpl_editor) 안에만 있다 — 잠금 요약에는 없다', () => { + expect(block).toBeTruthy(); + expect(findById(findById(sectionsRoot, 'bizppurio_tpl_locked') as AnyNode, 'bizppurio_tpl_request_comment')).toBeFalsy(); + }); + + it('Textarea(bz_request_comment, ≤500) 가 bz_tpl_modal.request_comment 를 읽고 쓰며 카운터가 /500 이다', () => { + const ta = findAllByName(block, 'Textarea')[0] as AnyNode; + const props = ta.props as Record; + expect(props.name).toBe('bz_request_comment'); + expect(props.maxLength).toBe(500); + expect(props.value).toBe("{{_global.bz_tpl_modal?.request_comment ?? ''}}"); + const change = (ta.actions as AnyNode[])[0]; + expect(change.type).toBe('change'); + expect((change.params as Record)['bz_tpl_modal.request_comment']).toBe('{{$event.target.value}}'); + const counter = findAllByName(block, 'Span').map((s) => String(s.text)).find((t) => t.endsWith('/500')) as string; + expect(evalExpr(counter.replace('/500', ''), { _global: { bz_tpl_modal: { request_comment: '12345' } } })).toBe(5); + }); + + it('모달 시딩이 request_comment 를 빈 문자열로 초기화한다(이전 알림의 의견 이월 금지)', () => { + const seeds = collectActions(sectionsRoot) + .filter((a) => a.handler === 'setState' && (a.params as Record)?.bz_tpl_modal); + expect(seeds.length).toBeGreaterThan(0); + for (const s of seeds) { + expect(((s.params as Record).bz_tpl_modal as Record).request_comment).toBe(''); + } + }); + + it('라벨·placeholder·안내 키가 ko/en 에 존재한다', () => { + for (const key of ['request_comment', 'request_comment_placeholder', 'request_comment_hint']) { + expect(resolve(ko, `sirsoft-message_bizppurio.template.form.${key}`), `ko ${key}`).toBeTruthy(); + expect(resolve(en, `sirsoft-message_bizppurio.template.form.${key}`), `en ${key}`).toBeTruthy(); + } + }); + + /** children 경로의 조상 체인(루트→부모). id 가 없으면 null. */ + const ancestorsOf = (root: AnyNode, id: string, anc: AnyNode[] = []): AnyNode[] | null => { + for (const c of (root.children as AnyNode[] | undefined) ?? []) { + if (c.id === id) return anc.concat(root); + const r = ancestorsOf(c, id, anc.concat(root)); + if (r) return r; + } + return null; + }; + + it('바로연결 블록(헤더+목록) 바로 뒤 형제로 놓이고, 라벨+[바로연결 추가] flex 행 안에 끼지 않는다 (회귀: 화면 검수 지적 2026-08-23)', () => { + const chain = ancestorsOf(editor, 'bizppurio_tpl_request_comment') as AnyNode[]; + expect(chain).toBeTruthy(); + const parent = chain[chain.length - 1]; + const siblings = parent.children as AnyNode[]; + const idx = siblings.findIndex((c) => c.id === 'bizppurio_tpl_request_comment'); + const prev = siblings[idx - 1]; + // 직전 형제 = 바로연결 블록 전체(추가 버튼 + 목록 iteration 을 모두 품는다) + expect(JSON.stringify(prev)).toContain('template.form.quick_reply_add'); + expect(JSON.stringify(prev)).toContain('quickRepliesItem'); + // 의견 블록의 조상 어디에도 flex 행이 없다(헤더 행 내부 삽입이면 라벨이 세로로 찌그러진다) + for (const a of chain) { + expect(String((a.props as { className?: string } | undefined)?.className ?? ''), `ancestor ${a.id ?? a.name}`).not.toMatch(/\bflex\b/); + } + }); +}); + +describe('lifecycle UI — 코어 계약 준수 + i18n 정합', () => { + it('플러그인 파일 어디에도 코어 엔드포인트·코어 모달 상태 키 리터럴이 없다(전부 extensionPointProps 간접 참조)', () => { + const all = JSON.stringify([overlay, footer, sections, footerActions]); + expect(all).not.toContain('/api/admin/notification-templates/'); + expect(all).not.toContain('notification_template_form_modal'); + expect(all).not.toContain('modal_notification_template_form'); + }); + + it('참조하는 모든 플러그인 i18n 정적 키가 ko·en 에 존재한다', () => { + const keys = [overlay, footer, sections, footerActions].flatMap(collectPluginKeys); + 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.' 8종 + 'template.approval.' 2종이 ko·en 에 모두 존재한다", () => { + 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()); + expect(Object.keys(dict.template.approval).sort()).toEqual(['approved', 'not_approved']); + expect(Object.keys(dict.template.row)).toEqual(expect.arrayContaining(['on', 'off', 'sms_label', 'alimtalk_disabled'])); + } + }); + + it("동적 접두 'templates.link_type.' 가족: 작성 폼이 사용하는 링크 유형 코드가 ko·en 에 모두 존재한다", () => { + 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 — 바인딩 브레이스 위생 (회귀: 프리필 끝 `}}` 잔재)', () => { + const FILES = [ + ['core', coreOverlay], ['board', boardOverlay], ['ecommerce', ecommerceOverlay], + ['footer', footer], ['sections', sections], ['footerActions', footerActions], + ] as const; + + it('문자열 값 어디에도 4연속 브레이스(}}}}·{{{{)가 없다', () => { + for (const [name, json] of FILES) { + const offenders = collectStrings(json).filter((v) => v.includes('}}}}') || v.includes('{{{{')); + expect(offenders, `${name} 브레이스 잔재: ${offenders.join(' | ')}`).toEqual([]); + } + }); + + it('표현식 $t 를 2-인자(파라미터 객체)로 호출하지 않는다 — 엔진은 단일 인자만 지원', () => { + for (const [name, json] of FILES) { + const offenders = collectStrings(json).filter((v) => /\$t\([^)]*,\s*\{/.test(v)); + expect(offenders, `${name} 2-인자 $t 호출: ${offenders.join(' | ')}`).toEqual([]); + } + }); +}); + +/** + * @effects upload_in_progress_locks_save_buttons_not_cancel + */ +describe('lifecycle UI — 이미지 업로드 상태 배선', () => { + it('업로드 진행 중에는 통합 저장 계열 버튼이 잠긴다(취소는 코어 버튼이라 플러그인이 잠글 수 없다)', () => { + const buttons = findAllByName(actionsRoot, 'Button'); + expect(buttons).toHaveLength(2); + for (const b of buttons) { + const disabled = String((b.props as { disabled?: string } | undefined)?.disabled ?? ''); + const ep = { definition: { type: 'welcome' } }; + expect(evalBinding(disabled, { _global: { bz_tpl_modal: { notification_type: 'welcome' }, bz_tpl_upload: { uploading: true } }, extensionPointProps: ep })).toBe(true); + expect(evalBinding(disabled, { _global: { bz_tpl_modal: { notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: ep })).toBe(false); + } + expect(JSON.stringify(actionsRoot)).not.toContain('$t:common.cancel'); + }); + + it('업로드 실패 배너는 상태에서만 읽고 fallback 을 가진다', () => { + expect(JSON.stringify(sectionsRoot)).toContain("_global.bz_tpl_upload?.error ?? ''"); + }); +}); + +describe('lifecycle UI — SMS 본문 다국어', () => { + const raw = JSON.stringify(sectionsRoot); + + it('SMS 섹션은 로케일 탭을 제공하고 본문을 로케일별로 읽는다', () => { + const tabs = findById(sectionsRoot, 'bz_sms_lang_tabs'); + expect(tabs, 'SMS 본문 언어 탭이 없으면 ko 한 벌만 입력된다').toBeTruthy(); + expect(JSON.stringify(tabs)).toContain('{{$locales}}'); + expect(raw).toContain('bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale]'); + }); + + it('변수 삽입 대상 경로가 현재 편집 로케일을 가리킨다', () => { + expect(raw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)"); + }); +}); + +describe('compose form — 유형 전환·반복 입력 실평가 (#597 §14.2 T7 / §6.2)', () => { + const evalWithModal = (expr: string, modal: Record): boolean => + evalBinding(expr, { _global: { bz_tpl_modal: modal, bz_tpl_upload: {} } }); + + 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(sectionsRoot); + return found; + }; + + it.each(['TEXT', 'IMAGE', 'ITEM_LIST'])('강조유형 %s 블록은 그 유형에서만 노출된다', (type) => { + const expr = conditionalBlocks()[type]; + expect(expr, `${type} 조건부 블록이 섹션에 없다`).toBeTruthy(); + for (const current of ['NONE', 'TEXT', 'IMAGE', 'ITEM_LIST']) { + expect(evalWithModal(expr, { content: { templateEmphasizeType: current } }), `${current} → ${type}`).toBe(current === type); + } + }); + + it('부가정보(templateExtra) 는 EX·MI 메시지 유형에서만 노출된다', () => { + expect(JSON.stringify(sectionsRoot)).toContain("_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'"); + }); + + const REPEAT_CAPS: Array<[string, number]> = [['templateItem?.list', 10], ['buttons', 5], ['quickReplies', 10]]; + + it.each(REPEAT_CAPS)('반복 입력 %s 는 상한 %i 에 도달하면 추가가 잠긴다', (pathFragment, cap) => { + const addBtn = findAllByName(sectionsRoot, 'Button').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 }; + }; + expect(evalWithModal(expr, { content: makeContent(cap - 1) })).toBe(false); + expect(evalWithModal(expr, { content: makeContent(cap) })).toBe(true); + expect(evalWithModal(expr, { content: makeContent(cap + 1) })).toBe(true); + expect(evalWithModal(expr, { content: {} })).toBe(false); + }); + + it('버튼 linkType 별 조건부 링크 필드가 선택한 유형에서만 노출된다(부록 A-3 대응표 기준)', () => { + 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(sectionsRoot, 'Input').filter((n) => /buttonsItem\.linkType === '/.test(String((n as { if?: string }).if ?? ''))); + 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] as string; + expect(EXPECTED_FOR_FIELD[field], `부록 A-3 에 없는 조건부 필드: ${field}`).toBeTruthy(); + seen.add(field); + for (const lt of ALL_LINK_TYPES) { + expect(evalBinding(String((node as { if?: string }).if), { buttonsItem: { linkType: lt } }), `${field} @ ${lt}`).toBe(EXPECTED_FOR_FIELD[field].includes(lt)); + } + } + 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 new file mode 100644 index 00000000..998b17a5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx @@ -0,0 +1,435 @@ +/** + * 비즈뿌리오 메시징 플러그인 환경설정 레이아웃 구조 검증 (§6-1) + * + * 3 섹션: + * 1. section_api (연동 환경 + 비즈뿌리오 아이디 + 비밀번호 + API 키) + * 2. section_sending (발신번호 + 알림톡 발신프로필 키) + * 3. section_integration (webhook 수신 주소 안내) + * + * 크리덴셜(password/api_key/sender_key)은 type=password 로 마스킹, 저장은 + * 자동바인딩(_local.form) → 코어 /api/admin/plugins/{id}/settings PUT. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import layout from '../../../layouts/admin/plugin_settings.json'; +import ko from '../../../lang/ko.json'; +import en from '../../../lang/en.json'; +import { + findById, + findInputByName, + collectHandlers, + collectI18nKeys, + type AnyNode, +} from './helpers'; +import { + createLayoutTest, + createMockComponentRegistryWithBasics, +} from '@core/template-engine/__tests__/utils/layoutTestUtils'; + +const root = layout as unknown as AnyNode; + +describe('plugin_settings.json — 레이아웃 메타/권한', () => { + it('layout_name 이 plugin_settings 이다', () => { + expect((root as { layout_name?: string }).layout_name).toBe('plugin_settings'); + }); + + it('_admin_base 를 상속한다', () => { + expect((root as { extends?: string }).extends).toBe('_admin_base'); + }); + + it('core.plugins.update 권한을 요구한다', () => { + expect((root as { permissions?: string[] }).permissions).toContain('core.plugins.update'); + }); + + it('settings 데이터소스가 코어 플러그인 설정 API 를 조회한다', () => { + const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; + const settings = sources.find((s) => s.id === 'settings'); + expect(settings).toBeTruthy(); + expect(settings?.endpoint).toBe('/api/admin/plugins/{{route.identifier}}/settings'); + expect(settings?.initLocal).toBe('form'); + }); +}); + +describe('plugin_settings.json — 자동바인딩', () => { + it('환경설정 탭 패널이 dataKey=form + trackChanges 로 자동바인딩한다', () => { + const container = findById(root, 'connection_tab_panel'); + expect(container).toBeTruthy(); + expect((container as { dataKey?: string }).dataKey).toBe('form'); + expect((container as { trackChanges?: boolean }).trackChanges).toBe(true); + }); +}); + +describe('plugin_settings.json — 섹션', () => { + it.each([ + ['preparation_notice', '사용 전 준비 안내(상단)'], + ['section_api', 'API 연동'], + ['section_sending', '발송 설정'], + ['report_section', '리포트 수신 설정'], + ])('%s 섹션이 존재한다', (id) => { + expect(findById(root, id)).toBeTruthy(); + }); + + it('준비 안내 박스는 총괄 안내(intro)와 콘솔 링크를 구분선 위에 먼저 노출한다', () => { + const notice = findById(root, 'preparation_notice'); + const raw = JSON.stringify(notice); + // 총괄 안내 문구 키 + 콘솔 링크가 존재한다 + expect(raw).toContain('sirsoft-message_bizppurio.settings.preparation.intro'); + expect(raw).toContain('sirsoft-message_bizppurio.settings.preparation.console_link'); + // 총괄 안내가 채널별 준비 목록(sms_label)보다 먼저 배치된다 (이미지1 구조) + const introIdx = raw.indexOf('preparation.intro'); + const smsIdx = raw.indexOf('preparation.sms_label'); + expect(introIdx).toBeGreaterThanOrEqual(0); + expect(smsIdx).toBeGreaterThanOrEqual(0); + expect(introIdx).toBeLessThan(smsIdx); + }); + + it('채널별 준비 목록은 PC(lg 이상) 2단, 모바일 1단 그리드로 배치된다', () => { + // 문자·카카오 블록을 감싼 컨테이너가 grid grid-cols-1 lg:grid-cols-2 여야 한다. + // sms_label 을 담은 블록의 부모(구분선 아래 컨테이너)에서 grid 클래스를 확인. + const notice = findById(root, 'preparation_notice'); + const raw = JSON.stringify(notice); + // 세로 1단 고정(flex-col) 이 아니라 반응형 grid 여야 한다. + expect(raw).toContain('grid-cols-1'); + expect(raw).toContain('lg:grid-cols-2'); + }); + + it('문자·카카오 준비 블록은 카드(배경+테두리)로 감싸고 채널 아이콘을 라벨에 붙인다', () => { + const notice = findById(root, 'preparation_notice'); + const raw = JSON.stringify(notice); + // 각 열이 카드로 감싸짐 (다크모드 쌍 포함 solid 배경) + expect(raw).toContain('bg-blue-100'); + expect(raw).toContain('dark:bg-blue-900'); + // 채널별 아이콘 (문자=envelope, 카카오=comment-dots) + expect(raw).toContain('"envelope"'); + expect(raw).toContain('"comment-dots"'); + }); + + it('검수 모드 카드에 is_test_mode Toggle 이 있다', () => { + const card = findById(root, 'test_mode_card'); + expect(card).toBeTruthy(); + const raw = JSON.stringify(card); + expect(raw).toContain('"Toggle"'); + expect(raw).toContain('is_test_mode'); + }); + + it('검수 모드 카드에 검수/운영 계정 분리 권장 안내가 상시 노출된다', () => { + // 검수 켜짐/꺼짐과 무관하게(if 조건 없이) 카드 안에 계정 분리 권장 문구가 있어야 한다. + const card = findById(root, 'test_mode_card') as { if?: string } | null; + expect(card).toBeTruthy(); + expect(card?.if).toBeUndefined(); // 카드 자체가 조건부가 아님 → 상시 노출 + const raw = JSON.stringify(card); + expect(raw).toContain('settings.test_mode.account_notice'); + }); + + it('운영 모드(검수 off) 경고 박스가 조건부로 존재한다', () => { + const warning = findById(root, 'live_mode_warning'); + expect(warning).toBeTruthy(); + expect((warning as { if?: string }).if).toContain('!_local.form.is_test_mode'); + }); +}); + +describe('plugin_settings.json — 입력 필드 6종', () => { + it.each([ + 'bizppurio_id', + 'password', + 'api_key', + 'sender_number', + 'sender_key', + ])('%s 입력 필드가 자동바인딩 name 으로 존재한다', (name) => { + expect(findInputByName(root, name)).toBeTruthy(); + }); + + it('크리덴셜(password/api_key/sender_key)은 type=password 로 마스킹된다', () => { + for (const cred of ['password', 'api_key', 'sender_key']) { + const input = findInputByName(root, cred); + expect((input?.props as { type?: string } | undefined)?.type).toBe('password'); + } + }); + + it('bizppurio_id/sender_number 는 일반 text 입력이다', () => { + for (const field of ['bizppurio_id', 'sender_number']) { + const input = findInputByName(root, field); + expect((input?.props as { type?: string } | undefined)?.type).toBe('text'); + } + }); + +}); + +describe('plugin_settings.json — 연결 확인 (§529)', () => { + it('field_connection_check 필드가 field_password 와 별개 노드로 존재한다', () => { + const field = findById(root, 'field_connection_check'); + expect(field).toBeTruthy(); + expect(field).not.toBe(findById(root, 'field_password')); + }); + + it('연결 확인 버튼이 설정 행과 동일한 grid-cols-12(4/8) + 우측 정렬 패턴을 따른다', () => { + const field = findById(root, 'field_connection_check'); + const raw = JSON.stringify(field); + expect(raw).toContain('grid-cols-1'); + expect(raw).toContain('lg:grid-cols-12'); + expect(raw).toContain('lg:col-span-4'); + expect(raw).toContain('lg:col-span-8'); + expect(raw).toContain('lg:justify-end'); + }); + + it('버튼은 대기 상태 plug, 로딩 상태 spinner(animate-spin) 아이콘을 조건부로 갖는다', () => { + const btn = findById(root, 'connection_check_button'); + const raw = JSON.stringify(btn); + expect(raw).toContain('"plug"'); + expect(raw).toContain('"spinner"'); + expect(raw).toContain('animate-spin'); + expect(raw).toContain('_local.tokenChecking'); + }); + + it('버튼 클릭은 hasChanges=false 일 때만 apiCall 로 /admin/token/check 를 POST 한다', () => { + const btn = findById(root, 'connection_check_button'); + const raw = JSON.stringify(btn); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/token/check'); + expect(raw).toContain('"method":"POST"'); + // apiCall 액션 자체가 !hasChanges 가드를 갖는다 + const actions = (btn as { actions?: AnyNode[] } | null)?.actions ?? []; + const sequence = actions.find((a) => a.handler === 'sequence'); + const inner = ((sequence as { actions?: AnyNode[] } | undefined)?.actions ?? []) as AnyNode[]; + const apiCallAction = inner.find((a) => a.handler === 'apiCall'); + expect(apiCallAction?.if).toContain('!_local.hasChanges'); + }); + + it('hasChanges=true 일 때는 apiCall 없이 unsaved_changes toast 만 실행한다', () => { + const btn = findById(root, 'connection_check_button'); + const actions = (btn as { actions?: AnyNode[] } | null)?.actions ?? []; + const sequence = actions.find((a) => a.handler === 'sequence'); + const inner = ((sequence as { actions?: AnyNode[] } | undefined)?.actions ?? []) as AnyNode[]; + const guardToast = inner.find((a) => a.handler === 'toast' && a.if === '{{_local.hasChanges}}'); + expect(guardToast).toBeTruthy(); + expect(JSON.stringify(guardToast)).toContain('connection_check.unsaved_changes'); + }); + + it('성공/실패 결과는 화면 상시 표시 없이 toast 로만 안내한다', () => { + const btn = findById(root, 'connection_check_button'); + const raw = JSON.stringify(btn); + expect(raw).toContain('connection_check.success'); + expect(raw).toContain('connection_check.failed'); + }); +}); + +/** + * hasChanges 초기값(런타임) 검증 — §529 비판적 재검토에서 발견한 공백. + * + * 위 describe 블록은 레이아웃 JSON의 if 조건 문자열만 정적으로 확인한다. 하지만 + * "페이지를 막 열고 아무것도 바꾸지 않은 상태(_local.hasChanges 가 아직 세팅 전)" + * 에서 실제로 어떻게 평가되는지는 런타임 값 — Boolean(undefined) 규칙에 따라 + * `{{!_local.hasChanges}}` 는 true(API 호출 진행), `{{_local.hasChanges}}` 는 + * false(경고 미노출) 가 되어야 정상이다. 실제 엔진(createLayoutTest)으로 렌더해 + * 이 가정을 증명한다(추정이 아니라 확인). + */ +describe('plugin_settings.json — 연결 확인 버튼의 hasChanges 초기 상태 (§529 런타임 검증)', () => { + const connectionCheckButton = findById(root, 'connection_check_button') as AnyNode & { + actions: AnyNode[]; + }; + const clickAction = connectionCheckButton.actions.find((a) => a.handler === 'sequence') as AnyNode; + + function buildProbe() { + return { + version: '1.0.0', + layout_name: 'test/connection-check-initial-state', + components: [connectionCheckButton], + }; + } + + afterEach(() => { + vi.clearAllMocks(); + }); + + /** + * toast 핸들러는 ActionDispatcher 내장 처리(handleToast)라 커스텀 registerHandler + * 로 가로챌 수 없다 — 실제로는 globalStateUpdater 를 통해 `_global.toasts` 배열에 + * 쌓인다(createLayoutTest 의 getToasts() 는 이 경로를 타지 않는 죽은 유틸리티임을 + * 최소 재현으로 확인). 따라서 getState()._global.toasts 를 직접 읽는다. + */ + function lastToastMessages(utils: ReturnType): string[] { + const toasts = (utils.getState()._global?.toasts ?? []) as Array<{ message: string }>; + return toasts.map((t) => t.message); + } + + it('로드 직후(hasChanges 미설정) 클릭하면 hasChanges 가드를 통과해 실제 API 응답까지 도달한다', async () => { + const registry = createMockComponentRegistryWithBasics(); + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('token_check_probe', { response: { success: true } }); + await utils.render(); + + // 초기 상태: hasChanges 는 아직 세팅되지 않음(undefined) — 저장 폼을 만지지 않은 상태. + // Boolean(undefined) === false 이므로 미저장 가드({{_local.hasChanges}})는 통과해야 한다. + expect(utils.getState()._local?.hasChanges).toBeFalsy(); + + await utils.triggerAction(clickAction); + + // hasChanges 가드를 통과했다면 apiCall 이 실행되어 onSuccess/onError 중 + // 하나가 반드시 toast 를 남긴다 — unsaved_changes 경고는 뜨지 않고, + // success 또는 failed(원격 호출 실패 응답 처리) 중 하나만 떠야 한다. + const messages = lastToastMessages(utils); + expect(messages.some((m) => m.includes('connection_check.unsaved_changes'))).toBe(false); + expect(messages.length).toBeGreaterThan(0); + + utils.cleanup(); + }); + + it('hasChanges=true 로 세팅된 뒤 클릭하면 API 호출 없이 미저장 경고 toast 만 뜬다', async () => { + const registry = createMockComponentRegistryWithBasics(); + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + await utils.render(); + + utils.setState('hasChanges', true, 'local'); + await utils.triggerAction(clickAction); + + const messages = lastToastMessages(utils); + expect(messages.some((m) => m.includes('connection_check.unsaved_changes'))).toBe(true); + expect(messages.some((m) => m.includes('connection_check.success'))).toBe(false); + + utils.cleanup(); + }); +}); + +describe('plugin_settings.json — 비밀번호 필드 라벨 (§529)', () => { + it('비밀번호 필드 라벨이 "비즈뿌리오 모듈 비밀번호"로 G7 로그인 비밀번호와 구분된다', () => { + expect((ko as Record).settings.fields.password.label).toBe('비즈뿌리오 모듈 비밀번호'); + expect((en as Record).settings.fields.password.label).toBe('Bizppurio Module Password'); + }); +}); + +describe('plugin_settings.json — 리포트 수신 설정', () => { + it('report_url 데이터소스가 조회 엔드포인트를 호출한다', () => { + const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; + const reportUrl = sources.find((s) => s.id === 'report_url'); + expect(reportUrl).toBeTruthy(); + expect(reportUrl?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/report-url'); + }); + + it('리포트 섹션에 조회값(fallback 웹훅 경로) readonly 표시 + 복사 버튼이 있다', () => { + const section = findById(root, 'report_section'); + const raw = JSON.stringify(section); + expect(raw).toContain('report_url?.data?.url'); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/webhook'); + expect(raw).toContain('"readOnly":true'); + expect(raw).toContain('copyToClipboard'); + }); +}); + +describe('plugin_settings.json — 필드 인라인 에러', () => { + it.each([ + 'bizppurio_id', + 'password', + 'api_key', + 'sender_number', + 'sender_key', + ])('%s 필드에 인라인 에러 노드가 존재한다', (name) => { + const errorNode = findById(root, `field_${name}_error`); + expect(errorNode).toBeTruthy(); + expect((errorNode as { if?: string }).if).toContain(`_local.errors?.${name}`); + }); +}); + +describe('plugin_settings.json — 저장 버튼', () => { + it('저장 버튼이 hasChanges 없으면 비활성화된다', () => { + const save = findById(root, 'save_button'); + expect((save?.props as { disabled?: string } | undefined)?.disabled).toContain('!_local.hasChanges'); + }); + + it('저장은 코어 설정 API 로 PUT 한다', () => { + const text = JSON.stringify(findById(root, 'save_button')); + expect(text).toContain('/api/admin/plugins/{{route.identifier}}/settings'); + expect(text).toContain('"method":"PUT"'); + expect(text).toContain('{{_local.form}}'); + }); + + it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => { + const handlers = collectHandlers(layout); + const allowed = [ + 'apiCall', 'setState', 'toast', 'navigate', 'sequence', 'switch', + 'refetchDataSource', 'scrollIntoView', 'copyToClipboard', + 'openModal', 'closeModal', 'replaceUrl', 'suppress', + 'sirsoft-message_bizppurio.uploadTemplateImage', + 'sirsoft-message_bizppurio.insertVariable', + ]; + for (const h of handlers) { + expect(allowed).toContain(h); + } + }); +}); + +describe('plugin_settings.json — i18n 키 정합', () => { + it('레이아웃이 참조하는 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => { + const keys = collectI18nKeys(layout); + expect(keys.length).toBeGreaterThan(0); + + const resolve = (dict: Record, path: string): unknown => + path.split('.').reduce((acc, seg) => { + if (acc && typeof acc === 'object') { + return (acc as Record)[seg]; + } + return undefined; + }, dict); + + for (const raw of keys) { + // "$t:sirsoft-message_bizppurio.settings.title" → "settings.title" + const path = raw.replace('$t:sirsoft-message_bizppurio.', ''); + expect(resolve(ko, path), `ko 누락: ${path}`).toBeTruthy(); + expect(resolve(en, path), `en 누락: ${path}`).toBeTruthy(); + } + }); +}); + +describe('plugin_settings.json — 탭 전환', () => { + // 탭 버튼은 query.tab 을 바꾸며 화면(if 조건부 패널)을 다시 그려야 하므로 + // replaceUrl(URL만 변경, if 재평가 없음) 이 아니라 navigate 를 써야 한다. + const tabButtonHandlers = (id: string): string[] => { + const btn = findById(layout, id); + const handlers: string[] = []; + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') return; + const n = node as Record; + if (typeof n.handler === 'string') handlers.push(n.handler); + for (const v of Object.values(n)) { + if (Array.isArray(v)) v.forEach(walk); + else if (v && typeof v === 'object') walk(v); + } + }; + walk((btn as { actions?: unknown })?.actions); + return handlers; + }; + + it('환경설정 탭 버튼은 navigate 로 화면을 전환한다 (replaceUrl 금지)', () => { + const handlers = tabButtonHandlers('tab_connection'); + expect(handlers).toContain('navigate'); + expect(handlers).not.toContain('replaceUrl'); + }); + + it('알림톡 템플릿 탭 버튼은 navigate 로 화면을 전환한다 (replaceUrl 금지)', () => { + const handlers = tabButtonHandlers('tab_templates'); + expect(handlers).toContain('navigate'); + expect(handlers).not.toContain('replaceUrl'); + }); +}); + +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 === 'templates_readiness') as + | Record + | undefined; + expect(ds).toBeTruthy(); + expect(ds?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates-readiness'); + expect(ds?.if).toContain("query.tab ?? 'connection') === 'templates'"); + }); + + 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..3bf43fb3 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx @@ -0,0 +1,566 @@ +// 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, inspection_request_carries_reviewer_comment + * + * 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 sections from '../../../extensions/notification_template_form_sections.json'; +import footerActions from '../../../extensions/notification_template_form_footer_actions.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.props as Record)?.['aria-label'] === '$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)과 코어 [편집] 모달 섹션(_global.bz_tpl_modal)의 노출 조건을 같은 상태 + * 집합에 **실제로 태워** 대조한다(제품 결정 2026-08-23 — 행 하단 버튼 폐지, 섹션이 SSoT). + * + * 문자열 동일성 단언은 두 면의 식이 실제로 같은 결과를 내는지 증명하지 못한다 — 두 식을 + * 각자의 컨텍스트로 평가해 상태별 결과가 일치하는지를 본다. + */ + const MANAGE_ROWS: Record> = { + '행만 있고 내용 없음': { has_content: false, status: 'draft', template_code: null }, + 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)); + }; + + /** 편집 모달 섹션 식을 _global.bz_tpl_modal 컨텍스트로 평가(섹션은 상세 GET 으로 status/template_code 를 시딩한다) */ + const evalSection = (expr: string, bzRow: Record): boolean => { + const body = expr.trim().replace(/^{{/, '').replace(/}}$/, ''); + // eslint-disable-next-line no-new-func + // 섹션은 READY(자기 알림 유형으로 시딩 + 상세 GET 완료) 컨텍스트에서 평가한다 + const fn = new Function('_global', 'extensionPointProps', `return (${body});`); + return Boolean(fn( + { bz_tpl_modal: { notification_type: 'welcome', loading: false, status: bzRow.status, template_code: bzRow.template_code ?? null }, bz_tpl_ui: { confirmCancelApproval: false } }, + { definition: { type: 'welcome' }, channel: 'alimtalk' }, + )); + }; + + const sectionsRoot = { children: (sections as { components?: AnyNode[] }).components ?? [] } 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_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(sectionsRoot, 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(evalSection(f!.if as string, bzRow)); + } + }); + + it('관리 화면 [검수 신청]은 draft + 내용 보유에서만 노출된다(편집 모달은 [저장 후 검수 신청]이 본문 입력으로 판정)', () => { + const m = byTextKey(manageView, 'template.row.btn_request'); + expect(m?.if).toBeTruthy(); + for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) { + expect(evalManage(m!.if as string, bzRow), `검수 신청 @ ${state}`).toBe(bzRow.status === 'draft' && bzRow.has_content === true); + } + }); + + it('작성/수정 버튼은 편집 모달의 작성 폼이 열리는 상태(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 editor = findById(sectionsRoot, 'bizppurio_tpl_editor'); + expect(editor?.if).toBeTruthy(); + for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) { + expect(evalManage(m!.if as string, bzRow), `작성/수정 @ ${state}`).toBe(evalSection(editor!.if as string, bzRow)); + } + }); + + 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('상태 전이·delivery upsert 엔드포인트 계열이 편집 모달 섹션·푸터 액션과 같다', () => { + const layoutEndpoints = collectEndpoints(layout); + const sharedEndpoints = collectEndpoints([sections, footerActions]); + // 편집 모달이 쓰는 계열(map 조회·상세 GET 제외 — 관리 화면은 DB 목록을 쓴다)이 관리 화면에도 존재한다 + for (const ep of sharedEndpoints) { + if (ep.endsWith('/map') || ep === '/api/plugins/sirsoft-message_bizppurio/admin/templates/{id}') 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)"); + }); +}); + +describe('manage 모달 — 검수자 전달 의견 (#597 §18.7, 제품 결정 2026-08-23)', () => { + const composeModal = findById(modalRoot, 'modal_bizppurio_template') as AnyNode; + const sectionsRoot = { children: (sections as { components?: AnyNode[] }).components ?? [] } as AnyNode; + const collectHandlers = (node: unknown, acc: AnyNode[] = []): AnyNode[] => { + if (Array.isArray(node)) node.forEach((c) => collectHandlers(c, acc)); + else if (node && typeof node === 'object') { + const rec = node as AnyNode; + if (typeof rec.handler === 'string') acc.push(rec); + Object.values(rec).forEach((v) => collectHandlers(v, acc)); + } + return acc; + }; + + it('작성 모달에 bz_manage_request_comment 입력란(≤500)이 있고 편집 모달 섹션과 같은 상태 키·바인딩을 쓴다', () => { + const block = findById(composeModal, 'bz_manage_request_comment') as AnyNode; + expect(block).toBeTruthy(); + const ta = findAllByName(block, 'Textarea')[0] as AnyNode; + expect((ta.props as { name: string }).name).toBe('bz_request_comment'); + expect((ta.props as { maxLength: number }).maxLength).toBe(500); + const sectionsTa = findAllByName(findById(sectionsRoot, 'bizppurio_tpl_request_comment') as AnyNode, 'Textarea')[0] as AnyNode; + expect((ta.props as { value: string }).value).toBe((sectionsTa.props as { value: string }).value); + expect(JSON.stringify(ta.actions)).toBe(JSON.stringify(sectionsTa.actions)); + }); + + it('작성 모달의 request 2노드는 comment 를 싣고, 행 단위 [검수 신청](입력란 없음)은 싣지 않는다', () => { + const all = collectHandlers(root).filter((a) => a.handler === 'apiCall' && String(a.target).endsWith('/request')); + const modalReqs = all.filter((a) => !String(a.target).includes('{{bzRow.id}}')); + const rowReqs = all.filter((a) => String(a.target).includes('{{bzRow.id}}')); + expect(modalReqs).toHaveLength(2); + expect(rowReqs).toHaveLength(1); + for (const r of modalReqs) { + expect((r.params as { body: { comment: string } }).body.comment).toBe("{{_global.bz_tpl_modal?.request_comment ?? ''}}"); + } + for (const r of rowReqs) expect((r.params as { body?: unknown }).body).toBeUndefined(); + }); + + it('바로연결 블록(헤더+목록) 바로 뒤 형제로 놓이고, 라벨+[바로연결 추가] flex 행 안에 끼지 않는다 (회귀: 화면 검수 지적 2026-08-23)', () => { + const ancestorsOf = (root: AnyNode, id: string, anc: AnyNode[] = []): AnyNode[] | null => { + for (const c of (root.children as AnyNode[] | undefined) ?? []) { + if (c.id === id) return anc.concat(root); + const r = ancestorsOf(c, id, anc.concat(root)); + if (r) return r; + } + return null; + }; + const chain = ancestorsOf(composeModal, 'bz_manage_request_comment') as AnyNode[]; + expect(chain).toBeTruthy(); + const parent = chain[chain.length - 1]; + const siblings = parent.children as AnyNode[]; + const idx = siblings.findIndex((c) => c.id === 'bz_manage_request_comment'); + const prev = siblings[idx - 1]; + expect(JSON.stringify(prev)).toContain('template.form.quick_reply_add'); + expect(JSON.stringify(prev)).toContain('quickRepliesItem'); + for (const a of chain) { + expect(String((a.props as { className?: string } | undefined)?.className ?? ''), `ancestor ${a.id ?? a.name}`).not.toMatch(/\bflex\b/); + } + }); + + it('행에서 작성 모달을 열 때 request_comment 를 빈 문자열로 시딩한다(다른 행의 의견 이월 금지)', () => { + const seeds = collectHandlers(root) + .filter((a) => a.handler === 'setState' && (a.params as Record)?.bz_tpl_modal); + expect(seeds.length).toBeGreaterThan(0); + for (const s of seeds) { + expect(((s.params as Record).bz_tpl_modal as Record).request_comment).toBe(''); + } + }); + + it('라벨·placeholder·안내 키가 ko/en 에 존재한다', () => { + const pick = (json: unknown, key: string): unknown => key.split('.') + .reduce((acc, seg) => (acc as Record)?.[seg], json); + for (const key of ['request_comment', 'request_comment_placeholder', 'request_comment_hint']) { + expect(pick(ko, `template.form.${key}`), `ko ${key}`).toBeTruthy(); + expect(pick(en, `template.form.${key}`), `en ${key}`).toBeTruthy(); + } + }); +}); + +describe('manage — 목록 SMS 열은 상태 배지 (행 하단과 같은 규칙, 제품 결정 2026-08-23)', () => { + // iteration(bzRow) 안이라 정적 id 를 둘 수 없다 — 텍스트 식으로 찾는다 + const badge = findAllByName(manageView, 'Span').find((n) => String(n.text).includes("manage.sms_fallback') : $t('sirsoft-message_bizppurio.template.row.off')")) as AnyNode; + const evalCell = (expr: string, row: Record): unknown => { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + // eslint-disable-next-line no-new-func + return new Function('$t', 'bzRow', `return (${body});`)((k: string) => k, row); + }; + + it('단독/대체는 초록, 미사용은 회색 배지이며 평문 "-" 는 남지 않는다', () => { + expect(badge).toBeTruthy(); + const cls = String((badge.props as { className: string }).className); + expect(String(evalCell(cls, { sms_only: true }))).toContain('bg-green-100'); + expect(String(evalCell(cls, { fallback_sms_enabled: true }))).toContain('bg-green-100'); + expect(String(evalCell(cls, {}))).toContain('bg-gray-100'); + expect(String(evalCell(cls, {}))).toContain('rounded'); + expect(evalCell(String(badge.text), { sms_only: true })).toBe('sirsoft-message_bizppurio.manage.sms_only'); + expect(evalCell(String(badge.text), { fallback_sms_enabled: true })).toBe('sirsoft-message_bizppurio.manage.sms_fallback'); + expect(evalCell(String(badge.text), {})).toBe('sirsoft-message_bizppurio.template.row.off'); + expect(JSON.stringify(manageView)).not.toContain("sms_fallback') : '-')"); + }); +}); + +describe('manage — 목록 모바일 카드 전환 + 도구줄 버튼 일관성 (제품 결정 2026-08-24)', () => { + const header = findById(manageView, 'bz_manage_list_header') as AnyNode; + const rowsWrap = findById(manageView, 'bz_manage_list_rows') as AnyNode; + const findIterationRow = (n: AnyNode | null): AnyNode | null => { + if (!n) return null; + if (n.iteration?.item_var === 'bzRow') return n; + for (const c of n.children ?? []) { + const found = findIterationRow(c); + if (found) return found; + } + return null; + }; + const portableClass = (n: AnyNode | null | undefined): string => String( + ((n?.responsive as Record | undefined)?.portable?.props?.className) ?? '', + ); + + it('Table 마크업이 사라지고 grid 목록으로 렌더된다(헤더 7컬럼 키 유지, portable 에서 숨김)', () => { + expect(findAllByName(manageView, 'Table').length).toBe(0); + expect(header).toBeTruthy(); + expect(String((header.props as { className?: string })?.className)).toContain('grid-cols-12'); + expect(portableClass(header)).toBe('hidden'); + const raw = JSON.stringify(header); + for (const c of ['notification', 'owner', 'status', 'sms', 'requested_at', 'synced_at', 'actions']) { + expect(raw, `columns.${c}`).toContain('manage.columns.' + c); + } + }); + + it('행은 데스크톱 grid, portable 에서 카드(flex-wrap)로 전환된다 — responsive 는 반복 노드가 아니라 내부 래퍼에 둔다', () => { + const row = findIterationRow(rowsWrap); + expect(row).toBeTruthy(); + expect(row!.responsive, '반복 노드 자체에 responsive 를 두지 않는다(내부 래퍼 담당)').toBeUndefined(); + const inner = (row!.children ?? [])[0] as AnyNode; + expect(String((inner.props as { className?: string })?.className)).toContain('grid grid-cols-12'); + expect(portableClass(inner)).toContain('flex flex-wrap'); + }); + + it('소속·신청일·동기화일 셀은 portable 에서 숨고, 카드 메타 줄이 대신 노출된다', () => { + const row = findIterationRow(rowsWrap) as AnyNode; + const rowInner = (row.children ?? [])[0] as AnyNode; + const hiddenCells = (rowInner.children ?? []).filter((c) => portableClass(c) === 'hidden'); + expect(hiddenCells.length).toBeGreaterThanOrEqual(3); + // 메타 줄: 신청일+동기화일을 한 텍스트에 담고, 데스크톱(hidden)→portable 에서만 보인다 + const all: AnyNode[] = []; + const walk = (n: AnyNode) => { all.push(n); (n.children ?? []).forEach(walk); }; + walk(row); + const meta = all.find((n) => typeof n.text === 'string' + && n.text.includes("requested_at ?? '').slice(0, 10)") + && n.text.includes('last_synced_at')); + expect(meta, '카드 메타 줄(신청·동기화 합본)이 없다').toBeTruthy(); + expect(String((meta!.props as { className?: string })?.className)).toContain('hidden'); + expect(portableClass(meta)).not.toContain('hidden'); + expect(portableClass(meta).length).toBeGreaterThan(0); + }); + + it('행 액션 묶음은 카드 전환 후에도 단일 인스턴스다(삭제 버튼 1개)', () => { + const delBtns = findAllByName(manageView, 'Button') + .filter((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_delete'); + expect(delBtns.length).toBe(1); + }); + + it('검색 버튼이 btn btn-primary 계열로 통일된다(구 미니 버튼 조합 제거)', () => { + const searchBtn = findAllByName(manageView, 'Button') + .find((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_search') as AnyNode; + expect(searchBtn).toBeTruthy(); + const cls = String((searchBtn.props as { className?: string })?.className); + expect(cls).toContain('btn btn-primary'); + expect(cls).not.toContain('text-xs'); + }); + + it('새로고침이 btn-icon 아이콘 버튼으로 통일된다(fa-arrows-rotate + aria-label, refetch 전용 유지)', () => { + const refreshBtn = findAllByName(manageView, 'Button') + .find((b) => (b.props as Record)?.['aria-label'] === '$t:sirsoft-message_bizppurio.templates.list.refresh') as AnyNode; + expect(refreshBtn).toBeTruthy(); + expect(String((refreshBtn.props as { className?: string })?.className)).toContain('btn-icon'); + const raw = JSON.stringify(refreshBtn); + expect(raw).toContain('fa-arrows-rotate'); + expect(raw).toContain('bizppurio_templates_list'); + expect(raw).not.toContain('"handler":"navigate"'); + }); + + it('검색 입력·상태 필터가 portable 에서 전폭 계열로 전환된다', () => { + const input = findAllByName(manageView, 'Input') + .find((i) => String((i.props as { value?: string })?.value ?? '').includes('bzSearchDraft')) as AnyNode; + expect(portableClass(input)).toContain('w-full'); + const select = findAllByName(manageView, 'Select') + .find((sel) => (sel.props as { value?: string })?.value === "{{query.bz_status ?? ''}}") as AnyNode; + expect(portableClass(select)).toContain('flex-1'); + }); +}); 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..e6e5f48e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts @@ -0,0 +1,203 @@ +/** + * @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)를 + * 보유하는지 산출 파일 전수(편집 모달 푸터 1본 + 관리 화면)로 고정한다. 가드가 한 파일에서라도 빠지면 + * 그 면에서만 중복 신청이 재발한다. + * + * 실행 검증은 §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 = [ + // 3면 공유 1본 — 코어 [편집] 모달 푸터에 주입되는 통합 저장/검수 신청 버튼(제품 결정 2026-08-23) + 'resources/extensions/notification_template_form_footer_actions.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건이면 아래 단언은 공회전한다)', () => { + // 편집 모달 푸터 [저장 후 검수 신청] 1 + 관리 화면(모달 저장 후 신청 신규/수정 + 행 신청) + expect(all.length).toBeGreaterThanOrEqual(3); + }); + + 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; + // 관리 화면 모달: text=form.btn_save / 편집 모달 푸터(통합 저장): 자식 Span 이 $t:common.save + const isSave = n.name === 'Button' + && ((typeof n.text === 'string' && n.text.endsWith('sirsoft-message_bizppurio.template.form.btn_save')) + || (Array.isArray(n.children) && JSON.stringify(n.children).includes('$t:common.save') + && JSON.stringify(n.props ?? {}).includes('bz_tpl_modal'))); + 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 액션이 산출 파일 전수에서 재진입 가드를 보유한다', () => { + const all = FILES.flatMap(collectSaveClickActions); + // 편집 모달 푸터 통합 [저장] 1 + 관리 화면 모달 (신규/수정) 2버튼 + expect(all.length, '저장 버튼 click 액션 수').toBe(3); + + for (const a of all) { + // 편집 모달 푸터의 통합 [저장]은 isSaving 가드에 상세 GET 완료 전 가드(loading)를 AND 로 더 단다 — 포함 판정 + expect(a.ifExpr, `${a.file}: 저장 체인에 재진입 가드(if)가 없다 — 더블 클릭 시 중복 저장`) + .toContain(GUARD.slice(2, -2)); + } + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts new file mode 100644 index 00000000..5eeecb61 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts @@ -0,0 +1,25 @@ +/** + * 비즈뿌리오 플러그인 레이아웃 렌더 테스트 환경 설정. + * + * jest-dom matcher(toBeInTheDocument 등)를 로드하고, jsdom 전역 mock 을 준비한다. + * 코어 레이아웃 렌더 테스트(createLayoutTest)를 사용하는 테스트가 소비한다. + */ + +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts new file mode 100644 index 00000000..134e09c8 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts @@ -0,0 +1,17 @@ +/** + * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵. + * + * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인 + * 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable + * + * - insertVariable: 작성 모달·SMS 모달 본문에 `#{변수}` 커서 삽입 (#597 §4.2) + * - uploadTemplateImage: 이미지형 템플릿 이미지 업로드 프록시 호출 (multipart — apiCall 미적합) + */ + +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 new file mode 100644 index 00000000..02e0e40b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts @@ -0,0 +1,95 @@ +/** + * sirsoft-message_bizppurio 플러그인 엔트리포인트. + * + * 플러그인 활성화 시 자동 로드되어 handlerMap 의 커스텀 핸들러를 ActionDispatcher 에 + * 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다. + * (현재 등록 핸들러: insertVariable, uploadTemplateImage — handlers/index.ts 참조.) + */ + +import { handlerMap } from './handlers'; + +const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio'; + +const logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? { + log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args), + warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args), + error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args), +}; + +/** + * handlerMap 의 모든 핸들러를 ActionDispatcher 에 등록합니다. + * + * @param dispatcher 코어 ActionDispatcher 인스턴스 + */ +function register(dispatcher: any): void { + Object.entries(handlerMap).forEach(([name, handler]) => { + dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.${name}`, handler, { + category: 'plugin', + source: PLUGIN_IDENTIFIER, + }); + }); + + logger.log( + `${Object.keys(handlerMap).length} handler(s) registered:`, + Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`), + ); +} + +/** + * ActionDispatcher 준비 후 핸들러를 등록합니다. + * + * 최초 로드 시 ActionDispatcher 가 아직 없을 수 있으므로 짧게 재시도한다. + * + * @param retry ActionDispatcher 부재 시 재시도 여부 + */ +function registerHandlers(retry: boolean): void { + const dispatcher = (window as any).G7Core?.getActionDispatcher?.(); + + if (dispatcher) { + register(dispatcher); + return; + } + + if (!retry) { + logger.warn('ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.'); + return; + } + + let count = 0; + const max = 50; // 최대 5초 (50 * 100ms) + const tick = () => { + const found = (window as any).G7Core?.getActionDispatcher?.(); + if (found) { + register(found); + return; + } + if (++count <= max) { + setTimeout(tick, 100); + } else { + logger.error('ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.'); + } + }; + tick(); +} + +/** + * 플러그인 초기화 — DOM 준비 후 핸들러 등록. + */ +export function initPlugin(): void { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => registerHandlers(true)); + } else { + const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.(); + registerHandlers(!hasDispatcher); + } +} + +initPlugin(); + +if (typeof window !== 'undefined') { + (window as any).__SirsoftMessageBizppurio = { + identifier: PLUGIN_IDENTIFIER, + handlers: Object.keys(handlerMap), + initPlugin, + }; +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts new file mode 100644 index 00000000..a0610d1a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts @@ -0,0 +1,28 @@ +/** + * sirsoft-message_bizppurio 플러그인 프론트 타입 정의. + */ + +/** + * 액션 컨텍스트 인터페이스. + * + * ActionDispatcher 가 커스텀 핸들러 실행 시 전달하는 컨텍스트다. + */ +export interface ActionContext { + /** 현재 로컬 상태 가져오기 */ + getLocalState?: () => Record; + /** 로컬 상태 업데이트 */ + setLocalState?: (updates: Record) => void; + /** 이벤트 객체 */ + event?: Event; + /** 데이터 컨텍스트 */ + dataContext?: Record; +} + +/** + * 커스텀 핸들러 액션 객체(공통). + */ +export interface ActionWithParams { + handler: string; + params?: Record; + [key: string]: any; +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json new file mode 100644 index 00000000..269433fd --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -0,0 +1,304 @@ +{ + "name": "Bizppurio Messaging", + "description": "Bizppurio SMS/LMS and Kakao Alimtalk delivery plugin.", + "settings": { + "title": "Bizppurio Messaging Settings", + "description": "Manage Bizppurio integration and delivery settings.", + "save": "Save", + "saving": "Saving...", + "save_success": "Settings saved.", + "save_failed": "Failed to save settings.", + "test_mode": { + "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 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 Settings", + "description": "Sender information used for SMS and alimtalk delivery." + } + }, + "fields": { + "bizppurio_id": { + "label": "Bizppurio ID", + "hint": "The Bizppurio account ID used for both sending and Kakao management." + }, + "password": { + "label": "Bizppurio Module Password", + "hint": "Enter the Bizppurio module password. (Bizppurio console > Module integration settings > Change module password)" + }, + "connection_check": { + "label": "Connection Check", + "hint": "Verify that the saved ID and password are valid. (Applies after saving)", + "button": "Check Connection", + "checking": "Checking...", + "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": "Request the API key from Bizppurio customer support along with your account ID." + }, + "sender_number": { + "label": "Sender Number", + "hint": "The sender phone number used for SMS and alimtalk delivery." + }, + "sender_key": { + "label": "Alimtalk Sender Profile Key", + "hint": "The 40-character sender profile key used for alimtalk delivery and template requests." + } + }, + "report": { + "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 receiving address copied to clipboard." + }, + "tabs": { + "connection": "Settings", + "templates": "Notification Templates" + }, + "preparation": { + "intro": "To send SMS and Kakao alimtalk, complete the preparations below in the Bizppurio console first.", + "sms_label": "SMS/LMS", + "sms_sender": "Register a sender number.", + "kakao_label": "Kakao Alimtalk", + "kakao_channel": "Create a KakaoTalk business channel and register a sender profile.", + "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": { + "variables_hint": "Available variables (click to insert at the cursor)" + }, + "banner": { + "not_ready": "Required delivery settings are not complete.", + "setup_action": "Set up", + "test_mode": "Test mode — no real messages are sent." + }, + "dispatch_result": { + "column_header": "SMS/Alimtalk Result", + "inspection_label": "Test", + "low_balance": "Low balance", + "fallback": "SMS fallback {status}", + "detail_title": "SMS/Alimtalk Delivery Result", + "channel_label": "Channel: {channel}", + "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" + } + }, + "template": { + "tab_guide": "Open [Edit] on each notification to write the alimtalk template and the SMS body together and request review. Alimtalk is sent automatically after Kakao approval; fallback SMS and SMS-only sending are configured in the same window.", + "status": { + "unwritten": "Not composed", + "draft": "Draft", + "requested": "Under inspection", + "approved": "Approved", + "rejected": "Rejected", + "stopped": "Stopped", + "blocked": "Blocked", + "dormant": "Dormant" + }, + "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", + "sms_label": "SMS", + "on": "On", + "off": "Off", + "alimtalk_disabled": "Sending off" + }, + "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", + "request_comment": "Note to reviewer", + "request_comment_placeholder": "e.g. #{name}=Jane Doe, #{order_number}=20260823-0001", + "request_comment_hint": "Sent to the Kakao reviewer together with the inspection request (optional, up to 500 characters). Kakao review guidelines ask for an example value per body variable, so listing them here reduces rejections. It is not stored — it is only sent when you 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" + }, + "approval": { + "approved": "Approved", + "not_approved": "Not approved" + }, + "section": { + "alimtalk_title": "Alimtalk template", + "sms_title": "SMS", + "alimtalk_enabled": "Send alimtalk", + "locked_hint": "Templates under review or already approved cannot be edited. Press [Cancel request] or [Edit (cancel approval)] first.", + "saved_toast": "Notification template saved.", + "loading": "Loading template…" + } + }, + "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 forward", + "AC": "Add channel", + "BC": "Consult talk", + "BT": "Bot transfer", + "TN": "Call", + "MP": "Map", + "P1": "Secure image", + "P2": "Personal info", + "P3": "One-click pay" + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json new file mode 100644 index 00000000..1c06fbfc --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -0,0 +1,304 @@ +{ + "name": "비즈뿌리오 메시지 발송", + "description": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다.", + "settings": { + "title": "비즈뿌리오 메시지 발송 설정", + "description": "비즈뿌리오 연동 정보와 발송 설정을 관리합니다.", + "save": "저장", + "saving": "저장 중...", + "save_success": "설정이 저장되었습니다.", + "save_failed": "설정 저장에 실패했습니다.", + "test_mode": { + "label": "검수 모드", + "hint": "검수 모드에서는 비즈뿌리오 검수 도메인으로 발송됩니다. 끄면 운영 도메인으로 실제 발송됩니다.", + "account_notice": "검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다." + }, + "live_mode_warning_title": "운영 환경으로 발송됩니다", + "live_mode_warning_body": "검수 모드가 꺼져 있어 실제 고객에게 문자·알림톡이 발송되고 발송 비용이 청구됩니다. 검수 단계에서는 검수 모드를 켜 두세요.", + "sections": { + "api": { + "title": "API 연동", + "description": "발송 시스템과 카카오 관리 시스템에 공통으로 사용하는 연동 정보입니다." + }, + "sending": { + "title": "발송 설정", + "description": "문자·알림톡 발송에 사용하는 발신 정보입니다." + } + }, + "fields": { + "bizppurio_id": { + "label": "비즈뿌리오 아이디", + "hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다." + }, + "password": { + "label": "비즈뿌리오 모듈 비밀번호", + "hint": "비즈뿌리오 모듈 비밀번호를 입력해주세요. (비즈뿌리오 콘솔 > 모듈연동 환경설정 > 모듈 비밀번호 변경)" + }, + "connection_check": { + "label": "연결 확인", + "hint": "저장된 아이디·비밀번호가 유효한지 눌러서 바로 확인하세요. (저장 후에만 반영됩니다)", + "button": "연결 확인", + "checking": "확인 중...", + "success": "인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다.", + "failed": "인증 확인에 실패했습니다.", + "unsaved_changes": "변경사항을 먼저 저장해주세요." + }, + "api_key": { + "label": "API 키", + "hint": "API 키는 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다." + }, + "sender_number": { + "label": "발신번호", + "hint": "문자·알림톡 발송에 사용하는 발신 전화번호입니다." + }, + "sender_key": { + "label": "알림톡 발신프로필 키", + "hint": "알림톡 발송·템플릿 신청에 사용하는 발신프로필 키(40자)입니다." + } + }, + "report": { + "section_title": "리포트 수신 설정", + "hint": "비즈뿌리오는 문자·알림톡 발송 후 성공/실패 결과를 이 주소로 전송(URL PUSH)하는 리포트 수신 방식을 제공합니다. 아래 주소를 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 등록하세요.", + "note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.", + "copy": "복사", + "copied": "리포트 수신 주소가 클립보드에 복사되었습니다." + }, + "tabs": { + "connection": "환경설정", + "templates": "알림 템플릿 관리" + }, + "preparation": { + "intro": "문자·카카오 알림톡을 발송하려면 먼저 비즈뿌리오 콘솔에서 아래 사전 준비를 완료해야 합니다.", + "sms_label": "문자(SMS/LMS)", + "sms_sender": "발신번호를 등록하세요.", + "kakao_label": "카카오 알림톡", + "kakao_channel": "카카오톡 비즈니스 채널을 만들고 발신프로필을 등록하세요.", + "kakao_template": "알림톡 템플릿은 관리자 알림 설정(비즈뿌리오 탭)에서 작성해 검수를 신청합니다.", + "kakao_apikey": "고객센터에 API 키를 요청하세요.", + "console_link": "비즈뿌리오 콘솔 열기" + } + }, + "channels": { + "bizppurio_tab": "비즈뿌리오" + }, + "binding": { + "variables_hint": "제공 변수 (클릭하면 본문 커서 위치에 삽입됩니다)" + }, + "banner": { + "not_ready": "발송에 필요한 설정이 완료되지 않았습니다.", + "setup_action": "설정하기", + "test_mode": "검수 모드입니다 — 실제 발송은 이뤄지지 않습니다." + }, + "dispatch_result": { + "column_header": "문자·알림톡 결과", + "inspection_label": "검수", + "low_balance": "잔액 부족", + "fallback": "SMS 대체발송 {status}", + "detail_title": "문자·알림톡 발송 결과", + "channel_label": "발송 채널: {channel}", + "sent_content_label": "실제 발송 내용", + "sent_content_hint": "알림톡은 승인 확정된 템플릿 내용으로 발송되며, 위 \"본문\"과 다를 수 있습니다." + }, + "editor": { + "data_source": { + "dispatch_results": "비즈뿌리오 발송 결과" + } + }, + "template": { + "tab_guide": "각 알림의 [편집]에서 알림톡 템플릿과 문자(SMS) 본문을 함께 작성하고 검수를 신청하세요. 카카오 승인 후 알림톡이 자동 발송되며, 대체 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 본문 편집", + "sms_label": "문자(SMS)", + "on": "사용", + "off": "미사용", + "alimtalk_disabled": "발송 사용 안 함" + }, + "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": "저장 후 검수 신청", + "request_comment": "검수자 전달 의견", + "request_comment_placeholder": "예: #{name}=홍길동, #{order_number}=20260823-0001", + "request_comment_hint": "검수 신청 시 카카오 검수자에게 함께 전달됩니다(선택, 500자 이내). 카카오 심사 가이드는 본문 변수마다 예시값을 요구하므로 변수별 예시값을 적어 두면 반려를 줄일 수 있습니다. 저장되지 않으며 신청할 때만 전송됩니다.", + "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": "닫기" + }, + "approval": { + "approved": "승인됨", + "not_approved": "미승인" + }, + "section": { + "alimtalk_title": "알림톡 템플릿", + "sms_title": "문자(SMS)", + "alimtalk_enabled": "알림톡 발송 사용", + "locked_hint": "검수중이거나 승인된 템플릿은 내용을 수정할 수 없습니다. 수정하려면 [신청 취소] 또는 [수정 (승인 취소)]를 먼저 누르세요.", + "saved_toast": "알림 템플릿을 저장했습니다.", + "loading": "템플릿 내용을 불러오는 중…" + } + }, + "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": { + "readiness": { + "title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다", + "go_settings": "환경설정으로 이동", + "missing_label": "미설정 항목", + "api_key_missing": "카카오 관리 API 키", + "sender_key_missing": "알림톡 발신프로필 키", + "note": "위 항목을 환경설정 탭에서 입력하면 템플릿 작성·검수 신청이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 승인된 템플릿만 사용할 수 있습니다." + }, + "list": { + "refresh": "새로고침" + }, + "link_type": { + "WL": "웹링크", + "AL": "앱링크", + "DS": "배송조회", + "BK": "봇키워드", + "MD": "메시지전달", + "AC": "채널추가", + "BC": "상담톡전환", + "BT": "봇전환", + "TN": "전화하기", + "MP": "지도보기", + "P1": "이미지 보안전송", + "P2": "개인정보이용", + "P3": "원클릭 결제" + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json new file mode 100644 index 00000000..e1867170 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -0,0 +1,5274 @@ +{ + "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 방침에 따라 직접 수행(인증·발송 환경 필요) 단계에서 도입 예정 — 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 + }, + { + "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" + } + }, + "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" + }, + "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": "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": "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": "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 flex-shrink-0", + "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" + } + } + ] + } + ], + "responsive": { + "portable": { + "props": { + "className": "flex-1 min-w-0 text-sm" + } + } + } + }, + { + "type": "basic", + "name": "Input", + "props": { + "className": "flex-1 min-w-0 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", + "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}}" + } + } + ], + "responsive": { + "portable": { + "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" + } + } + } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-primary whitespace-nowrap flex-shrink-0" + }, + "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 flex-1 min-w-0" + } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "aria-label": "$t:sirsoft-message_bizppurio.templates.list.refresh", + "className": "btn-icon flex-shrink-0" + }, + "actions": [ + { + "type": "click", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurio_templates_list" + } + } + ], + "comment": "새로고침 — 코어 관리자 목록 관례(btn-icon + 회전 아이콘)와 통일 (제품 결정 2026-08-24).", + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-arrows-rotate", + "className": "text-base" + } + } + ] + } + ], + "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": [ + { + "id": "bz_manage_list_header", + "type": "basic", + "name": "Div", + "comment": "데스크톱 grid 헤더 — portable 에서는 카드가 자기 서술이므로 숨긴다 (제품 결정 2026-08-24 모바일 카드뷰).", + "props": { + "className": "grid grid-cols-12 gap-2 px-4 py-2.5 bg-gray-50 dark:bg-gray-900 text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "responsive": { + "portable": { + "props": { + "className": "hidden" + } + } + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-3" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.notification" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-2" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.owner" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-1" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.status" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-1" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.sms" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-1" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.requested_at" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-1" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.synced_at" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-3 text-right" + }, + "text": "$t:sirsoft-message_bizppurio.manage.columns.actions" + } + ] + }, + { + "id": "bz_manage_list_rows", + "type": "basic", + "name": "Div", + "if": "{{(bizppurio_templates_list?.data?.templates ?? []).length > 0}}", + "props": { + "className": "divide-y divide-gray-100 dark:divide-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "comment": "반복 노드에는 responsive 를 두지 않는다(저장소 관례 — 실측상 반복 노드 위 오버라이드는 화면 전환에 반영되지 않아, 클래스·responsive 는 내부 래퍼가 담당한다. 2026-08-24 Chrome 실측).", + "iteration": { + "source": "{{bizppurio_templates_list?.data?.templates ?? []}}", + "item_var": "bzRow", + "index_var": "bzRowIdx" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "comment": "행 — 데스크톱 grid 12열, portable 에서 카드(flex-wrap: 이름 전폭 → 배지 줄 → 메타 줄 → 액션 전폭).", + "props": { + "className": "grid grid-cols-12 gap-2 px-4 py-3 items-center hover:bg-gray-50 dark:hover:bg-gray-800/50" + }, + "responsive": { + "portable": { + "props": { + "className": "flex flex-wrap gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50" + } + } + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-3" + }, + "responsive": { + "portable": { + "props": { + "className": "w-full" + } + } + }, + "children": [ + { + "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" + } + }, + { + "type": "basic", + "name": "P", + "comment": "카드 메타 줄 — 데스크톱에서 별도 열인 소속·신청일·동기화일을 portable 카드에서 한 줄로 요약.", + "props": { + "className": "hidden" + }, + "responsive": { + "portable": { + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + } + } + }, + "text": "{{$t('sirsoft-message_bizppurio.manage.owner.' + (bzRow.definition_extension_type ?? 'core'))}} {{(bzRow.definition_extension_type ?? 'core') === 'core' ? '' : ('(' + (bzRow.definition_extension_identifier ?? '') + ')')}} · {{$t('sirsoft-message_bizppurio.manage.columns.requested_at')}} {{(bzRow.requested_at ?? '').slice(0, 10) || '-'}} · {{$t('sirsoft-message_bizppurio.manage.columns.synced_at')}} {{(bzRow.last_synced_at ?? '').slice(0, 10) || '-'}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-2 text-sm text-gray-700 dark:text-gray-200" + }, + "responsive": { + "portable": { + "props": { + "className": "hidden" + } + } + }, + "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": "Div", + "props": { + "className": "col-span-1" + }, + "children": [ + { + "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": "Div", + "props": { + "className": "col-span-1" + }, + "comment": "SMS 발송 모드 배지 — 단독/대체(초록), 미사용(회색). 행 하단 상태 배지와 같은 규칙(제품 결정 2026-08-23).", + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ' + ((bzRow.sms_only || bzRow.fallback_sms_enabled) ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}" + }, + "text": "{{bzRow.sms_only ? $t('sirsoft-message_bizppurio.manage.sms_only') : (bzRow.fallback_sms_enabled ? $t('sirsoft-message_bizppurio.manage.sms_fallback') : $t('sirsoft-message_bizppurio.template.row.off'))}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-1 text-sm text-gray-700 dark:text-gray-200 tabular-nums" + }, + "responsive": { + "portable": { + "props": { + "className": "hidden" + } + } + }, + "text": "{{(bzRow.requested_at ?? '').slice(0, 10) || '-'}}" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-1 text-sm text-gray-700 dark:text-gray-200 tabular-nums" + }, + "responsive": { + "portable": { + "props": { + "className": "hidden" + } + } + }, + "text": "{{(bzRow.last_synced_at ?? '').slice(0, 10) || '-'}}" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "col-span-3" + }, + "responsive": { + "portable": { + "props": { + "className": "w-full pt-1" + } + } + }, + "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": "" + } + }, + "request_comment": "" + } + } + }, + { + "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 p-0 overflow-hidden" + } + }, + { + "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-2" + }, + "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-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2" + }, + "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-2" + } + }, + { + "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-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2" + }, + "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-2" + } + }, + { + "id": "bz_manage_request_comment", + "type": "basic", + "name": "Div", + "comment": "검수자 전달 의견 — kapi template/request 의 comment(≤500). 카카오 심사 가이드가 요구하는 변수 예시값 등을 검수자에게 전할 유일한 통로(제품 결정 2026-08-23 §18.7). 신청 시에만 전송되며 행에 저장되지 않는다.", + "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.request_comment" + }, + { + "type": "basic", + "name": "Span", + "text": "{{(_global.bz_tpl_modal?.request_comment ?? '').length}}/500", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500" + } + } + ], + "props": { + "className": "flex-between" + } + }, + { + "type": "basic", + "name": "Textarea", + "props": { + "name": "bz_request_comment", + "rows": 3, + "maxLength": 500, + "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", + "placeholder": "$t:sirsoft-message_bizppurio.template.form.request_comment_placeholder", + "value": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bz_tpl_modal.request_comment": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.template.form.request_comment_hint" + } + ] + } + ] + }, + { + "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", + "body": { + "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + } + }, + "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", + "body": { + "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}" + } + }, + "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 new file mode 100644 index 00000000..6864ddbd --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php @@ -0,0 +1,47 @@ + $e->getMessage(), + 'result_code' => $e->getResultCode(), + ], + ); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php new file mode 100644 index 00000000..df306291 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php @@ -0,0 +1,43 @@ +reported_at !== null; + } + + /** + * replay 감지를 통일된 형식으로 로깅합니다 (운영 모니터링용). + * + * @param string $refkey 우리 부여 키 + * @param string|null $resultCode 리포트 결과 코드 + */ + protected function logReplayDetected(string $refkey, ?string $resultCode): void + { + Log::info('비즈뿌리오 webhook: replay 감지 — 이미 리포트 반영됨, 멱등 응답', [ + 'refkey' => $refkey, + 'result_code' => $resultCode, + ]); + } +} 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 new file mode 100644 index 00000000..056d2a6f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php @@ -0,0 +1,61 @@ +guard(fn () => ResponseHelper::success('messages.success', [ + 'categories' => $this->service->categories(), + ])); + } + + /** + * 발신프로필(사용중) 정보를 조회합니다. + * + * @return JsonResponse data.profiles 에 발신프로필 상태 정보 + */ + public function profiles(): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success('messages.success', [ + 'profiles' => $this->service->senderProfiles(), + ])); + } +} 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..85cf056b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php @@ -0,0 +1,382 @@ +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 RequestBizppurioInspectionRequest $request 검수자 전달 의견(comment, 선택 ≤500) + * @param int $id 템플릿 PK + * @return JsonResponse data.template 에 갱신된 행 + */ + public function requestInspection(RequestBizppurioInspectionRequest $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.requested', + ['template' => $this->detailRow($this->service->requestInspection($template, $request->comment()))], + )); + } + + /** + * 검수 신청을 취소합니다 (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/DispatchResultController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php new file mode 100644 index 00000000..c3b4f21e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php @@ -0,0 +1,61 @@ + $this->service->resultsForLogIds($request->logIds()), + ]); + } + + /** + * 최근 연결된 비즈뿌리오 결과 맵을 반환합니다 (코어 이력 화면 결과 컬럼용, 타이밍 무관). + * + * 파라미터 없이 GET 으로 최근 결과 맵을 받아 화면이 row.id 로 매칭한다(kginicis test-map 선례). + * 목록 data_source(notificationLogs) 로드 순서에 의존하지 않아, 결과 컬럼이 비는 문제를 원천 차단. + * + * @return JsonResponse data.results 에 notification_log_id → 결과 맵 + */ + public function recent(): JsonResponse + { + return ResponseHelper::success('messages.success', [ + 'results' => $this->service->recentResults(), + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/TokenCheckController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/TokenCheckController.php new file mode 100644 index 00000000..d108e9fb --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/TokenCheckController.php @@ -0,0 +1,66 @@ +tokenService->verifyCredentials(); + + return ResponseHelper::success('sirsoft-message_bizppurio::messages.token_check.success'); + } catch (BizppurioApiException $e) { + // 비즈뿌리오가 준 실패 사유 원문은 메시지 키가 아니라 관리자 전용 errors + // 페이로드로 전달한다 (키 자리에 원문 전달 금지 — docs/backend/exceptions.md). + return ResponseHelper::error( + 'sirsoft-message_bizppurio::messages.token_check.failed', + 422, + [ + 'bizppurio_message' => $e->getMessage(), + 'result_code' => $e->getResultCode(), + ], + ); + } catch (ConnectionException) { + return ResponseHelper::error( + 'sirsoft-message_bizppurio::messages.error.connection_failed', + 422, + ); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php new file mode 100644 index 00000000..633d3453 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php @@ -0,0 +1,49 @@ +reports->apply([ + 'REFKEY' => $request->input('REFKEY'), + 'RESULT' => $request->input('RESULT'), + 'MEDIA' => $request->input('MEDIA'), + 'TELRES' => $request->input('TELRES'), + 'KAORES' => $request->input('KAORES'), + 'raw' => $request->all(), + ]); + + return ResponseHelper::success('sirsoft-message_bizppurio::messages.webhook.received'); + } +} 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/Enums/DispatchChannel.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php new file mode 100644 index 00000000..58ede0a3 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php @@ -0,0 +1,52 @@ + __('sirsoft-message_bizppurio::messages.channel.sms'), + self::Lms => __('sirsoft-message_bizppurio::messages.channel.lms'), + self::Alimtalk => __('sirsoft-message_bizppurio::messages.channel.alimtalk'), + }; + } + + /** + * 문자(SMS/LMS) 채널 여부를 반환합니다. + * + * @return bool 문자 채널이면 true, 알림톡이면 false + */ + public function isText(): bool + { + return $this === self::Sms || $this === self::Lms; + } + + /** + * 모든 채널 값 목록을 반환합니다. + * + * @return array 채널 문자열 배열 (예: ["sms", "lms", "alimtalk"]) + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php new file mode 100644 index 00000000..a81cd214 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php @@ -0,0 +1,42 @@ + __('sirsoft-message_bizppurio::messages.source.auto'), + self::Manual => __('sirsoft-message_bizppurio::messages.source.manual'), + self::Bulk => __('sirsoft-message_bizppurio::messages.source.bulk'), + }; + } + + /** + * 모든 출처 값 목록을 반환합니다. + * + * @return array 출처 문자열 배열 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php new file mode 100644 index 00000000..0377e545 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php @@ -0,0 +1,54 @@ + __('sirsoft-message_bizppurio::messages.status.pending'), + self::Sent => __('sirsoft-message_bizppurio::messages.status.sent'), + self::Success => __('sirsoft-message_bizppurio::messages.status.success'), + self::Failed => __('sirsoft-message_bizppurio::messages.status.failed'), + }; + } + + /** + * 리포트 수신으로 상태가 확정되었는지(성공/실패) 여부를 반환합니다. + * + * @return bool 성공 또는 실패이면 true, 대기/발송중이면 false + */ + public function isFinal(): bool + { + return $this === self::Success || $this === self::Failed; + } + + /** + * 모든 상태 값 목록을 반환합니다. + * + * @return array 상태 문자열 배열 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php new file mode 100644 index 00000000..4475233d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php @@ -0,0 +1,69 @@ + __('sirsoft-message_bizppurio::messages.result_category.success'), + self::Retry => __('sirsoft-message_bizppurio::messages.result_category.retry'), + self::PermanentFailure => __('sirsoft-message_bizppurio::messages.result_category.permanent_failure'), + self::BalanceLow => __('sirsoft-message_bizppurio::messages.result_category.balance_low'), + }; + } + + /** + * 재시도 대상 분류 여부를 반환합니다. + * + * @return bool 일시 오류(retry)이면 true + */ + public function isRetryable(): bool + { + return $this === self::Retry; + } + + /** + * 발송 실패로 확정되는 분류 여부를 반환합니다. + * + * @return bool 영구 실패 또는 잔액 부족이면 true + */ + public function isFailure(): bool + { + return $this === self::PermanentFailure || $this === self::BalanceLow; + } + + /** + * 모든 분류 값 목록을 반환합니다. + * + * @return array 분류 문자열 배열 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php new file mode 100644 index 00000000..cceb808b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php @@ -0,0 +1,60 @@ +resultCode; + } + + /** + * HTTP 상태 코드를 반환합니다. + * + * @return int|null HTTP 상태 코드, 없으면 null + */ + public function getHttpStatus(): ?int + { + return $this->httpStatus; + } +} 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/Exceptions/NotificationSendSkippedException.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php new file mode 100644 index 00000000..b52dde93 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php @@ -0,0 +1,18 @@ +ip(), self::ALLOWED_IPS, true)) { + abort(403, 'Forbidden'); + } + + return $next($request); + } +} 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/BizppurioWebhookRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php new file mode 100644 index 00000000..6dbaaedc --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php @@ -0,0 +1,49 @@ + + */ + public function rules(): array + { + return [ + 'DEVICE' => ['nullable', 'string', 'max:20'], + 'CMSGID' => ['nullable', 'string', 'max:64'], + 'MSGID' => ['nullable', 'string', 'max:64'], + 'PHONE' => ['nullable', 'string', 'max:20'], + 'MEDIA' => ['nullable', 'string', 'max:10'], + 'RESULT' => ['required', 'string', 'max:10'], + 'REFKEY' => ['required', 'string', 'max:32'], + 'TELRES' => ['nullable', 'string', 'max:10'], + 'KAORES' => ['nullable', 'string', 'max:10'], + ]; + } +} 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/DispatchResultLookupRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php new file mode 100644 index 00000000..cfaba4a9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php @@ -0,0 +1,52 @@ + + */ + public function rules(): array + { + return [ + // 빈 배열 허용: 코어 알림 발송 이력이 0건(또는 로드 전)이면 화면이 빈 배열을 보낸다. + // 이때 422 로 막으면 결과 컬럼 조회 자체가 에러가 되므로, present(키 존재)만 요구하고 + // 빈 배열은 통과시켜 빈 결과 맵을 돌려준다. + 'notification_log_ids' => ['present', 'array', 'max:100'], + 'notification_log_ids.*' => ['integer', 'min:1'], + ]; + } + + /** + * 검증된 코어 알림 로그 id 목록을 정수 배열로 반환합니다. + * + * @return array 로그 id 목록 + */ + public function logIds(): array + { + return array_map('intval', $this->validated('notification_log_ids', [])); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/RequestBizppurioInspectionRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/RequestBizppurioInspectionRequest.php new file mode 100644 index 00000000..cbbf9392 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/RequestBizppurioInspectionRequest.php @@ -0,0 +1,63 @@ +> + */ + public function rules(): array + { + return [ + 'comment' => ['sometimes', 'nullable', 'string', 'max:500'], + ]; + } + + /** + * 검수자 전달 의견을 반환합니다 (앞뒤 공백 제거, 비어 있으면 null). + * + * @return string|null 전달 의견 + */ + public function comment(): ?string + { + $comment = trim((string) ($this->validated()['comment'] ?? '')); + + return $comment === '' ? null : $comment; + } + + /** + * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다. + * + * @return array 필드 경로 → 라벨 + */ + public function attributes(): array + { + return [ + 'comment' => __('sirsoft-message_bizppurio::messages.validation.attributes.comment'), + ]; + } +} 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/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/Jobs/SendMessageJob.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php new file mode 100644 index 00000000..22a082b8 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php @@ -0,0 +1,176 @@ + $payload 발송 payload (MessagePayloadBuilder 조립) + * @param string $refkey 우리 부여 키 (이력 매칭용) + */ + public function __construct( + public readonly array $payload, + public readonly string $refkey, + ) { + $this->afterCommit = true; + } + + /** + * 발송을 수행하고 결과코드에 따라 재시도/실패를 판정합니다. + * + * 발송 응답으로 이력(bizppurio_dispatches)을 갱신한다(Phase 4): + * - 성공: status=sent(발송 접수 완료, 최종 성공은 webhook 리포트가 확정) + messagekey 저장. + * - 재시도: 예외를 던져 큐 재시도(이력은 pending 유지). + * - 영구 실패: status=failed + result_code 기록. + * + * @param BizppurioApiClient $client 발송 API 클라이언트 + * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 리포지토리 + * + * @throws BizppurioApiException 일시 오류·429 시(큐 재시도 트리거) + */ + public function handle(BizppurioApiClient $client, BizppurioDispatchRepositoryInterface $dispatches): void + { + $result = $client->sendMessage($this->payload); + $code = (string) ($result['code'] ?? ''); + + if ($client->isSuccess($result)) { + // 발송 접수 성공 → sent(최종 성공/실패는 webhook 리포트가 확정) + messagekey 저장. + $this->updateDispatch($dispatches, [ + 'status' => DispatchStatus::Sent->value, + 'messagekey' => $result['messagekey'] ?? null, + ]); + + return; + } + + if (in_array($code, self::RETRYABLE_CODES, true)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.send_retryable', ['code' => $code]), + resultCode: $code, + ); + } + + // 영구 실패: 재시도하지 않고 종료 + 실패 이력 기록. + $this->updateDispatch($dispatches, [ + 'status' => DispatchStatus::Failed->value, + 'result_code' => $code, + 'result_message' => $result['description'] ?? null, + ]); + + Log::warning('비즈뿌리오 발송 영구 실패', [ + 'refkey' => $this->refkey, + 'code' => $code, + 'description' => $result['description'] ?? null, + ]); + } + + /** + * refkey 로 발송 이력을 조회해 갱신합니다 (이력이 없으면 무시). + * + * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 리포지토리 + * @param array $data 갱신 데이터 + */ + private function updateDispatch(BizppurioDispatchRepositoryInterface $dispatches, array $data): void + { + $dispatch = $dispatches->findByRefkey($this->refkey); + if ($dispatch !== null) { + $dispatches->update($dispatch, $data); + } + } + + /** + * 재시도 사이 대기 시간(초)을 반환합니다. + * + * 429·일시오류 재시도 시 짧게 backoff 한다. sync 실행 시에도 이 값이 적용된다. + * + * @return int 대기 초 + */ + public function backoff(): int + { + return self::SYNC_RETRY_MAX_WAIT_SECONDS; + } + + /** + * 모든 재시도 소진 후 최종 실패 시 로그를 남기고 발송 이력을 실패로 마감합니다. + * + * 타임아웃·연결실패(ConnectionException)는 결과코드 없는 예외로 handle() 의 상태 갱신 + * 분기를 우회하므로, 재시도 소진 후 이력이 pending 인 채 방치된다. 여기서 refkey 로 이력을 + * 조회해 failed 로 마감한다(전송 실패라 최종 실패는 webhook 으로도 회수 불가). 이미 확정 + * (success/failed)된 이력은 webhook 이 먼저 결과를 확정한 경우이므로 덮어쓰지 않는다(멱등). + * + * @param Throwable $exception 마지막으로 발생한 예외 + */ + public function failed(Throwable $exception): void + { + $resultCode = $exception instanceof BizppurioApiException + ? $exception->getResultCode() + : null; + + Log::error('비즈뿌리오 발송 Job 최종 실패', [ + 'refkey' => $this->refkey, + 'error' => $exception->getMessage(), + 'result_code' => $resultCode, + ]); + + $dispatches = app(BizppurioDispatchRepositoryInterface::class); + $dispatch = $dispatches->findByRefkey($this->refkey); + + // 이력 없음(비정상) 또는 이미 확정(webhook 선반영) → 마감 불필요(멱등) + if ($dispatch === null || $dispatch->status->isFinal()) { + return; + } + + $dispatches->update($dispatch, [ + 'status' => DispatchStatus::Failed->value, + 'result_code' => $resultCode, + 'result_message' => $exception->getMessage(), + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php new file mode 100644 index 00000000..996492f6 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php @@ -0,0 +1,110 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'sirsoft-message_bizppurio.notification.extract_data' => [ + 'method' => 'injectBalanceLowData', + 'priority' => 10, + 'type' => 'filter', + ], + ]; + } + + /** + * 기본 핸들러 (미사용 — 필터 메서드로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 잔액부족 알림의 data 에 본문 치환 변수를 채웁니다. + * + * extract_data 결과(`{notifiable, notifiables, data, context}`)를 받아, 대상이 + * 잔액부족 알림이면 훅 인자(결과코드·채널)와 사이트 메타를 data 에 덧붙여 반환한다. + * 그 외 유형은 원본을 그대로 통과시킨다. `name` 은 코어가 수신자별로 치환하도록 + * `{recipient_name}` placeholder 를 넣는다(NotificationHookListener 폴백 규약). + * + * @param array $result extract_data 결과 + * @param string $type 알림 정의 유형 + * @param array $args 훅 원본 인수 ([$resultCode, $channel]) + * @return array 변수가 채워진(또는 원본) 결과 + */ + public function injectBalanceLowData(array $result, string $type, array $args): array + { + if ($type !== self::NOTIFICATION_TYPE) { + return $result; + } + + $resultCode = (string) ($args[0] ?? ''); + $channel = (string) ($args[1] ?? ''); + + $result['data'] = array_merge( + $result['data'] ?? [], + [ + 'name' => '{recipient_name}', + 'app_name' => (string) config('app.name', ''), + 'result_code' => $resultCode, + 'channel_label' => $this->channelLabel($channel), + 'settings_url' => rtrim((string) config('app.url', ''), '/').self::SETTINGS_PATH, + 'site_url' => (string) config('app.url', ''), + ], + ); + + return $result; + } + + /** + * 채널 문자열을 잔액부족 알림용 채널군 라벨(문자/알림톡)로 변환합니다. + * + * SMS/LMS 는 "문자" 로 묶고 알림톡은 "알림톡" 으로 표기한다. 알 수 없는 채널은 + * 원본 문자열을 그대로 반환한다(치환 실패보다 원본 노출이 안전). + * + * @param string $channel 채널 문자열 (sms/lms/alimtalk) + * @return string 채널군 라벨 + */ + private function channelLabel(string $channel): string + { + $enum = DispatchChannel::tryFrom($channel); + + if ($enum === null) { + return $channel; + } + + return $enum->isText() + ? __('sirsoft-message_bizppurio::messages.channel_group.text') + : __('sirsoft-message_bizppurio::messages.channel_group.alimtalk'); + } +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php new file mode 100644 index 00000000..985d8a92 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php @@ -0,0 +1,97 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'sirsoft-ecommerce.notification.extract_data' => [ + 'method' => 'injectGuestPhone', + 'priority' => 20, + 'type' => 'filter', + ], + ]; + } + + /** + * 기본 핸들러 (미사용 — 필터 메서드로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 비회원 주문 알림의 data 에 수신 전화번호를 주입합니다. + * + * 이커머스 리스너가 만든 extract_data 결과(`{notifiable, notifiables, data, context}`)를 + * 받아, 대상이 주문 알림이고 비회원 주문이면 data 에 전화번호를 덧붙여 반환한다. + * 그 외에는 원본을 그대로 통과시킨다(회원 주문은 드라이버가 회원 mobile 을 사용). + * + * @param array $result 이커머스 extract_data 결과 + * @param string $type 알림 정의 유형 + * @param array $args 훅 원본 인수 ([$order, ...]) + * @return array 전화번호가 보강된(또는 원본) 결과 + */ + public function injectGuestPhone(array $result, string $type, array $args): array + { + if (! in_array($type, self::ORDER_NOTIFICATION_TYPES, true)) { + return $result; + } + + $order = $args[0] ?? null; + if (! $order instanceof Order || ! $order->isGuestOrder()) { + return $result; + } + + // 주문자 전화번호(배송지 orderer_phone)를 이커머스 자체 관행(GuestOrderAuthService· + // OrderResource 와 동일)대로 관계 필드에서 직접 읽는다. 이커머스 모듈 무수정. + $phone = $order->shippingAddress?->orderer_phone; + if ($phone === null || trim((string) $phone) === '') { + return $result; + } + + $result['data'] = array_merge( + $result['data'] ?? [], + [SmsChannelDriver::RECIPIENT_PHONE_KEY => $phone], + ); + + return $result; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/InvalidateTokenOnSettingsSaveListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/InvalidateTokenOnSettingsSaveListener.php new file mode 100644 index 00000000..ac92c6f6 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/InvalidateTokenOnSettingsSaveListener.php @@ -0,0 +1,83 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.plugin_settings.after_save' => [ + 'method' => 'invalidateToken', + 'priority' => 10, + 'type' => 'action', + 'sync' => true, + ], + ]; + } + + /** + * 인터페이스 표준 진입점 — getSubscribedHooks 가 method='invalidateToken' 를 명시하므로 + * 이 메서드는 미사용. HookListenerInterface 추상 메서드 충족 목적으로만 정의한다. + * + * @param mixed ...$args 사용 안 함 + */ + public function handle(...$args): void + { + // no-op — 실제 진입점은 invalidateToken() 메서드 (action 훅) + } + + /** + * 본 플러그인 설정 저장 직후 토큰 캐시를 무효화한다. + * + * @param string $identifier 저장된 플러그인 식별자 + * @param array $settings 저장된 설정(사용 안 함) + * @param bool $result 저장 성공 여부 + */ + public function invalidateToken(string $identifier, array $settings, bool $result): void + { + if ($identifier !== self::IDENTIFIER || ! $result) { + return; + } + + $this->tokenService->forget(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php new file mode 100644 index 00000000..02636300 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php @@ -0,0 +1,90 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.notification_log.after_log_sent' => ['method' => 'linkLog', 'priority' => 15], + 'core.notification_log.after_log_failed' => ['method' => 'linkLog', 'priority' => 15], + ]; + } + + /** + * 기본 핸들러 (미사용 — linkLog 로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 방금 생성된 코어 알림 로그를 이번 발송 사이클의 dispatch 에 연결합니다. + * + * 이 사이클에서 우리 드라이버가 남긴 refkey 가 있으면 그 dispatch 에 로그 id 를 기록한다. + * refkey 가 없으면(비-비즈뿌리오 로그) 연결하지 않는다. + * + * @param NotificationLog $log 코어가 방금 생성한 로그(id 포함) + */ + public function linkLog(NotificationLog $log): void + { + $refkey = $this->linkContext->consume(); + + if ($refkey === null) { + return; + } + + try { + $this->dispatches->linkNotificationLog($refkey, (int) $log->id); + } catch (\Throwable $e) { + Log::warning('비즈뿌리오: 코어 알림 로그 연결 실패', [ + 'refkey' => $refkey, + 'notification_log_id' => $log->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php new file mode 100644 index 00000000..4e37402b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -0,0 +1,390 @@ +> + */ + public static function getSubscribedHooks(): array + { + $hooks = [ + 'core.notification.filter_available_channels' => [ + 'method' => 'addChannels', + 'priority' => 20, + 'type' => 'filter', + ], + 'core.notification.channel_readiness' => [ + 'method' => 'checkReadiness', + 'priority' => 20, + 'type' => 'filter', + ], + 'core.notification.channel_enabled' => [ + 'method' => 'gateChannelEnabled', + 'priority' => 20, + 'type' => 'filter', + ], + ]; + + foreach (self::CHANNEL_HOOK_PREFIXES as $prefix) { + $hooks["{$prefix}.notification.channels"] = [ + 'method' => 'addChannelCandidates', + 'priority' => 20, + 'type' => 'filter', + ]; + } + + return $hooks; + } + + /** + * 기본 핸들러 (미사용 — 필터 메서드로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 사용 가능한 채널 목록에 sms·alimtalk 메타를 추가합니다. + * + * 코어 NotificationChannelService::getAvailableChannels() 가 name_key/description_key/ + * source_label_key 를 활성 locale 로 해석하므로(localized_payload), lang key 로 선언한다. + * 중복 방지: 이미 존재하는 id 는 다시 추가하지 않는다. + * + * allow_guest:true — 비회원 문자 발송 허용(D1). 실제 게스트 전화번호는 SmsChannelDriver 가 + * data 에서 해석한다. + * + * @param array> $channels 현재 채널 메타 목록 + * @return array> sms·alimtalk 이 추가된 목록 + */ + public function addChannels(array $channels): array + { + $existingIds = array_column($channels, 'id'); + + // 검수(테스트) 모드 여부를 채널 메타에 실어 프론트(availableChannels)로 전달한다. + // 알림톡·SMS 탭 상단 상태 배너가 이 값으로 "테스트 모드 — 실제 발송 안 됨"을 노출한다. + // 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다. + $isTestMode = $this->isTestMode(); + + foreach ($this->channelMetas() as $meta) { + if (! in_array($meta['id'], $existingIds, true)) { + $meta['is_test_mode'] = $isTestMode; + $channels[] = $meta; + } + } + + return $channels; + } + + /** + * 채널 준비 상태를 검사합니다 (core.notification.channel_readiness). + * + * 코어/타 플러그인 채널의 판정({ready,reason})은 그대로 통과시키고, 우리 채널(sms/alimtalk) + * 일 때만 환경설정 충족 여부로 교체한다(D2). + * + * - sms: bizppurio_id + password + sender_number + * - alimtalk: sms 조건 + api_key + sender_key + * + * @param array{ready: bool, reason: string|null} $result 이전 필터까지의 판정 + * @param string $channelId 검사 대상 채널 + * @return array{ready: bool, reason: string|null} + */ + public function checkReadiness(array $result, string $channelId): array + { + return match ($channelId) { + 'sms' => $this->checkSmsReadiness(), + 'alimtalk' => $this->checkAlimtalkReadiness(), + default => $result, + }; + } + + /** + * 우리 채널(sms/alimtalk)의 "미저장=비활성(OFF)" 정책을 적용합니다 + * (core.notification.channel_enabled 필터). + * + * 코어의 기본 판정은 "채널 설정 엔트리가 없으면 활성(true)" 이다(하위호환). 하지만 + * sms·alimtalk 은 플러그인이 나중에 주입한 채널이라, 관리자가 명시적으로 켜기 전에는 + * 발송·기록하지 않아야 한다(opt-in). 게스트 발송 정책 isChannelGuestAllowed 의 + * "확장 채널은 미선언=차단" 과 같은 방향이다. + * + * 따라서 우리 채널이면서 해당 확장의 notifications.channels 저장소에 엔트리가 없을 때만 + * false 로 덮어쓴다. 엔트리가 저장되어 있으면(켜짐/꺼짐 모두) 코어가 이미 그 값을 반영해 + * $enabled 로 넘겨주므로 그대로 통과시킨다. 우리 채널이 아니면 항상 원본을 통과시킨다. + * + * @param bool $enabled 코어가 계산한 활성 여부 + * @param string $extensionType 확장 타입 (core/module/plugin) + * @param string|null $extensionIdentifier 확장 식별자 + * @param string $channelId 채널 식별자 + * @return bool 최종 활성 여부 + */ + public function gateChannelEnabled( + bool $enabled, + string $extensionType, + ?string $extensionIdentifier, + string $channelId + ): bool { + if (! in_array($channelId, self::CHANNEL_IDS, true)) { + return $enabled; + } + + // 저장 엔트리가 있으면 코어 판정($enabled)을 존중, 없으면 미저장 → OFF + if ($this->hasSavedChannelEntry($extensionType, $extensionIdentifier, $channelId)) { + return $enabled; + } + + return false; + } + + /** + * 해당 확장의 notifications.channels 저장소에 특정 채널 엔트리가 존재하는지 확인합니다. + * + * 저장소는 확장 타입별로 분리됩니다: + * - core → SettingsService + * - module → ModuleSettingsService + * - plugin → PluginSettingsService + * + * 조회 실패(예외)나 미지원 타입은 "미저장"(false)으로 간주해 안전측(OFF)으로 처리한다. + * + * @param string $extensionType 확장 타입 + * @param string|null $extensionIdentifier 확장 식별자 + * @param string $channelId 채널 식별자 + * @return bool 저장 엔트리 존재 여부 + */ + private function hasSavedChannelEntry( + string $extensionType, + ?string $extensionIdentifier, + string $channelId + ): bool { + try { + $channels = match ($extensionType) { + 'core' => app(SettingsService::class) + ->getSetting('notifications.channels', []), + 'module' => empty($extensionIdentifier) + ? [] + : app(ModuleSettingsService::class) + ->get($extensionIdentifier, 'notifications.channels', []), + 'plugin' => empty($extensionIdentifier) + ? [] + : app(PluginSettingsService::class) + ->get($extensionIdentifier, 'notifications.channels', []), + default => [], + }; + } catch (\Throwable $e) { + return false; + } + + if (! is_array($channels)) { + return false; + } + + foreach ($channels as $entry) { + if (is_array($entry) && ($entry['id'] ?? null) === $channelId) { + return true; + } + } + + return false; + } + + /** + * 레거시 다채널 자동 결정 경로에서 채널 후보에 sms·alimtalk 을 더합니다. + * + * {prefix}.notification.channels 는 채널 미지정(다채널) 발송 경로에서만 발화하며(코어 확인), + * 채널 지정 경로에서는 filter_available_channels 가 SSoT 다. 두 경로 모두에서 채널이 + * 누락되지 않도록 후보에 우리 채널 id 를 더한다(중복 제거). + * + * @param array $channels 정의별 채널 id 후보 + * @param string $type 알림 정의 유형 (미사용) + * @param object|null $notifiable 수신자 (미사용) + * @return array 우리 채널이 더해진 후보 + */ + public function addChannelCandidates(array $channels, string $type = '', ?object $notifiable = null): array + { + foreach (self::CHANNEL_IDS as $id) { + if (! in_array($id, $channels, true)) { + $channels[] = $id; + } + } + + return array_values($channels); + } + + /** + * sms·alimtalk 채널 메타 정의를 반환합니다. + * + * '비즈뿌리오' 통합 탭 메타(#597 §3.3 — 범용 키, 확장명 하드코딩 없음): + * - sms.hidden_tab: 탭만 숨긴다(채널 토글 카드·발송 축은 그대로). + * - alimtalk.tab_channels: 이 목록 중 하나라도 활성 저장이면 탭을 노출한다. + * - alimtalk.tab_label_key: 탭 라벨용 프론트 lang 키($t 해석 — 카드 라벨(name)과 분리). + * - alimtalk.hidden_template_editor: 코어 알림 템플릿 [편집] 모달의 제목/본문 입력·미리보기· + * 코어 [저장]을 숨긴다(제품 결정 2026-08-23). 알림톡 본문은 카카오 규격(#{var}, 유형별 필드)이라 + * 코어 본문과 다른 편집기가 필요하고, 그 편집기·통합 저장은 플러그인이 같은 모달의 + * extension_point(notification_template_form_sections / _footer_actions)에 주입한다. + * 코어 채널 템플릿 행(제목/본문)은 발송에 쓰이지 않으므로 숨겨도 동작 손실이 없다. + * 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다 + * (is_test_mode 와 동일 경로). + * + * @return array> + */ + private function channelMetas(): array + { + return [ + [ + 'id' => 'sms', + 'name_key' => self::LANG.'.channels.sms.name', + 'description_key' => self::LANG.'.channels.sms.description', + 'icon' => 'fas fa-comment-sms', + 'source' => self::PLUGIN_IDENTIFIER, + 'source_label_key' => self::LANG.'.channels.source_label', + 'allow_guest' => true, + 'hidden_tab' => true, + ], + [ + 'id' => 'alimtalk', + 'name_key' => self::LANG.'.channels.alimtalk.name', + 'description_key' => self::LANG.'.channels.alimtalk.description', + 'icon' => 'fas fa-comment-dots', + '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', + 'hidden_template_editor' => true, + ], + ]; + } + + /** + * SMS 채널 준비 상태를 검사합니다. + * + * @return array{ready: bool, reason: string|null} + */ + private function checkSmsReadiness(): array + { + if ($this->missing('bizppurio_id') || $this->missing('password')) { + return $this->notReady('sms_credentials_missing'); + } + + if ($this->missing('sender_number')) { + return $this->notReady('sms_sender_number_missing'); + } + + return ['ready' => true, 'reason' => null]; + } + + /** + * 알림톡 채널 준비 상태를 검사합니다. + * + * @return array{ready: bool, reason: string|null} + */ + private function checkAlimtalkReadiness(): array + { + if ($this->missing('bizppurio_id') || $this->missing('password')) { + return $this->notReady('sms_credentials_missing'); + } + + if ($this->missing('sender_number')) { + return $this->notReady('sms_sender_number_missing'); + } + + if ($this->missing('api_key')) { + return $this->notReady('alimtalk_api_key_missing'); + } + + if ($this->missing('sender_key')) { + return $this->notReady('alimtalk_sender_key_missing'); + } + + return ['ready' => true, 'reason' => null]; + } + + /** + * 환경설정 값이 비어 있는지 확인합니다. + * + * @param string $key 설정 키 + * @return bool 비어 있으면 true + */ + private function missing(string $key): bool + { + $value = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, $key, ''); + + return trim((string) $value) === ''; + } + + /** + * 검수(테스트) 모드 여부를 반환합니다. + * + * is_test_mode=true 이면 실제 발송이 이뤄지지 않는다(검수 환경). 상태 배너 노출 기준. + * + * @return bool 검수 모드면 true + */ + private function isTestMode(): bool + { + return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); + } + + /** + * ready=false 판정을 lang reason key 와 함께 반환합니다. + * + * @param string $reasonKey messages.readiness.* 하위 키 + * @return array{ready: bool, reason: string} + */ + private function notReady(string $reasonKey): array + { + return ['ready' => false, 'reason' => self::LANG.'.readiness.'.$reasonKey]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php new file mode 100644 index 00000000..62ebab88 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php @@ -0,0 +1,274 @@ + [...], ...]` (키가 type) + * - 모듈·기본값 복원 경로: 순차 배열 `[['type' => 'order_confirmed', ...], ...]` + * 둘 다 각 정의 값에 대해 동일하게 채널을 증강하고 키/순서는 보존한다. + * + * 증강 대상 = 회원(사용자) 대상 알림(결정 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 +{ + /** 증강할 채널 id 목록 */ + private const CHANNELS = ['sms', 'alimtalk']; + + /** + * 구독할 훅 목록 반환. + * + * @return array> + */ + public static function getSubscribedHooks(): array + { + $hooks = []; + foreach ([ + 'seed.notifications.translations', + 'seed.sirsoft-board.notifications.translations', + 'seed.sirsoft-ecommerce.notifications.translations', + 'core.notification.filter_default_definitions', + ] as $hook) { + $hooks[$hook] = [ + 'method' => 'augment', + 'priority' => 50, + 'type' => 'filter', + ]; + } + + return $hooks; + } + + /** + * 기본 핸들러 (미사용 — 필터 메서드로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 정의 배열의 각 회원 대상 알림에 sms·alimtalk 채널 template 을 증강합니다. + * + * 키(코어=type, 모듈=정수)와 순서를 보존한 채 각 정의 값만 변형한다. 정의 형태가 배열이 + * 아니거나 회원 대상이 아니면 원본을 그대로 둔다. + * + * @param array $definitions 시딩 직전 알림 정의 배열 + * @return array 채널이 증강된 정의 배열 + */ + public function augment(array $definitions): array + { + foreach ($definitions as $key => $definition) { + if (! is_array($definition) || ! $this->isMemberFacing($definition)) { + continue; + } + + $definitions[$key] = $this->augmentDefinition($definition); + } + + return $definitions; + } + + /** + * 단일 알림 정의에 sms·alimtalk 채널을 channels·templates 에 추가합니다. + * + * 이미 해당 채널이 선언돼 있으면(다른 경로로 이미 존재) 중복 추가하지 않는다. + * + * @param array $definition 단일 알림 정의 + * @return array 증강된 정의 + */ + private function augmentDefinition(array $definition): array + { + $channels = $definition['channels'] ?? ['mail']; + $templates = $definition['templates'] ?? []; + + $existingChannels = array_column($templates, 'channel'); + $baseBody = $this->resolveBaseBody($templates); + $baseSubject = $this->resolveBaseSubject($templates); + $recipients = $this->resolveRecipients($templates); + + foreach (self::CHANNELS as $channel) { + if (in_array($channel, $existingChannels, true)) { + continue; + } + + if (! in_array($channel, $channels, true)) { + $channels[] = $channel; + } + + $templates[] = [ + 'channel' => $channel, + 'recipients' => $recipients, + 'subject' => $baseSubject, + 'body' => $baseBody, + ]; + } + + $definition['channels'] = $channels; + $definition['templates'] = $templates; + + return $definition; + } + + /** + * 알림이 회원(사용자) 대상인지 판정합니다 (결정 E). + * + * 정의의 어떤 template recipients 든 관리자 전용(type=role, value=admin)이 아닌 수신자가 + * 하나라도 있으면 회원 대상으로 본다. recipients 가 전혀 없으면(수신자 미지정) 회원 발송 + * 기본값으로 포함한다. channels 컬럼이 아니라 recipients 로 판정한다(결정 E). + * + * @param array $definition 단일 알림 정의 + * @return bool 회원 대상이면 true + */ + private function isMemberFacing(array $definition): bool + { + $templates = $definition['templates'] ?? []; + $hasAnyRecipient = false; + + foreach ($templates as $template) { + foreach ($template['recipients'] ?? [] as $recipient) { + $hasAnyRecipient = true; + + $type = $recipient['type'] ?? null; + $value = $recipient['value'] ?? null; + + if (! ($type === 'role' && $value === 'admin')) { + return true; + } + } + } + + return ! $hasAnyRecipient; + } + + /** + * 증강할 채널의 기본 body(다국어 배열)를 결정합니다. + * + * 우선순위: database 채널 body(짧은 평문) → mail body 의 HTML 제거본 → 빈 배열. + * 문자/알림톡은 평문이므로 HTML 을 담지 않는다. + * + * @param array> $templates 기존 template 목록 + * @return array locale → 평문 body + */ + private function resolveBaseBody(array $templates): array + { + $database = $this->findTemplate($templates, 'database'); + if ($database !== null && is_array($database['body'] ?? null)) { + return $database['body']; + } + + $mail = $this->findTemplate($templates, 'mail'); + if ($mail !== null && is_array($mail['body'] ?? null)) { + return array_map(fn ($body) => $this->stripHtml((string) $body), $mail['body']); + } + + return []; + } + + /** + * 증강할 채널의 기본 subject(다국어 배열)를 결정합니다. + * + * database → mail subject 순으로 재활용한다. LMS 전환 시 제목으로 쓰인다. + * + * @param array> $templates 기존 template 목록 + * @return array locale → subject + */ + private function resolveBaseSubject(array $templates): array + { + foreach (['database', 'mail'] as $channel) { + $template = $this->findTemplate($templates, $channel); + if ($template !== null && is_array($template['subject'] ?? null)) { + return $template['subject']; + } + } + + return []; + } + + /** + * 증강할 채널의 recipients 를 결정합니다. + * + * 기존 template(database → mail)의 recipients 를 그대로 재활용해 수신자 규칙을 일치시킨다. + * 없으면 발생 회원 본인(trigger_user) 기본값을 쓴다. + * + * @param array> $templates 기존 template 목록 + * @return array> recipients 규칙 + */ + private function resolveRecipients(array $templates): array + { + foreach (['database', 'mail'] as $channel) { + $template = $this->findTemplate($templates, $channel); + if ($template !== null && ! empty($template['recipients']) && is_array($template['recipients'])) { + return $template['recipients']; + } + } + + return [['type' => 'trigger_user']]; + } + + /** + * template 목록에서 특정 채널의 template 을 찾습니다. + * + * @param array> $templates template 목록 + * @param string $channel 찾을 채널 + * @return array|null 매칭 template 또는 null + */ + private function findTemplate(array $templates, string $channel): ?array + { + foreach ($templates as $template) { + if (($template['channel'] ?? null) === $channel) { + return $template; + } + } + + return null; + } + + /** + * HTML 문자열을 문자/알림톡용 평문으로 변환합니다. + * + * 태그 제거 + 엔티티 디코드 + 연속 공백 정리. 변수 표기({name} 등)는 보존된다. + * + * @param string $html 원본 HTML + * @return string 평문 + */ + private function stripHtml(string $html): string + { + $text = preg_replace('/<[^>]+>/', ' ', $html) ?? ''; + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return trim((string) preg_replace('/\s+/', ' ', $text)); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php new file mode 100644 index 00000000..bd881706 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php @@ -0,0 +1,138 @@ + + */ + private const LIVE_REQUIRED_FIELDS = ['bizppurio_id', 'password', 'sender_number']; + + /** + * 구독 훅 메타데이터. + * + * @return array> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.plugin_settings.update_rules' => [ + 'method' => 'addLiveModeRules', + 'priority' => 10, + 'type' => 'filter', + 'sync' => true, + ], + ]; + } + + /** + * 인터페이스 표준 진입점 — getSubscribedHooks 가 method='addLiveModeRules' 를 명시하므로 + * 이 메서드는 미사용. HookListenerInterface 추상 메서드 충족 목적으로만 정의한다. + * + * @param mixed ...$args 사용 안 함 + */ + public function handle(...$args): void + { + // no-op — 실제 진입점은 addLiveModeRules() 메서드 (filter 훅) + } + + /** + * 운영(live) 환경 진입 시 발송 자격증명에 required 규칙을 부여한다. + * + * @param array $rules 코어가 스키마로 생성한 검증 규칙 + * @param string $identifier 검증 대상 플러그인 식별자 + * @return array 조정된 검증 규칙 + */ + public function addLiveModeRules(array $rules, string $identifier): array + { + if ($identifier !== self::IDENTIFIER) { + return $rules; + } + + // 검수 모드(is_test_mode)가 명시적으로 false(운영) 일 때만 자격증명을 강제한다. + // 본 필터 훅(core.plugin_settings.update_rules)은 코어 UpdatePluginSettingsRequest 가 + // "현재 요청의 is_test_mode 에 따른 조건부 검증 규칙"을 만들도록 발행하는 확장점이므로, + // 현재 입력 값 참조가 본질적으로 필요하다 (FormRequest 우회가 아님 — 코어 검증기 자체의 입력). + $isTestMode = filter_var( + // audit:allow listener-formrequest-bypass reason: 검증 규칙 필터 훅의 의도된 입력 모드 참조 + request()->input('is_test_mode', true), + FILTER_VALIDATE_BOOLEAN, + FILTER_NULL_ON_FAILURE + ); + + if ($isTestMode !== false) { + return $rules; + } + + // 운영 모드 검증이 확정된 이 시점(HTTP 요청 처리 중)에 현재 로케일로 항목 라벨을 등록한다. + // 요청 처리 시점에는 플러그인 lang 네임스페이스가 모두 준비돼 있어 폴백 없이 정확한 + // 로케일 라벨이 잡힌다. + $this->registerLiveCredentialAttributeLabels(); + + foreach (self::LIVE_REQUIRED_FIELDS as $field) { + $fieldRules = (array) ($rules[$field] ?? []); + // 코어가 부여한 nullable 을 제거하고 required 로 강제. + $fieldRules = array_values(array_filter( + $fieldRules, + static fn ($rule) => $rule !== 'nullable' + )); + if (! in_array('required', $fieldRules, true)) { + array_unshift($fieldRules, 'required'); + } + $rules[$field] = $fieldRules; + } + + return $rules; + } + + /** + * 발송 자격증명 필드의 검증 에러 메시지 항목 라벨을 현재 요청 로케일로 등록한다. + * + * 코어 UpdatePluginSettingsRequest 의 검증 에러 메시지에서 항목 이름이 영문 키(`bizppurio id`)로 + * 노출되지 않도록, 전역 validation.attributes 에 표시 이름을 런타임 병합한다. 코어 검증기를 + * 수정하지 않고 Laravel 의 attribute 해석 메커니즘만 활용한다. + */ + private function registerLiveCredentialAttributeLabels(): void + { + $locale = app()->getLocale(); + + // addLines 가 validation 그룹을 attributes 만 든 빈 껍데기로 조기 캐시하면, 표준 Translator + // 의 isLoaded 가드로 인해 validation.php 전체(required 등)가 로드되지 않는 회귀가 발생한다. + // 따라서 addLines 전에 validation 그룹을 먼저 로드시켜 캐시를 정상으로 채운 뒤 attributes 만 보탠다. + Lang::get('validation.required', [], $locale); + + Lang::addLines([ + 'validation.attributes.bizppurio_id' => __(self::IDENTIFIER.'::messages.settings.bizppurio_id_attribute', [], $locale), + 'validation.attributes.password' => __(self::IDENTIFIER.'::messages.settings.password_attribute', [], $locale), + 'validation.attributes.sender_number' => __(self::IDENTIFIER.'::messages.settings.sender_number_attribute', [], $locale), + ], $locale); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php new file mode 100644 index 00000000..8d6b251f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php @@ -0,0 +1,150 @@ + + */ + protected $fillable = [ + 'refkey', + 'messagekey', + 'channel', + 'media', + 'to_number', + 'to_name', + 'to_user_id', + 'content', + 'request_payload', + 'notification_type', + 'notification_log_id', + 'status', + 'result_code', + 'result_message', + 'fallback_status', + 'source', + 'is_test_mode', + 'sent_at', + 'reported_at', + 'raw_payload', + ]; + + /** + * 속성 캐스팅 정의 + * + * @return array + */ + protected function casts(): array + { + return [ + 'channel' => DispatchChannel::class, + 'status' => DispatchStatus::class, + 'source' => DispatchSource::class, + 'to_user_id' => 'integer', + 'notification_log_id' => 'integer', + 'is_test_mode' => 'boolean', + 'sent_at' => 'datetime', + 'reported_at' => 'datetime', + 'request_payload' => 'array', + 'raw_payload' => 'array', + ]; + } + + /** + * 마스킹된 수신 전화번호를 반환합니다 (010-****-5678 형식). + * + * 가운데 4자리를 `*` 로 가린다. 뒤 4자리·앞 3자리는 노출한다. 전체보기는 + * 권한(messaging.view)이 있는 상세 화면에서 원본 to_number 로 별도 제공한다. + * + * @return string 마스킹된 번호 + */ + public function getMaskedNumberAttribute(): string + { + $digits = preg_replace('/[^0-9]/', '', $this->to_number) ?? ''; + $len = strlen($digits); + + if ($len < 7) { + // 너무 짧으면 뒤 절반만 노출 + return str_repeat('*', (int) ceil($len / 2)).substr($digits, (int) ceil($len / 2)); + } + + $head = substr($digits, 0, $len - 8 > 0 ? 3 : $len - 4); + $tail = substr($digits, -4); + + return $head.'-****-'.$tail; + } + + /** + * 회원 수신자 관계 (비회원이면 null). + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'to_user_id'); + } + + /** + * refkey 로 조회하는 스코프 (webhook 매칭). + * + * @param Builder $query + * @param string $refkey 우리 부여 키 + * @return Builder + */ + public function scopeByRefkey(Builder $query, string $refkey): Builder + { + return $query->where('refkey', $refkey); + } +} 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 new file mode 100644 index 00000000..3dc403c8 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -0,0 +1,139 @@ + + */ + protected array $repositories = [ + BizppurioDispatchRepositoryInterface::class => BizppurioDispatchRepository::class, + BizppurioTemplateRepositoryInterface::class => BizppurioTemplateRepository::class, + ]; + + /** + * CacheInterface 가 필요한 서비스 (contextual binding). + * + * - BizppurioTokenService(Phase 2): 발송 토큰 캐시 + * - WebhookReportService(Phase 4): 잔액부족 알림 쿨다운(D3 중복 방지) + * + * @var array + */ + protected array $cacheServices = [ + BizppurioTokenService::class, + WebhookReportService::class, + ]; + + /** + * 서비스 컨테이너 바인딩을 등록합니다. + * + * 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, + ]); + } + } + + /** + * 플러그인 루트 lang 디렉토리를 로드합니다. + * + * 백엔드 다국어는 lang/{ko,en}/*.php 에 두고 `$this->translationNamespace()` + * (= 플러그인 식별자) 네임스페이스로 로드한다. + */ + protected function loadExtensionTranslations(): void + { + $langPath = dirname($this->getProviderPath(), 2).'/lang'; + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, $this->translationNamespace()); + } + } + + /** + * 부팅 — 코어 알림 ChannelManager 에 sms 채널 드라이버를 등록합니다. + * + * 코어 GenericNotification::via() 가 반환하는 'sms' 채널 문자열은 코어에 드라이버가 + * 없어 그대로 두면 발송 시 "Driver [sms] not supported" 예외가 난다. 코어를 수정하지 + * 않고 ChannelManager::extend() 로 드라이버를 런타임 등록해, 'sms' 채널 발송이 + * SmsChannelDriver::send() 로 위임되게 한다. 플러그인 비활성/삭제 시 이 등록도 + * 사라져 코어가 안전하게 원복된다. + * + * 알림톡('alimtalk')도 동일하게 AlimtalkChannelDriver 로 위임한다(Phase 6). 관리자가 + * 알림톡 탭에서 연결한 승인 템플릿(binding)이 있을 때만 실제 발송되고, 미연결 알림은 + * 드라이버가 조용히 skip 한다. + */ + public function boot(): void + { + parent::boot(); + + // ChannelManager 는 지연 싱글턴(첫 알림 발송 시 해석)이므로 resolving 콜백으로 등록한다. + // 다만 다른 코드가 이미 해석해 둔 경우 resolving 이 발화하지 않으므로, 이미 해석돼 + // 있으면 즉시 등록하여 어느 순서에서도 누락되지 않게 한다. + $this->app->resolving( + ChannelManager::class, + fn (ChannelManager $manager) => $this->registerChannelDrivers($manager), + ); + + if ($this->app->resolved(ChannelManager::class)) { + $this->registerChannelDrivers($this->app->make(ChannelManager::class)); + } + } + + /** + * 코어 알림 ChannelManager 에 이 플러그인의 채널 드라이버를 등록합니다. + * + * - sms: SmsChannelDriver 로 실제 발송 위임(Phase 3). + * - alimtalk: AlimtalkChannelDriver 로 실제 발송 위임(Phase 6). 연결된 승인 템플릿 + * (binding)이 있을 때만 발송하고 미연결 알림은 드라이버가 skip 한다. + * + * @param ChannelManager $manager 코어 알림 채널 매니저 + */ + private function registerChannelDrivers(ChannelManager $manager): void + { + $manager->extend('sms', fn ($app) => $app->make(SmsChannelDriver::class)); + $manager->extend('alimtalk', fn ($app) => $app->make(AlimtalkChannelDriver::class)); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php new file mode 100644 index 00000000..6d2df914 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php @@ -0,0 +1,161 @@ + $data 이력 데이터 + * @return BizppurioDispatch 생성된 이력 + */ + public function create(array $data): BizppurioDispatch + { + return BizppurioDispatch::create($data); + } + + /** + * refkey 로 발송 이력을 조회합니다. + * + * @param string $refkey 우리 부여 키 + * @return BizppurioDispatch|null 매칭된 이력 또는 null + */ + public function findByRefkey(string $refkey): ?BizppurioDispatch + { + return BizppurioDispatch::query()->byRefkey($refkey)->first(); + } + + /** + * 발송 이력의 속성을 갱신합니다. + * + * @param BizppurioDispatch $dispatch 대상 이력 + * @param array $data 갱신 데이터 + * @return BizppurioDispatch 갱신된 이력 + */ + public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch + { + $dispatch->fill($data)->save(); + + return $dispatch; + } + + /** + * 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다. + * + * @param array $filters channel / status / date_from / date_to / keyword + * @param int $perPage 페이지당 건수 + * @return LengthAwarePaginator + */ + public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator + { + $query = BizppurioDispatch::query(); + + if (! empty($filters['channel'])) { + $query->where('channel', $filters['channel']); + } + + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (! empty($filters['date_from'])) { + $query->where('sent_at', '>=', $filters['date_from']); + } + + if (! empty($filters['date_to'])) { + $query->where('sent_at', '<=', $filters['date_to']); + } + + if (! empty($filters['keyword'])) { + $keyword = $filters['keyword']; + $query->where(function ($q) use ($keyword) { + $q->where('to_number', 'like', "%{$keyword}%") + ->orWhere('to_name', 'like', "%{$keyword}%") + ->orWhere('refkey', 'like', "%{$keyword}%"); + }); + } + + // 발송 이력은 발송할 때마다 쌓여 뒤쪽 페이지가 깊어진다. 지연 조인으로 OFFSET 구간에서는 + // 키 컬럼만 훑고, 본문·요청/webhook 페이로드 같은 넓은 컬럼은 이번 페이지 행에서만 읽는다. + // 정렬 스펙 끝에 키 컬럼이 자동으로 덧붙어 동률(같은 시각 발송) 구간의 전순서도 보장된다. + // + // columns 를 좁히지 않은 이유: 이 목록을 소비하는 화면이 아직 없어 표시 컬럼 계약이 + // 확정되지 않았다. OFFSET 구간의 넓은 컬럼 읽기는 지연 조인으로 이미 사라졌으므로, + // 컬럼 프루닝은 화면이 생겨 실제 사용 컬럼이 정해질 때 얹는다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => 'created_at', 'direction' => 'desc']], + perPage: $perPage, + ); + } + + /** + * refkey 로 dispatch 를 찾아 코어 알림 로그 id 를 연결합니다 (A-2 연결고리). + * + * @param string $refkey 발송 사이클에서 부여한 refkey + * @param int $notificationLogId 코어 notification_logs.id + * @return bool 연결 성공 여부(대상 없으면 false) + */ + public function linkNotificationLog(string $refkey, int $notificationLogId): bool + { + $dispatch = BizppurioDispatch::query()->byRefkey($refkey)->first(); + + if ($dispatch === null) { + return false; + } + + $dispatch->notification_log_id = $notificationLogId; + $dispatch->save(); + + return true; + } + + /** + * 코어 알림 로그 id 목록으로 dispatch 를 일괄 조회해 log id 키 맵으로 반환합니다 (A-2 표시). + * + * @param array $notificationLogIds 코어 로그 id 목록 + * @return Collection notification_log_id 를 키로 하는 dispatch 맵 + */ + public function findByNotificationLogIdsKeyed(array $notificationLogIds): Collection + { + if ($notificationLogIds === []) { + return collect(); + } + + return BizppurioDispatch::query() + ->whereIn('notification_log_id', $notificationLogIds) + ->get() + ->keyBy('notification_log_id'); + } + + /** + * 코어 로그에 연결된 최근 dispatch 를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관). + * + * @param int $limit 최근 dispatch 조회 상한 + * @return Collection notification_log_id 를 키로 하는 dispatch 맵 + */ + public function recentLinkedKeyed(int $limit = 1000): Collection + { + return BizppurioDispatch::query() + ->whereNotNull('notification_log_id') + ->orderByDesc('id') + ->limit($limit) + ->get() + ->keyBy('notification_log_id'); + } +} 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..aa150d52 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioTemplateRepository.php @@ -0,0 +1,246 @@ + + */ + 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 의 [사유 보기]가 소비하므로 포함한다. + * + * 행 수는 알림 정의 수(notification_type UNIQUE)에 묶인다 — 코어·확장이 선언한 알림 + * 개수만큼만 존재하는 설정성 테이블이므로 상한 없는 get() 이 데이터 증가에 비례하지 않는다. + * + * @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(); + } + + /** + * 특정 상태의 행 전체를 조회합니다 (동기화 커맨드 대상 선별). + * + * allSummaries() 와 같은 근거로 상한을 두지 않는다 — 알림 정의 수에 묶인 설정성 테이블의 + * 부분집합이다. + * + * @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/BizppurioDispatchRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php new file mode 100644 index 00000000..6e6935da --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php @@ -0,0 +1,88 @@ + $data 이력 데이터 + * @return BizppurioDispatch 생성된 이력 + */ + public function create(array $data): BizppurioDispatch; + + /** + * refkey 로 발송 이력을 조회합니다 (webhook 매칭). + * + * @param string $refkey 우리 부여 키 + * @return BizppurioDispatch|null 매칭된 이력 또는 null(위조) + */ + public function findByRefkey(string $refkey): ?BizppurioDispatch; + + /** + * 발송 이력의 속성을 갱신합니다. + * + * @param BizppurioDispatch $dispatch 대상 이력 + * @param array $data 갱신 데이터 + * @return BizppurioDispatch 갱신된 이력 + */ + public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch; + + /** + * 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다 (이력 화면). + * + * @param array $filters channel / status / date_from / date_to / keyword + * @param int $perPage 페이지당 건수 + * @return LengthAwarePaginator + */ + public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator; + + /** + * refkey 로 dispatch 를 찾아 코어 알림 로그 id 를 연결합니다 (A-2 연결고리). + * + * LinkNotificationLogListener 가 발송 사이클 직후 코어 로그 id 를 이 dispatch 에 기록할 때 + * 호출한다. refkey 가 없거나(비-비즈뿌리오 로그) 이미 연결됐으면 아무 것도 하지 않는다. + * + * @param string $refkey 발송 사이클에서 부여한 refkey + * @param int $notificationLogId 코어 notification_logs.id + * @return bool 연결 성공 여부(대상 없으면 false) + */ + public function linkNotificationLog(string $refkey, int $notificationLogId): bool; + + /** + * 코어 알림 로그 id 목록으로 dispatch 를 일괄 조회해 log id 키 맵으로 반환합니다 (A-2 표시). + * + * 코어 알림 발송 이력 화면이 현재 페이지의 log id 배열로 비즈뿌리오 결과를 한 번에 조회한다 + * (N+1 회피). 매칭되지 않는 log id(메일·DB 등 비-비즈뿌리오)는 맵에서 빠진다. + * + * @param array $notificationLogIds 코어 로그 id 목록 + * @return Collection notification_log_id 를 키로 하는 dispatch 맵 + */ + public function findByNotificationLogIdsKeyed(array $notificationLogIds): Collection; + + /** + * 코어 로그에 연결된 최근 dispatch 를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관). + * + * 화면이 파라미터 없이 GET 으로 최근 결과 맵을 받아 row.id 로 매칭한다(kginicis test-map 선례). + * params 로 로그 id 를 넘기지 않으므로 목록 data_source 로드 순서에 의존하지 않는다. 이력 화면은 + * 페이지네이션(20건)이라 최근 N건 맵으로 현재 화면을 덮는다. notification_log_id 가 없는 + * dispatch(아직 연결 전)는 제외한다. + * + * @param int $limit 최근 dispatch 조회 상한 + * @return Collection notification_log_id 를 키로 하는 dispatch 맵 + */ + public function recentLinkedKeyed(int $limit = 1000): Collection; +} 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 new file mode 100644 index 00000000..682a92e0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php @@ -0,0 +1,281 @@ +definitionService->resolve($type)?->getLocalizedName(); + + return $label !== null && $label !== '' ? $label : $type; + } catch (\Throwable $e) { + return $type; + } + } + + /** + * 알림을 카카오 알림톡으로 발송합니다. + * + * Laravel NotificationSender 가 'alimtalk' 채널 드라이버로 이 메서드를 호출한다. + * GenericNotification 이 아닌 알림은 대상이 아니므로 조용히 무시한다. + * + * @param object $notifiable 수신자 (User 또는 GuestNotifiable) + * @param Notification $notification 발송 대상 알림 + */ + public function send(object $notifiable, Notification $notification): void + { + if (! $notification instanceof GenericNotification) { + return; + } + + $type = $notification->getType(); + + // 1. 템플릿 행 조회 + 발송 게이트 판정 (미승인·비활성·행 없음 → skip). + // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를 + // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다. + $template = $this->templates->findByType($type); + if ($template === null || ! $template->isAlimtalkSendable()) { + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_template_not_approved', ['type' => $this->resolveTypeLabel($type)]) + ); + } + + // 2. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) + $to = $this->resolvePhone($notifiable, $notification->getData()); + if ($to === null) { + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.recipient_phone_missing', ['type' => $this->resolveTypeLabel($type)]) + ); + } + + // 3. 승인 스냅샷 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip. + $mapped = $this->payloadMapper->map((array) $template->approved_content, $notification->getData()); + $message = (string) ($mapped['message'] ?? ''); + if (trim($message) === '') { + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.message_body_empty', ['type' => $this->resolveTypeLabel($type)]) + ); + } + + // 4. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra, 대체발송 ON 시 SMS resend 병합) + $refkey = $this->generateRefkey(); + $payload = $this->payloadBuilder->buildAlimtalk( + $to, + (string) $template->template_code, + $message, + $refkey, + (array) ($mapped['extra'] ?? []), + ); + + if ($template->fallback_sms_enabled) { + $payload = $this->withSmsFallback($payload, $this->smsFallbackBody( + $template, + $notification, + BaseNotification::resolveNotifiableLocale($notifiable), + )); + } + + // 5. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신. + $this->dispatches->create([ + 'refkey' => $refkey, + 'channel' => DispatchChannel::Alimtalk->value, + 'to_number' => $to, + 'to_name' => $notifiable->name ?? null, + 'to_user_id' => $this->resolveUserId($notifiable), + 'content' => $message, + 'request_payload' => $this->payloadBuilder->forHistory($payload), + 'notification_type' => $type, + 'status' => DispatchStatus::Pending->value, + 'source' => DispatchSource::Auto->value, + 'is_test_mode' => $this->isTestMode(), + 'sent_at' => now(), + ]); + + // A-2: 이 발송 사이클 직후 발화할 코어 알림 로그(after_log_sent)에 이 dispatch 를 잇도록 + // refkey 를 컨텍스트에 남긴다. LinkNotificationLogListener 가 그 로그 id 를 여기에 연결한다. + $this->linkContext->remember($refkey); + + SendMessageJob::dispatch($payload, $refkey); + } + + /** + * SMS 대체발송 본문을 행의 sms_body 에서 렌더합니다 (#597 §3.5). + * + * 대체 SMS 본문 소스는 코어 알림 템플릿이 아니라 bizppurio_templates.sms_body 다. + * `#{var}` 표기를 알림 data 로 치환하며, 본문이 비어 있으면 빈 문자열을 반환하고 + * withSmsFallback 이 병합하지 않는다(빈 SMS 방지). + * + * sms_body 는 로케일별 맵이므로 SmsChannelDriver 와 동일하게 수신자 로케일로 렌더한다 — + * 같은 알림의 SMS 단독 발송과 대체발송이 서로 다른 언어로 나가면 안 된다(§14.3). + * + * @param BizppurioTemplate $template 템플릿 행 + * @param GenericNotification $notification 발송 대상 알림 + * @param string|null $locale 수신자 로케일 (null 이면 현재 앱 로케일) + * @return string 치환 완료된 대체 SMS 본문 (없으면 빈 문자열) + */ + private function smsFallbackBody(object $template, GenericNotification $notification, ?string $locale = null): string + { + $body = trim($template->getLocalizedSmsBody($locale)); + if ($body === '') { + return ''; + } + + return $this->payloadMapper->substituteText($body, $notification->getData()); + } + + /** + * 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송). + * + * 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다. + * `resend:{first:"sms"}` + `recontent:{sms:{message}}` 구조를 따른다(현행 방식 유지 — + * G7 레벨 재발송 없음). 대체 본문이 비어 있으면 빈 SMS 를 보내지 않도록 병합하지 않는다. + * + * @param array $payload 알림톡 발송 payload + * @param string $renderedBody 치환 완료된 대체 SMS 본문 + * @return array resend/recontent 가 병합된 payload (빈 본문이면 원본 그대로) + */ + private function withSmsFallback(array $payload, string $renderedBody): array + { + if (trim($renderedBody) === '') { + return $payload; + } + + $payload['resend'] = ['first' => 'sms']; + $payload['recontent'] = ['sms' => ['message' => $renderedBody]]; + + return $payload; + } + + /** + * 수신자가 회원이면 user id 를, 비회원(GuestNotifiable)이면 null 을 반환합니다. + * + * @param object $notifiable 수신자 + * @return int|null 회원 ID 또는 null + */ + private function resolveUserId(object $notifiable): ?int + { + if ($notifiable instanceof User) { + return (int) $notifiable->getKey(); + } + + return null; + } + + /** + * 수신자의 전화번호를 해석합니다 (SmsChannelDriver 와 동일 규칙). + * + * 회원(Notifiable)은 mobile 속성을, 비회원은 알림 data 의 _recipient_phone 을 사용한다. + * 숫자 외 문자는 제거하고, 값이 없으면 null 을 반환한다. + * + * @param object $notifiable 수신자 + * @param array $data 알림 data + * @return string|null 정규화된 전화번호 또는 null + */ + private function resolvePhone(object $notifiable, array $data): ?string + { + $raw = $notifiable->mobile + ?? ($data[self::RECIPIENT_PHONE_KEY] ?? null); + + $normalized = preg_replace('/[^0-9]/', '', (string) $raw); + + return ($normalized === null || $normalized === '') ? null : $normalized; + } + + /** + * webhook 매칭용 refkey(UTF-8 최대 32byte, unique)를 생성합니다. + * + * @return string 32자 refkey + */ + private function generateRefkey(): string + { + return Str::random(32); + } + + /** + * 검수 모드 여부를 반환합니다 (발송 이력 스냅샷용). + * + * 기본값(미설정)은 안전하게 검수(true)로 간주한다(BizppurioApiClient::baseUrl() 과 동일 정책). + * + * @return bool 검수 모드면 true + */ + private function isTestMode(): bool + { + return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php new file mode 100644 index 00000000..6f9e96bf --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php @@ -0,0 +1,312 @@ + + */ + private const LINK_FIELD_MAP = [ + 'linkMo' => 'url_mobile', + 'linkPc' => 'url_pc', + 'linkAnd' => 'scheme_android', + 'linkIos' => 'scheme_ios', + ]; + + /** + * 카카오 상세조회 응답을 발송 API content.at 형식으로 변환합니다. + * + * @param array $detail 카카오 상세조회 응답(templateContent/buttons/…) + * @param array $data 알림 data (변수 치환 소스: {key => value}) + * @return array{message: string, extra: array} 치환 완료 본문 + extra + */ + public function map(array $detail, array $data): array + { + $message = $this->substitute((string) ($detail['templateContent'] ?? ''), $data); + + $extra = []; + + if ($button = $this->mapButtons($detail['buttons'] ?? null, $data)) { + $extra['button'] = $button; + } + + if ($quickreply = $this->mapButtons($detail['quickReplies'] ?? null, $data)) { + $extra['quickreply'] = $quickreply; + } + + if ($title = $this->substitute((string) ($detail['templateTitle'] ?? ''), $data)) { + $extra['title'] = $title; + } + + if ($header = $this->substitute((string) ($detail['templateHeader'] ?? ''), $data)) { + $extra['header'] = $header; + } + + if ($item = $this->mapItem($detail['templateItem'] ?? null, $data)) { + $extra['item'] = $item; + } + + if ($highlight = $this->mapItemHighlight($detail['templateItemHighlight'] ?? null, $data)) { + $extra['itemhighlight'] = $highlight; + } + + if ($link = $this->mapLinkFields($detail['templateRepresentLink'] ?? null, $data)) { + $extra['link'] = $link; + } + + return ['message' => $message, 'extra' => $extra]; + } + + /** + * 버튼/바로연결 배열을 발송 형식으로 변환합니다 (동일 규칙 공유). + * + * @param mixed $buttons 카카오 buttons/quickReplies 배열 + * @param array $data 변수 치환 소스 + * @return array> 발송 button/quickreply 배열 (없으면 빈 배열) + */ + private function mapButtons(mixed $buttons, array $data): array + { + if (! is_array($buttons) || $buttons === []) { + return []; + } + + $mapped = []; + + foreach ($buttons as $button) { + if (! is_array($button)) { + continue; + } + + $row = []; + + if (isset($button['name'])) { + $row['name'] = $this->substitute((string) $button['name'], $data); + } + + if (isset($button['linkType'])) { + $row['type'] = (string) $button['linkType']; + } + + // 링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 필드 + URL 변수 치환. + foreach (self::LINK_FIELD_MAP as $from => $to) { + if (isset($button[$from]) && $button[$from] !== '') { + $row[$to] = $this->substituteLinkField((string) $button[$from], $data); + } + } + + // 전화·플러그인 등 부가 필드. + if (isset($button['telNumber']) && $button['telNumber'] !== '') { + $row['tel_number'] = (string) $button['telNumber']; + } + + if (isset($button['pluginId']) && $button['pluginId'] !== '') { + $row['plugin_id'] = (string) $button['pluginId']; + } + + if ($row !== []) { + $mapped[] = $row; + } + } + + return $mapped; + } + + /** + * 대표링크/링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 형식으로 변환합니다. + * + * @param mixed $link 카카오 templateRepresentLink + * @param array $data 변수 치환 소스 + * @return array 발송 link 필드 (없으면 빈 배열) + */ + private function mapLinkFields(mixed $link, array $data): array + { + if (! is_array($link) || $link === []) { + return []; + } + + $mapped = []; + + foreach (self::LINK_FIELD_MAP as $from => $to) { + if (isset($link[$from]) && $link[$from] !== '') { + $mapped[$to] = $this->substituteLinkField((string) $link[$from], $data); + } + } + + return $mapped; + } + + /** + * 아이템리스트(list/summary)를 변환하고 각 필드를 치환합니다. + * + * @param mixed $item 카카오 templateItem + * @param array $data 변수 치환 소스 + * @return array 발송 item (없으면 빈 배열) + */ + private function mapItem(mixed $item, array $data): array + { + if (! is_array($item) || $item === []) { + return []; + } + + $mapped = []; + + if (isset($item['list']) && is_array($item['list'])) { + $list = []; + foreach ($item['list'] as $entry) { + if (! is_array($entry)) { + continue; + } + $list[] = [ + 'title' => $this->substitute((string) ($entry['title'] ?? ''), $data), + 'description' => $this->substitute((string) ($entry['description'] ?? ''), $data), + ]; + } + if ($list !== []) { + $mapped['list'] = $list; + } + } + + if (isset($item['summary']) && is_array($item['summary'])) { + $mapped['summary'] = [ + 'title' => $this->substitute((string) ($item['summary']['title'] ?? ''), $data), + 'description' => $this->substitute((string) ($item['summary']['description'] ?? ''), $data), + ]; + } + + return $mapped; + } + + /** + * 아이템 하이라이트(title/description)를 변환하고 치환합니다. + * + * @param mixed $highlight 카카오 templateItemHighlight + * @param array $data 변수 치환 소스 + * @return array 발송 itemhighlight (없으면 빈 배열) + */ + private function mapItemHighlight(mixed $highlight, array $data): array + { + if (! is_array($highlight) || $highlight === []) { + return []; + } + + $mapped = []; + + if (isset($highlight['title'])) { + $mapped['title'] = $this->substitute((string) $highlight['title'], $data); + } + + if (isset($highlight['description'])) { + $mapped['description'] = $this->substitute((string) $highlight['description'], $data); + } + + 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 값으로 치환합니다. + * + * 카카오 템플릿 변수와 코어 알림 data 는 변수명 규칙이 동일하다(표기만 #{} vs {}). + * data 에 없는 변수는 원문(#{key})을 유지한다 — 카카오가 변수 불일치로 판단하게 두어, + * 우리가 임의로 빈 값을 채워 잘못된 발송을 하지 않는다. + * + * @param string $text 치환 대상(#{key} 포함) + * @param array $data 변수 치환 소스 + * @return string 치환된 문자열 + */ + private function substitute(string $text, array $data): string + { + if ($text === '' || $data === []) { + return $text; + } + + $replacements = []; + foreach ($data as $key => $value) { + if (is_scalar($value) || $value === null) { + $replacements['#{'.$key.'}'] = (string) $value; + } + } + + return strtr($text, $replacements); + } + + /** + * 버튼/링크 URL 필드를 치환하고, 프로토콜 중복을 방어합니다. + * + * 코어 알림 data 의 `*_url` 계열 변수(action_url 등)는 항상 `config('app.url')` 기반의 + * 프로토콜 포함 완전한 URL 이다(mail 채널이 href="{action_url}" 로 원문 그대로 소비하는 + * 계약). 반면 카카오 알림톡 버튼은 `http://#{action_url}` 처럼 원문에 프로토콜 접두어를 + * 직접 붙여 등록되는 경우가 있어, 단순 문자열 치환 시 `http://https://…` 형태로 프로토콜이 + * 중복될 수 있다. + * + * 원문이 `http://`/`https://` 로 시작 + 치환 결과가 (그 접두어를 제거했을 때) 다시 + * `http://`/`https://` 로 시작하는 경우에만 원문의 선행 접두어를 제거한다. 변수가 이미 + * 프로토콜 없이 등록된 경우(가이드 문서 원안)나, 변수값 자체가 프로토콜을 포함하지 않는 + * 경우(상대경로 등)는 원문을 그대로 둔다 — 후자를 건드리면 오히려 필요한 접두어가 사라진다. + * + * @param string $text 치환 대상(#{key} 포함, 링크 필드 원문) + * @param array $data 변수 치환 소스 + * @return string 치환되고 프로토콜 중복이 제거된 문자열 + */ + private function substituteLinkField(string $text, array $data): string + { + $substituted = $this->substitute($text, $data); + + foreach (['http://', 'https://'] as $prefix) { + if (! str_starts_with($text, $prefix)) { + continue; + } + + $afterPrefix = substr($substituted, strlen($prefix)); + if (str_starts_with($afterPrefix, 'http://') || str_starts_with($afterPrefix, 'https://')) { + return $afterPrefix; + } + } + + return $substituted; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php new file mode 100644 index 00000000..97c16337 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php @@ -0,0 +1,85 @@ +> [{code, name, groupName}] + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function categories(): array + { + $response = $this->kakao->request('/v3/kakao/template/category/all'); + + $this->assertSuccess($response); + + return array_values((array) ($response['data'] ?? [])); + } + + /** + * 발신프로필(사용중) 목록을 조회합니다. + * + * 규격(5.발신프로필관리): `/v3/kakao/profile/use` 응답의 data 는 + * `{success: [...프로필], fail: [...조회실패]}` 2단 봉투다. 실제 발신프로필 목록은 + * data.success 배열에 담기므로 그 배열을 반환한다(data 통째 반환 시 success/fail + * 껍데기가 소비처에 그대로 노출됨). + * + * @return array> 발신프로필 목록(data.success) + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function senderProfiles(): array + { + $response = $this->kakao->getSenderProfiles(); + + $this->assertSuccess($response); + + return array_values((array) ($response['data']['success'] ?? [])); + } + + /** + * 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/BizppurioApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php new file mode 100644 index 00000000..d7ffd430 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php @@ -0,0 +1,153 @@ + $payload MessagePayloadBuilder 가 조립한 발송 본문 + * @return array 응답 배열 (code/description/messagekey/refkey) + * + * @throws BizppurioApiException HTTP 실패·응답 파싱 실패 시 + */ + public function sendMessage(array $payload): array + { + $result = $this->postMessage($payload, $this->tokenService->getToken()); + + // 토큰·인증 무효 → 재발급 후 1회 재시도 + if (in_array((string) ($result['code'] ?? ''), self::AUTH_INVALID_CODES, true)) { + $result = $this->postMessage($payload, $this->tokenService->refreshToken()); + } + + return $result; + } + + /** + * 발송 응답 결과코드가 성공(1000)인지 판정합니다. + * + * @param array $result sendMessage 응답 + * @return bool 성공이면 true + */ + public function isSuccess(array $result): bool + { + return (string) ($result['code'] ?? '') === self::SUCCESS_CODE; + } + + /** + * 단일 발송 요청을 수행합니다. + * + * @param array $payload 발송 본문 + * @param string $token Bearer 액세스 토큰 + * @return array 응답 배열 + * + * @throws BizppurioApiException HTTP 실패·응답 파싱 실패 시 + */ + private function postMessage(array $payload, string $token): array + { + $response = $this->http() + ->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json; charset=utf-8', + ]) + ->post($this->baseUrl().'/v3/message', $payload); + + if ($response->failed()) { + // 비즈뿌리오는 실패 응답에도 code·description 을 담아준다(명세). 이를 추출해 + // 예외에 실어, 발송 이력·로그에 "왜 실패했는지"(결과코드+사유)가 남도록 한다. + // body 가 없거나 파싱 불가하면 HTTP 상태만 보존한다. + $body = $response->json(); + $code = is_array($body) ? (string) ($body['code'] ?? '') : ''; + $description = is_array($body) ? (string) ($body['description'] ?? '') : ''; + + throw new BizppurioApiException( + $description !== '' + ? $description + : __('sirsoft-message_bizppurio::messages.error.send_failed'), + resultCode: $code !== '' ? $code : null, + httpStatus: $response->status(), + ); + } + + $result = $response->json(); + + if (! is_array($result)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.invalid_response'), + httpStatus: $response->status(), + ); + } + + return $result; + } + + /** + * 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다. + * + * @return string 베이스 URL (스킴 포함) + */ + private function baseUrl(): string + { + // 검수 모드(is_test_mode)가 꺼져 있으면 운영 도메인으로 발송한다. 기본값(미설정)은 + // 안전하게 검수(true)로 간주해 운영 발송이 우발적으로 일어나지 않도록 한다. + $isTestMode = (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); + + return $isTestMode ? self::HOST_DEV : self::HOST_LIVE; + } + + /** + * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다. + */ + private function http(): PendingRequest + { + return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS) + ->timeout(self::REQUEST_TIMEOUT_SECONDS); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php new file mode 100644 index 00000000..107a40fa --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php @@ -0,0 +1,400 @@ + 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function getSenderProfiles(): array + { + return $this->post('/v3/kakao/profile/use'); + } + + /** + * 알림톡 템플릿 목록을 조회합니다. (`/v3/kakao/template/list`) + * + * @param string $senderKey 발신프로필 키 + * @param array $params 추가 조회 파라미터 (count/page/status 등) + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function getTemplateList(string $senderKey, array $params = []): array + { + return $this->post('/v3/kakao/template/list', array_merge($params, [ + 'senderKey' => $senderKey, + ])); + } + + /** + * 알림톡 템플릿 상세를 조회합니다. (`/v3/kakao/template/detail`) + * + * @param string $senderKey 발신프로필 키 + * @param string $templateCode 템플릿 코드 + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function getTemplateDetail(string $senderKey, string $templateCode): array + { + return $this->post('/v3/kakao/template/detail', [ + 'senderKey' => $senderKey, + 'templateCode' => $templateCode, + ]); + } + + /** + * 카카오 관리 API 의 임의 엔드포인트를 호출합니다. + * + * 템플릿 CRUD·검수·카테고리 조회 등에서 재사용한다. bizId/apiKey 는 + * 자동으로 주입되므로 도메인 파라미터만 전달한다. + * + * @param string $path 엔드포인트 경로 (예: '/v3/kakao/template/add') + * @param array $params 요청 파라미터 (bizId/apiKey 제외) + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function request(string $path, array $params = []): array + { + 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)인지 판정합니다. + * + * @param array $result 응답 배열 + * @return bool 성공이면 true + */ + public function isSuccess(array $result): bool + { + return (string) ($result['code'] ?? '') === self::SUCCESS_CODE; + } + + /** + * bizId/apiKey 를 주입해 카카오 관리 API 를 POST 호출합니다. + * + * @param string $path 엔드포인트 경로 + * @param array $params 요청 파라미터 (bizId/apiKey 제외) + * @return array 응답 배열 + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + private function post(string $path, array $params = []): array + { + [$bizId, $apiKey] = $this->credentials(); + + $response = $this->http() + ->withHeaders(['Content-Type' => 'application/json; charset=utf-8']) + ->post(self::BASE_URL.$path, array_merge($params, [ + 'bizId' => $bizId, + 'apiKey' => $apiKey, + ])); + + 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; + } + + /** + * HTTP 실패 응답에서 카카오가 준 사유(message)와 결과코드(code)를 추출해 예외를 만듭니다. + * + * 카카오 관리 API 는 실패 시에도 `{code, message}` 봉투를 반환하므로, body 를 파싱해 + * 운영자에게 실제 사유(접근 불가 IP·반려 사유·계정 오류 등)를 노출한다. body 파싱이 + * 불가하거나 message 가 비어 있으면 일반 실패 문구로 폴백한다. + * + * @param Response $response 실패한 HTTP 응답 + * @return BizppurioApiException 카카오 사유·결과코드·HTTP 상태가 담긴 예외 + */ + private function failureException(Response $response): BizppurioApiException + { + $body = $response->json(); + $message = is_array($body) ? (string) ($body['message'] ?? '') : ''; + $code = is_array($body) ? (string) ($body['code'] ?? '') : ''; + + return new BizppurioApiException( + $message !== '' + ? $message + : __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), + resultCode: $code !== '' ? $code : null, + httpStatus: $response->status(), + ); + } + + /** + * 환경설정에서 bizId(=bizppurio_id)와 apiKey 를 조회합니다. + * + * @return array{0: string, 1: string} [bizId, apiKey] + * + * @throws BizppurioApiException 자격증명 미설정 시 + */ + private function credentials(): array + { + $settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? []; + $bizId = (string) ($settings['bizppurio_id'] ?? ''); + $apiKey = (string) ($settings['api_key'] ?? ''); + + if ($bizId === '' || $apiKey === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.kakao_credentials_missing'), + ); + } + + return [$bizId, $apiKey]; + } + + /** + * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다. + */ + private function http(): PendingRequest + { + return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS) + ->timeout(self::REQUEST_TIMEOUT_SECONDS); + } +} 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..f5370375 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTemplateService.php @@ -0,0 +1,726 @@ + $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 자체가 실패하면 다음 + * 시도에서 다시 채번부터 진행된다. + * - 검수자 전달 의견(comment)은 kapi request 에만 싣고 행에 저장하지 않는다 — 카카오 심사 + * 가이드가 요구하는 변수 '예시 텍스트' 를 전할 유일한 통로다(제품 결정 2026-08-23, §18.7). + * + * @param BizppurioTemplate $template 대상 행 + * @param string|null $comment 검수자 전달 의견 (≤500, 비어 있으면 미전달) + * @return BizppurioTemplate 갱신된 행 (status=requested) + * + * @throws BizppurioTemplateStateException 신청 불가 상태·content 미작성 + * @throws BizppurioApiException kapi 실패 (사유 원문 보존) + */ + public function requestInspection(BizppurioTemplate $template, ?string $comment = null): 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, + trim((string) $comment), + )); + } 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/BizppurioTokenService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php new file mode 100644 index 00000000..ba257735 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php @@ -0,0 +1,209 @@ +cache->remember( + self::CACHE_KEY, + fn (): string => $this->issueToken(), + self::CACHE_TTL_SECONDS, + ); + } + + /** + * 캐시된 토큰을 무효화하고 새 토큰을 발급받아 반환합니다. + * + * 발송 응답이 3002/3005(토큰·인증 무효)일 때 호출한다. + * + * @return string 새로 발급받은 Bearer 액세스 토큰 + * + * @throws BizppurioApiException 토큰 재발급 실패 시 + */ + public function refreshToken(): string + { + $this->cache->forget(self::CACHE_KEY); + + return $this->getToken(); + } + + /** + * 캐시된 토큰을 제거합니다. + */ + public function forget(): void + { + $this->cache->forget(self::CACHE_KEY); + } + + /** + * 현재 저장된 자격증명으로 토큰 발급을 즉시 재검증합니다. + * + * 캐시를 거치지 않고 매번 `/v1/token` 을 새로 호출한다(관리자가 계정/비밀번호가 + * 유효한지 그 자리에서 확인하려는 목적 — 설정 화면 "연결 확인" 버튼이 소비). + * 검증에 성공하면 새로 발급된 토큰으로 캐시를 갱신해, 확인 직후의 발송이 이 + * 토큰을 그대로 재사용할 수 있게 한다(불필요한 재발급 방지). + * + * @return string 새로 발급받은 Bearer 액세스 토큰 + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function verifyCredentials(): string + { + $token = $this->issueToken(); + + $this->cache->put(self::CACHE_KEY, $token, self::CACHE_TTL_SECONDS); + + return $token; + } + + /** + * `/v1/token` 을 호출해 새 토큰을 발급받습니다. + * + * @return string Bearer 액세스 토큰 + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + private function issueToken(): string + { + $settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? []; + $account = (string) ($settings['bizppurio_id'] ?? ''); + $password = (string) ($settings['password'] ?? ''); + + if ($account === '' || $password === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.credentials_missing'), + ); + } + + $response = $this->http() + ->withHeaders([ + 'Authorization' => 'Basic '.base64_encode($account.':'.$password), + 'Content-Type' => 'application/json; charset=utf-8', + ]) + ->post($this->baseUrl($settings).'/v1/token'); + + if ($response->failed()) { + throw new BizppurioApiException( + $this->describeFailure($response), + resultCode: (string) ($response->json('code') ?? '') ?: null, + httpStatus: $response->status(), + ); + } + + $token = (string) ($response->json('accesstoken') ?? ''); + + if ($token === '') { + throw new BizppurioApiException( + $this->describeFailure($response), + resultCode: (string) ($response->json('code') ?? '') ?: null, + httpStatus: $response->status(), + ); + } + + return $token; + } + + /** + * 토큰 발급 실패 응답에서 비즈뿌리오 원문 사유를 담은 메시지를 만듭니다. + * + * 비즈뿌리오는 실패 시 `{"code": "3007", "description": "invalid password in + * bizppurio"}` 형태로 원인을 내려준다. 원문이 있으면 함께 노출해 운영자가 계정/ + * 비밀번호 오류인지 서버 오류인지 즉시 구분할 수 있게 한다(고정 문구만으로는 + * 원인 추적이 불가능했던 문제 대응). + * + * @param \Illuminate\Http\Client\Response $response 실패한 HTTP 응답 + * @return string 사용자에게 보여줄 실패 메시지 + */ + private function describeFailure(\Illuminate\Http\Client\Response $response): string + { + $description = (string) ($response->json('description') ?? ''); + + if ($description === '') { + return __('sirsoft-message_bizppurio::messages.error.token_issue_failed'); + } + + return __('sirsoft-message_bizppurio::messages.error.token_issue_failed_with_reason', [ + 'reason' => $description, + ]); + } + + /** + * 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다. + * + * @param array $settings 플러그인 환경설정 + * @return string 베이스 URL (스킴 포함) + */ + private function baseUrl(array $settings): string + { + // 검수 모드(is_test_mode)가 꺼져 있으면 운영 도메인으로 발송한다. 기본값(미설정)은 + // 안전하게 검수(true)로 간주해 운영 발송이 우발적으로 일어나지 않도록 한다. + $isTestMode = (bool) ($settings['is_test_mode'] ?? true); + + return $isTestMode ? self::HOST_DEV : self::HOST_LIVE; + } + + /** + * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다. + */ + private function http(): PendingRequest + { + return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS) + ->timeout(self::REQUEST_TIMEOUT_SECONDS); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php new file mode 100644 index 00000000..42fe4fc5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php @@ -0,0 +1,55 @@ +pendingRefkey = $refkey; + } + + /** + * 링크 리스너가 최근 refkey 를 꺼냅니다 (꺼내면 비웁니다). + * + * 비-비즈뿌리오 채널(mail/database 등)의 로그가 발화한 경우엔 이 사이클에서 remember 된 + * refkey 가 없으므로 null 을 반환한다(그 로그는 연결 대상 아님). + * + * @return string|null 최근 refkey 또는 null + */ + public function consume(): ?string + { + $refkey = $this->pendingRefkey; + $this->pendingRefkey = null; + + return $refkey; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php new file mode 100644 index 00000000..441379b9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php @@ -0,0 +1,103 @@ + $notificationLogIds 코어 로그 id 목록(현재 페이지) + * @return array> notification_log_id → 결과 표시 데이터 + */ + public function resultsForLogIds(array $notificationLogIds): array + { + $dispatchMap = $this->dispatches->findByNotificationLogIdsKeyed($notificationLogIds); + + return $this->buildResultMap($dispatchMap); + } + + /** + * 최근 연결된 dispatch 결과를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관). + * + * 화면이 파라미터 없이 조회해 row.id 로 매칭한다(kginicis test-map 선례). 목록 data_source + * 로드 순서에 의존하지 않아 결과 컬럼이 빈 배열로 비는 문제를 원천 차단한다. + * + * @param int $limit 최근 dispatch 조회 상한 + * @return array> notification_log_id → 결과 표시 데이터 + */ + public function recentResults(int $limit = 1000): array + { + return $this->buildResultMap($this->dispatches->recentLinkedKeyed($limit)); + } + + /** + * dispatch 맵을 log id 키의 결과 표시 배열 맵으로 변환합니다. + * + * @param \Illuminate\Support\Collection $dispatchMap log id 키 dispatch 맵 + * @return array> notification_log_id → 결과 표시 데이터 + */ + private function buildResultMap(\Illuminate\Support\Collection $dispatchMap): array + { + $results = []; + + foreach ($dispatchMap as $logId => $dispatch) { + $results[(int) $logId] = $this->buildResult($dispatch); + } + + return $results; + } + + /** + * dispatch 1건을 화면 표시용 결과 배열로 변환합니다. + * + * @param BizppurioDispatch $dispatch 연결된 발송 이력 + * @return array 상태·결과 라벨·잔액부족·대체발송·검수 모드 표시 데이터 + */ + private function buildResult(BizppurioDispatch $dispatch): array + { + $code = $dispatch->result_code; + $hasCode = $code !== null && $code !== ''; + + return [ + 'status' => $dispatch->status?->value, + 'status_label' => $dispatch->status?->label(), + 'result_code' => $code, + // `사유 (코드)` 표시 라벨. 코드가 없으면(리포트 미수신) null → 화면은 상태 라벨만 표시. + 'result_label' => $hasCode ? $this->resultCodes->label($code) : null, + 'is_low_balance' => $hasCode && $this->resultCodes->isBalanceLow($code), + 'fallback_status' => $dispatch->fallback_status, + 'channel' => $dispatch->channel?->value, + // 발송 시점 검수 모드 스냅샷. null=컬럼 신설 이전 이력(검수 여부 판단 불가 → 화면 미표시). + 'is_test_mode' => $dispatch->is_test_mode, + // 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 notification_logs.body(대체발송용 코어 + // 템플릿 본문)와 다른 값(카카오 승인 템플릿 실제 내용)이라, 코어 "본문" 표시만으로는 + // 실제 발송 내용을 알 수 없다 — 화면이 채널별로 구분해 별도 노출할 수 있도록 포함. + 'content' => $dispatch->content, + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php new file mode 100644 index 00000000..672d0047 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php @@ -0,0 +1,174 @@ + 발송 payload + */ + public function buildSms(string $to, string $message, string $refkey): array + { + return $this->withCommon('sms', $to, $refkey, [ + 'sms' => [ + 'message' => $message, + ], + ]); + } + + /** + * LMS 발송 payload 를 조립합니다. (message: EUC-KR 최대 2000byte, subject 최대 64byte) + * + * @param string $to 수신 전화번호 + * @param string $message 본문 + * @param string $refkey 우리 부여 키 + * @param string|null $subject 제목(코어 subject 재사용). 없으면 생략 + * @return array 발송 payload + */ + public function buildLms(string $to, string $message, string $refkey, ?string $subject = null): array + { + $lms = ['message' => $message]; + + if ($subject !== null && $subject !== '') { + $lms['subject'] = $subject; + } + + return $this->withCommon('lms', $to, $refkey, [ + 'lms' => $lms, + ]); + } + + /** + * 알림톡(AT) 발송 payload 를 조립합니다. (message: 한/영 1300자) + * + * senderkey 는 환경설정 sender_key 를 사용한다. 버튼/바로연결 등 유형별 선택 + * 필드는 $extra 로 병합한다(템플릿에 포함된 경우 필수 — 채널 드라이버가 구성). + * + * @param string $to 수신 전화번호 + * @param string $templateCode 카카오 템플릿 코드 + * @param string $message 치환 완료된 본문 (#{변수} 형식) + * @param string $refkey 우리 부여 키 + * @param array $extra button/quickreply/title 등 유형별 선택 필드 + * @return array 발송 payload + */ + public function buildAlimtalk( + string $to, + string $templateCode, + string $message, + string $refkey, + array $extra = [], + ): array { + $at = array_merge([ + 'senderkey' => $this->senderKey(), + 'templatecode' => $templateCode, + 'message' => $message, + ], $extra); + + return $this->withCommon('at', $to, $refkey, [ + 'at' => $at, + ]); + } + + /** + * 공통 필드(account/type/from/to/refkey)와 채널별 content 를 결합합니다. + * + * @param string $type 메시지 타입 (sms/lms/at) + * @param string $to 수신 전화번호 + * @param string $refkey 우리 부여 키 + * @param array $content 채널별 content 배열 + * @return array 발송 payload + */ + private function withCommon(string $type, string $to, string $refkey, array $content): array + { + return [ + 'account' => $this->account(), + 'type' => $type, + 'from' => $this->senderNumber(), + 'to' => $to, + 'refkey' => $refkey, + 'content' => $content, + ]; + } + + /** + * 발송 payload 에서 발송 이력(request_payload) 저장용으로 개인식별 정보를 제거합니다. + * + * 이력 테이블에 이미 저장되는 필드(to→to_number, refkey→refkey, type→channel, + * content.{sms,lms,at}.message→content)는 완전 중복이므로 제외한다. 나머지(account/ + * from/senderkey/templatecode/button 등 요소·resend/recontent)는 이력 테이블 어디에도 + * 없고 발송 실패 원인 분석(결함①)·버튼 URL 검증(결함②)에 필요하므로 그대로 남긴다. + * + * @param array $payload buildSms/buildLms/buildAlimtalk 가 조립한 발송 payload + * @return array 개인식별 정보를 제거한 이력 저장용 payload + */ + public function forHistory(array $payload): array + { + unset($payload['to'], $payload['refkey'], $payload['type']); + + foreach (['sms', 'lms', 'at'] as $channelKey) { + if (isset($payload['content'][$channelKey]['message'])) { + unset($payload['content'][$channelKey]['message']); + } + } + + if (isset($payload['recontent']['sms']['message'])) { + unset($payload['recontent']['sms']['message']); + } + + return $payload; + } + + /** + * 발송 계정(account = bizppurio_id)을 반환합니다. + */ + private function account(): string + { + return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'bizppurio_id', ''); + } + + /** + * 발신 번호(from = sender_number)를 반환합니다. + */ + private function senderNumber(): string + { + return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_number', ''); + } + + /** + * 알림톡 발신프로필 키(senderkey = sender_key)를 반환합니다. + */ + private function senderKey(): string + { + return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_key', ''); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php new file mode 100644 index 00000000..f854ce79 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php @@ -0,0 +1,122 @@ +categorize($code) === ResultCategory::Success; + } + + /** + * 결과 코드가 잔액 부족인지 여부 (D3 자체 알림 트리거). + * + * @param string $code 결과 코드 + * @return bool + */ + public function isBalanceLow(string $code): bool + { + return in_array($code, self::BALANCE_LOW_CODES, true); + } + + /** + * 결과 코드의 사람이 읽는 사유(로케일)를 반환합니다. + * + * lang 에 정의된 코드면 사유를, 없으면 null 을 반환한다. + * + * @param string $code 결과 코드 + * @return string|null 사유 또는 null(미정의) + */ + public function reason(string $code): ?string + { + $key = "sirsoft-message_bizppurio::result_codes.{$code}"; + $reason = __($key); + + // __() 는 미정의 시 키 문자열을 그대로 반환 → 미정의로 판정. + return $reason === $key ? null : $reason; + } + + /** + * 표시용 라벨 `사유 (코드)` 을 반환합니다. + * + * lang 에 사유가 있으면 "사유 (코드)", 없으면 코드만 반환한다. + * + * @param string $code 결과 코드 + * @return string 표시 라벨 + */ + public function label(string $code): string + { + $reason = $this->reason($code); + + return $reason === null ? $code : "{$reason} ({$code})"; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php new file mode 100644 index 00000000..33108a52 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -0,0 +1,237 @@ +mobile, 비회원=알림 data 의 _recipient_phone + * (게스트 전화번호는 각 도메인 extract_data 리스너가 data 에 주입 — D1). + * 3. sms_body 의 #{var} 치환 → refkey 생성 → SmsTypeResolver 로 SMS/LMS 판별 → + * MessagePayloadBuilder 로 payload 조립 → SendMessageJob 위임(발송·재시도는 Job 책임). + */ +class SmsChannelDriver +{ + /** 플러그인 식별자 (manifest 와 일치) */ + private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio'; + + /** 알림 data 에서 비회원 전화번호를 싣는 표준 키 (extract_data 리스너와 계약) */ + public const RECIPIENT_PHONE_KEY = '_recipient_phone'; + + /** + * @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 발송 이력 영속화 + * @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2) + * @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용) + */ + public function __construct( + private readonly NotificationDefinitionService $definitionService, + private readonly BizppurioTemplateRepositoryInterface $templates, + private readonly AlimtalkPayloadMapper $payloadMapper, + private readonly SmsTypeResolver $typeResolver, + private readonly MessagePayloadBuilder $payloadBuilder, + private readonly BizppurioDispatchRepositoryInterface $dispatches, + private readonly DispatchLinkContext $linkContext, + private readonly PluginSettingsService $pluginSettings, + ) {} + + /** + * 알림 유형의 사람이 읽는 이름을 반환합니다 (스킵 예외 메시지·LMS 제목용). + * + * 정의 조회 실패·이름 미설정 시 코드값(type)을 그대로 반환한다(안전 폴백). + * + * LMS 제목으로 쓸 때는 수신자 로케일을 넘긴다 — 인자를 생략하면 앱 로케일이 적용되어 + * 본문과 제목의 언어가 갈린다. 운영자에게 보이는 스킵 예외 메시지는 앱 로케일이 맞다. + * + * @param string $type 알림 유형 코드값 (welcome 등) + * @param string|null $locale 렌더 로케일 (null 이면 현재 앱 로케일) + * @return string 사람이 읽는 이름 또는 코드값 + */ + private function resolveTypeLabel(string $type, ?string $locale = null): string + { + try { + $label = $this->definitionService->resolve($type)?->getLocalizedName($locale); + + return $label !== null && $label !== '' ? $label : $type; + } catch (\Throwable $e) { + return $type; + } + } + + /** + * 알림을 문자(SMS/LMS)로 발송합니다. + * + * Laravel NotificationSender 가 'sms' 채널 드라이버로 이 메서드를 호출한다. + * GenericNotification 이 아닌 알림은 대상이 아니므로 조용히 무시한다. + * + * @param object $notifiable 수신자 (User 또는 GuestNotifiable) + * @param Notification $notification 발송 대상 알림 + */ + public function send(object $notifiable, Notification $notification): void + { + if (! $notification instanceof GenericNotification) { + return; + } + + $type = $notification->getType(); + + // 1. 템플릿 행 조회 + 게이트 판정 (행 없음·비활성·본문 없음 → skip). + // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를 + // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다. + $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)]) + ); + } + + // 2. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) + $to = $this->resolvePhone($notifiable, $notification->getData()); + if ($to === null) { + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.recipient_phone_missing', ['type' => $this->resolveTypeLabel($type)]) + ); + } + + // 3. 본문 렌더 — 수신자 로케일의 sms_body 를 골라 #{var} 치환(알림톡 본문과 동일 규칙). + // 로케일 해석은 코어와 같은 규약(BaseNotification::resolveNotifiableLocale)을 쓴다. + $locale = BaseNotification::resolveNotifiableLocale($notifiable); + $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 조립. + // LMS 제목은 알림 정의 이름이며, 본문과 같은 수신자 로케일로 렌더한다 — 본문만 + // 로케일을 따르고 제목이 앱 로케일이면 한 통 안에서 언어가 갈린다. + $refkey = $this->generateRefkey(); + $channel = $this->typeResolver->resolve($message); + + $payload = $channel === DispatchChannel::Lms + ? $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 갱신. + $this->dispatches->create([ + 'refkey' => $refkey, + 'channel' => $channel->value, + 'to_number' => $to, + 'to_name' => $notifiable->name ?? null, + 'to_user_id' => $this->resolveUserId($notifiable), + 'content' => $message, + 'request_payload' => $this->payloadBuilder->forHistory($payload), + 'notification_type' => $type, + 'status' => DispatchStatus::Pending->value, + 'source' => DispatchSource::Auto->value, + 'is_test_mode' => $this->isTestMode(), + 'sent_at' => now(), + ]); + + // A-2: 이 발송 사이클 직후 발화할 코어 알림 로그(after_log_sent)에 이 dispatch 를 잇도록 + // refkey 를 컨텍스트에 남긴다. LinkNotificationLogListener 가 그 로그 id 를 여기에 연결한다. + $this->linkContext->remember($refkey); + + SendMessageJob::dispatch($payload, $refkey); + } + + /** + * 수신자가 회원이면 user id 를, 비회원(GuestNotifiable)이면 null 을 반환합니다. + * + * @param object $notifiable 수신자 + * @return int|null 회원 ID 또는 null + */ + private function resolveUserId(object $notifiable): ?int + { + // User 모델만 회원 PK 로 취급. GuestNotifiable 은 DB 회원이 아니므로 null. + if ($notifiable instanceof User) { + return (int) $notifiable->getKey(); + } + + return null; + } + + /** + * 수신자의 전화번호를 해석합니다. + * + * 회원(Notifiable)은 mobile 속성을, 비회원은 알림 data 의 _recipient_phone 을 사용한다. + * 숫자·하이픈 외 문자는 제거하고, 값이 없으면 null 을 반환한다. + * + * @param object $notifiable 수신자 + * @param array $data 알림 data + * @return string|null 정규화된 전화번호 또는 null + */ + private function resolvePhone(object $notifiable, array $data): ?string + { + $raw = $notifiable->mobile + ?? ($data[self::RECIPIENT_PHONE_KEY] ?? null); + + $normalized = preg_replace('/[^0-9]/', '', (string) $raw); + + return ($normalized === null || $normalized === '') ? null : $normalized; + } + + /** + * webhook 매칭용 refkey(UTF-8 최대 32byte, unique)를 생성합니다. + * + * 32자 hex(=32byte)로 고정해 발송 payload refkey 제약을 만족한다. + * + * @return string 32자 refkey + */ + private function generateRefkey(): string + { + return Str::random(32); + } + + /** + * 검수 모드 여부를 반환합니다 (발송 이력 스냅샷용). + * + * 기본값(미설정)은 안전하게 검수(true)로 간주한다(BizppurioApiClient::baseUrl() 과 동일 정책). + * + * @return bool 검수 모드면 true + */ + private function isTestMode(): bool + { + return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php new file mode 100644 index 00000000..84e01bbd --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php @@ -0,0 +1,90 @@ +byteLength($message) <= self::SMS_MAX_BYTES + ? DispatchChannel::Sms + : DispatchChannel::Lms; + } + + /** + * 본문의 EUC-KR 기준 byte 길이를 반환합니다. + * + * 이모지 등 EUC-KR 미표현 문자를 제거한 뒤 변환하여 byte 를 측정한다. + * + * @param string $message 대상 문자열(UTF-8) + * @return int EUC-KR byte 길이 + */ + public function byteLength(string $message): int + { + $sanitized = $this->stripUnsupported($message); + $eucKr = mb_convert_encoding($sanitized, 'EUC-KR', 'UTF-8'); + + return strlen($eucKr); + } + + /** + * LMS 최대 byte 를 초과하는지 여부를 반환합니다. + * + * @param string $message 변수 치환이 끝난 최종 본문 + * @return bool LMS 한도(2000byte) 초과 시 true + */ + public function exceedsLmsLimit(string $message): bool + { + return $this->byteLength($message) > self::LMS_MAX_BYTES; + } + + /** + * EUC-KR 로 표현 불가능한 문자(이모지 등)를 제거합니다. + * + * mb_convert_encoding 은 미표현 문자를 '?' 로 바꿔 byte 를 왜곡시키므로, + * UTF-8 → EUC-KR → UTF-8 왕복 후 원본과 달라진(=치환된) 문자를 걸러낸다. + * + * @param string $message 대상 문자열(UTF-8) + * @return string EUC-KR 표현 가능한 문자만 남긴 문자열 + */ + private function stripUnsupported(string $message): string + { + $substituteBackup = mb_substitute_character(); + // 미표현 문자를 삭제(none)하도록 설정해 왜곡 없이 제거 + mb_substitute_character('none'); + + try { + $eucKr = mb_convert_encoding($message, 'EUC-KR', 'UTF-8'); + + return mb_convert_encoding($eucKr, 'UTF-8', 'EUC-KR'); + } finally { + mb_substitute_character($substituteBackup); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php new file mode 100644 index 00000000..0a566668 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php @@ -0,0 +1,158 @@ + $report 검증된 리포트 (REFKEY/RESULT/MEDIA/TELRES/KAORES + raw) + */ + public function apply(array $report): void + { + $refkey = (string) ($report['REFKEY'] ?? ''); + $resultCode = (string) ($report['RESULT'] ?? ''); + + // ① REFKEY 매칭 — 없으면 위조/미매칭, 멱등 흡수 + $dispatch = $this->dispatches->findByRefkey($refkey); + if ($dispatch === null) { + return; + } + + // ② replay — 이미 리포트 반영된 이력이면 멱등 + if ($this->wasAlreadyReported($dispatch)) { + $this->logReplayDetected($refkey, $resultCode); + + return; + } + + // ③ 결과 분류 → 상태 전이 + 리포트 필드 기록 + $category = $this->resolver->categorize($resultCode); + $status = $category === ResultCategory::Success + ? DispatchStatus::Success + : DispatchStatus::Failed; + + $this->dispatches->update($dispatch, [ + 'status' => $status->value, + 'media' => $report['MEDIA'] ?? null, + 'result_code' => $resultCode, + 'result_message' => $this->resolver->reason($resultCode), + 'fallback_status' => $this->resolveFallbackStatus($report, $dispatch), + 'reported_at' => now(), + 'raw_payload' => $report['raw'] ?? $report, + ]); + + // ④ 잔액부족(9070/7436) → 관리자 자체 알림 (쿨다운 내 1회만, D3) + if ($category === ResultCategory::BalanceLow) { + $this->notifyBalanceLowOnce($resultCode, $dispatch->channel->value); + } + } + + /** + * 잔액부족 알림을 쿨다운 내 1회만 발송합니다 (D3 중복 방지). + * + * 잔액이 부족하면 발송건마다 9070/7436 이 쏟아지므로, 그때마다 알림을 보내면 + * 관리자에게 알림이 폭주한다. 채널별로 캐시 키를 두어, 쿨다운(기본 1시간) 동안 + * 이미 알렸으면 발송을 건너뛴다. 발송 이력의 실패 기록(③)은 이와 무관하게 매 건 + * 남으므로, 실패 내역 자체는 모두 보존된다. + * + * 쿨다운은 environment 설정(balance_low_notify_cooldown, 초)으로 조정 가능하며, + * 화면 노출 없이 설정 파일에서만 관리한다. 잔액을 충전하면 9070/7436 이 더 이상 + * 발생하지 않으므로 알림도 자연히 멈춘다. + * + * @param string $resultCode 결과 코드(9070 문자 / 7436 알림톡) + * @param string $channel 채널(sms / lms / alimtalk) + */ + private function notifyBalanceLowOnce(string $resultCode, string $channel): void + { + $cacheKey = "bizppurio:balance_low_notified:{$channel}"; + + if ($this->cache->has($cacheKey)) { + return; + } + + $cooldown = (int) $this->pluginSettings->get( + self::PLUGIN_IDENTIFIER, + 'balance_low_notify_cooldown', + self::DEFAULT_BALANCE_LOW_COOLDOWN, + ); + + $this->cache->put($cacheKey, true, $cooldown); + + HookManager::doAction( + 'sirsoft-message_bizppurio.balance.low', + $resultCode, + $channel, + ); + } + + /** + * 대체발송 결과를 해석합니다 (webhook TELRES/KAORES). + * + * 비즈뿌리오는 대체발송 미요청 건에도 TELRES/KAORES 에 "0" 같은 값을 채워 보내므로 + * (실측), webhook 응답값만으로는 대체발송 발생 여부를 판단할 수 없다. 우리가 발송 + * 시점에 실제로 대체발송을 요청했는지(`request_payload` 의 resend/recontent 존재)를 + * 먼저 확인해, 요청하지 않았으면 webhook 값과 무관하게 null 을 반환한다. + * + * @param array $report 리포트 + * @param BizppurioDispatch $dispatch webhook 매칭된 발송 이력 (발송 시점 요청 payload 보유) + * @return string|null 대체발송 결과 코드 또는 null + */ + private function resolveFallbackStatus(array $report, BizppurioDispatch $dispatch): ?string + { + $requestPayload = $dispatch->request_payload ?? []; + if (! isset($requestPayload['resend'])) { + return null; + } + + $telres = $report['TELRES'] ?? null; + $kaores = $report['KAORES'] ?? null; + + return $telres !== null && $telres !== '' + ? (string) $telres + : ($kaores !== null && $kaores !== '' ? (string) $kaores : null); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php new file mode 100644 index 00000000..c1605f9b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -0,0 +1,149 @@ +withoutMiddleware([EnforceIdentityPolicy::class, RefreshTokenExpiration::class]) + ->name('webhook'); + +Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () { + // 리포트 수신 주소 조회 (관리자 설정 페이지 표시용) + // + // 비즈뿌리오가 발송 결과(리포트)를 URL PUSH 로 전송할 수신 주소를 사이트 도메인 + // 기준 절대 URL 로 내려준다. url() 헬퍼는 리버스 프록시 뒤에서 요청 host 가 + // localhost 로 떨어질 수 있어, 운영자가 관리하는 config('app.url') 을 신뢰 소스로 + // 삼아 절대화한다. 운영자가 접속한 주소와 무관하게 항상 정식 도메인이 표시된다. + // + // ※ 실제 리포트 수신 처리(POST /webhook) 는 Phase 4 에서 구현한다. + Route::get('/report-url', function () { + $origin = rtrim((string) config('app.url', 'http://localhost'), '/'); + + return response()->json([ + 'success' => true, + 'data' => [ + 'url' => $origin.'/api/plugins/sirsoft-message_bizppurio/webhook', + ], + ]); + })->middleware('permission:admin,core.plugins.read') + ->name('report.url'); + + // 알림톡 템플릿 화면 준비 상태(값 유무) 조회. + // + // 카카오 관리에 필요한 자격증명(api_key·sender_key)은 sensitive:true 라 코어 설정 조회 + // 응답에서 제거된다(보안). 따라서 프론트가 설정 조회 값으로 "설정됨" 을 판정할 수 없어, + // 값 자체는 노출하지 않고 저장 여부(boolean)만 내려준다. 알림톡 탭 readiness 배너가 소비. + Route::get('/templates-readiness', function () { + $settings = app(PluginSettingsService::class) + ->get('sirsoft-message_bizppurio') ?? []; + + $filled = static fn (string $key): bool => trim((string) ($settings[$key] ?? '')) !== ''; + + return response()->json([ + 'success' => true, + 'data' => [ + 'api_key_set' => $filled('api_key'), + 'sender_key_set' => $filled('sender_key'), + 'ready' => $filled('api_key') && $filled('sender_key'), + ], + ]); + })->middleware('permission:admin,sirsoft-message_bizppurio.messaging.view') + ->name('templates.readiness'); + + /* + |---------------------------------------------------------------------- + | 알림톡 작성 모달 참조 조회 (#597) — 발신프로필·카테고리 + |---------------------------------------------------------------------- + | + | 알림톡 템플릿 작성 모달의 발신프로필 셀렉트·카테고리 셀렉트가 소비한다. + | 실시간 목록/상세 화면(구 Phase 5)은 DB 기반 라이프사이클로 대체되어 제거됐다. + */ + Route::prefix('alimtalk-templates')->name('alimtalk-templates.')->group(function () { + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { + Route::get('/categories', [AlimtalkTemplateController::class, 'categories'])->name('categories'); + Route::get('/profiles', [AlimtalkTemplateController::class, 'profiles'])->name('profiles'); + }); + }); + + /* + |---------------------------------------------------------------------- + | 비즈뿌리오 알림 템플릿 라이프사이클 (#597 §3.2) + |---------------------------------------------------------------------- + | + | 시스템 등록(draft) → 검수 신청(requested) → 승인(approved) 후 발송 활성화. + | 발송 판정은 DB(bizppurio_templates)가 유일한 근거이며, 카카오 상태 정합은 + | 스케줄러(bizppurio:sync-template-status)와 수동 sync 가 유지한다. + | + | 조회(index/map/show) = messaging.view / 그 외 변경 = messaging.manage. + | 구체 경로(map/image)를 {id} 보다 먼저 두어 라우트 충돌을 막는다. + */ + Route::prefix('templates')->name('templates.')->group(function () { + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { + 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('/', [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'); + }); + }); + + /* + |---------------------------------------------------------------------- + | 코어 알림 발송 이력 결과 조회 (A-2) — 코어 이력 화면 overlay 가 소비 + |---------------------------------------------------------------------- + | + | 코어 "알림 발송 이력" 화면에 plugin overlay 로 얹은 결과 컬럼이, 현재 페이지의 코어 알림 + | 로그 id 배열을 이 API 로 넘겨 비즈뿌리오 결과(상태·사유·잔액부족·대체발송)를 한 번에 조회 + | 한다(N+1 회피). 코어 화면·코어 테이블 무수정 — 연결 표식은 우리 dispatch 쪽에만 둔다. + | + | 조회 = messaging.view. + */ + Route::prefix('dispatch-results')->name('dispatch-results.')->group(function () { + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { + // 화면 결과 컬럼용: 파라미터 없이 최근 결과 맵을 받아 row.id 로 매칭(타이밍 무관, 기본 경로). + Route::get('/recent', [DispatchResultController::class, 'recent'])->name('recent'); + // 명시 로그 id 배열 조회(직접 조회용). 화면은 recent 를 쓴다. + Route::post('/lookup', [DispatchResultController::class, 'lookup'])->name('lookup'); + }); + }); + + // 인증 토큰 재검증 — 저장된 계정/비밀번호가 유효한지 그 자리에서 확인한다(설정 + // 화면 "연결 확인" 버튼). 캐시를 거치지 않고 매번 /v1/token 을 새로 호출하므로 + // 조회가 아닌 쓰기 성격에 준해 messaging.manage 권한을 요구한다. + Route::post('/token/check', [TokenCheckController::class, 'check']) + ->middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage') + ->name('token.check'); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php new file mode 100644 index 00000000..96af2cf4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php @@ -0,0 +1,182 @@ +app->instance(AlimtalkTemplateService::class, $mock); + + return $mock; + } + + /** + * 지정 권한을 가진 admin 사용자를 생성한다. + * + * @param array $permissionIdentifiers + */ + private function adminWith(array $permissionIdentifiers): User + { + $user = User::factory()->create(); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $testRole = Role::create([ + 'identifier' => 'bizppurio_tpl_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + + $permissionIds = []; + foreach ($permissionIdentifiers as $identifier) { + $permissionIds[] = Permission::firstOrCreate( + ['identifier' => $identifier], + ['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin'] + )->id; + } + $testRole->permissions()->sync($permissionIds); + + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } + + /** + * @param array $permissions + * @return array + */ + private function authHeaders(array $permissions): array + { + $user = $this->adminWith($permissions); + $token = $user->createToken('test-token')->plainTextToken; + + return [ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json', + ]; + } + + /** + * view 권한으로 카테고리 전체를 조회한다 (data.categories 봉투). + */ + public function test_view권한으로_카테고리를_조회한다(): void + { + $this->mockService()->shouldReceive('categories')->once()->andReturn([ + ['code' => '001001', 'name' => '회원가입', 'groupName' => '회원'], + ]); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->getJson(self::BASE.'/categories'); + + $response->assertStatus(200); + $response->assertJsonPath('data.categories.0.code', '001001'); + $response->assertJsonPath('data.categories.0.groupName', '회원'); + } + + /** + * 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.'/categories')->assertStatus(401); + $this->getJson(self::BASE.'/profiles')->assertStatus(401); + } + + /** + * view 권한이 없으면 두 라우트 모두 403 이다. + */ + public function test_view권한_없으면_403(): void + { + $headers = $this->authHeaders(['sirsoft-message_bizppurio.messaging.other']); + + $this->withHeaders($headers)->getJson(self::BASE.'/categories')->assertStatus(403); + $this->withHeaders($headers)->getJson(self::BASE.'/profiles')->assertStatus(403); + } + + /** + * kapi 실패는 카카오 사유(message)·결과코드가 담긴 422 로 전파된다 (GuardsKakaoRequests 규약). + */ + public function test_kapi_실패는_422로_전파된다(): void + { + $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.'/profiles'); + + $response->assertStatus(422); + $response->assertJsonPath('errors.bizppurio_message', '발신프로필을 찾을 수 없습니다.'); + $response->assertJsonPath('errors.result_code', '7204'); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php new file mode 100644 index 00000000..5446ce8d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php @@ -0,0 +1,176 @@ +plugin = new Plugin; + } + + /** + * activate() 로 채널이 증강된 상태에서 uninstall() 을 호출하면, 코어 알림 정의는 + * 보존한 채 sms·alimtalk template 과 channels 배열의 우리 채널만 제거된다. + */ + public function test_uninstall_시_sms_alimtalk_템플릿이_정리되고_코어_정의는_보존된다(): void + { + $this->plugin->activate(); + + $this->assertGreaterThan( + 0, + NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(), + '사전조건: activate() 후 sms·alimtalk 템플릿이 존재해야 한다.' + ); + + $welcome = NotificationDefinition::where('type', 'welcome')->firstOrFail(); + $this->assertContains('sms', $welcome->channels); + $this->assertContains('alimtalk', $welcome->channels); + + $this->plugin->uninstall(); + + $this->assertSame( + 0, + NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(), + 'uninstall() 후에는 sms·alimtalk 채널 템플릿이 하나도 남으면 안 된다.' + ); + + $welcome->refresh(); + $this->assertEqualsCanonicalizing( + ['mail', 'database'], + $welcome->channels, + 'welcome 정의는 삭제되지 않고, channels 배열에서 sms·alimtalk 만 제거되어야 한다.' + ); + $this->assertNotNull( + NotificationTemplate::where('definition_id', $welcome->id)->where('channel', 'mail')->first(), + 'mail 채널 템플릿은 그대로 보존되어야 한다.' + ); + $this->assertNotNull( + NotificationTemplate::where('definition_id', $welcome->id)->where('channel', 'database')->first(), + 'database 채널 템플릿은 그대로 보존되어야 한다.' + ); + } + + /** + * 게시판·이커머스처럼 코어가 아닌 다른 모듈이 소유한 알림 정의도 동일하게, + * 정의 자체는 남고 우리 채널만 제거된다. + * + * 정리 로직(cleanupChannelContributions)은 소유자를 묻지 않고 "우리 채널 template 을 + * 가진 정의" 를 대상으로 하므로, 실제 모듈 설치 여부에 의존하지 않고 모듈 소유 정의를 + * 직접 구성해 검증한다 — 모듈이 활성화된 환경에서만 실행되는 조건부 테스트였다면 이 + * 경로가 대부분의 실행에서 미검증으로 남는다(#597 브랜치 검증에서 교정). + */ + public function test_uninstall_시_모듈_소유_알림도_채널만_정리되고_정의는_보존된다(): void + { + $this->plugin->activate(); + + $moduleDefinition = NotificationDefinition::create([ + 'type' => 'module_owned_notification_for_cleanup', + 'hook_prefix' => 'sirsoft-board.post', + 'extension_type' => 'module', + 'extension_identifier' => 'sirsoft-board', + 'name' => ['ko' => '모듈 소유 알림', 'en' => 'Module owned'], + 'variables' => [], + 'channels' => ['mail', 'sms', 'alimtalk'], + 'hooks' => ['sirsoft-board.post.after_create'], + 'is_active' => true, + ]); + + foreach (['mail', 'sms', 'alimtalk'] as $channel) { + NotificationTemplate::create([ + 'definition_id' => $moduleDefinition->id, + 'channel' => $channel, + 'subject' => ['ko' => '제목'], + 'body' => ['ko' => '본문'], + 'recipients' => [['type' => 'trigger_user']], + 'is_active' => true, + 'is_default' => true, + ]); + } + + $this->plugin->uninstall(); + + $moduleDefinition->refresh(); + $this->assertNotContains('sms', $moduleDefinition->channels); + $this->assertNotContains('alimtalk', $moduleDefinition->channels); + $this->assertContains( + 'mail', + $moduleDefinition->channels, + '모듈이 원래 갖고 있던 채널은 그대로 보존되어야 한다.' + ); + $this->assertSame( + 0, + NotificationTemplate::where('definition_id', $moduleDefinition->id) + ->whereIn('channel', ['sms', 'alimtalk']) + ->count() + ); + $this->assertNotNull( + NotificationTemplate::where('definition_id', $moduleDefinition->id) + ->where('channel', 'mail') + ->first(), + '모듈 소유 정의의 mail 템플릿은 보존되어야 한다.' + ); + } + + /** + * 제거 후 재설치(재활성화)하면 activate() 의 재시딩 경로로 채널이 다시 생성된다(회귀 방지). + */ + public function test_제거_후_재활성화하면_채널이_다시_생성된다(): void + { + $this->plugin->activate(); + $this->plugin->uninstall(); + + $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count()); + + $this->plugin->activate(); + + $this->assertGreaterThan( + 0, + NotificationTemplate::where('channel', 'sms')->count(), + '재활성화 후 sms 채널 템플릿이 다시 생성되어야 한다.' + ); + $this->assertGreaterThan( + 0, + NotificationTemplate::where('channel', 'alimtalk')->count(), + '재활성화 후 alimtalk 채널 템플릿이 다시 생성되어야 한다.' + ); + + $welcome = NotificationDefinition::where('type', 'welcome')->firstOrFail(); + $this->assertContains('sms', $welcome->channels); + $this->assertContains('alimtalk', $welcome->channels); + } + + /** + * 채널 증강이 전혀 없는 상태(activate 를 호출하지 않은 상태)에서 uninstall() 을 + * 호출해도 예외 없이 안전하게 통과해야 한다(정리 대상 0건). + */ + public function test_증강된_채널이_없는_상태에서_uninstall해도_안전하다(): void + { + app(NotificationDefinitionSeeder::class)->run(); + + $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count()); + + $result = $this->plugin->uninstall(); + + $this->assertTrue($result); + $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php new file mode 100644 index 00000000..608dafcf --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php @@ -0,0 +1,55 @@ +run(); + + $this->assertSame( + 0, + NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(), + '활성화 전에는 sms·alimtalk 채널 템플릿이 없어야 한다(설치 전 상태).' + ); + + // [실행] 정식 활성화 진입점. 이 안에서 리스너 선등록 → 재시딩이 채널을 증강해야 한다. + (new Plugin)->activate(); + + // [검증] 회원 대상 코어 알림(welcome 등)에 sms·alimtalk 템플릿이 생성됐다. + $this->assertGreaterThan( + 0, + NotificationTemplate::where('channel', 'sms')->count(), + 'activate() 후 sms 채널 템플릿이 회원 알림에 생성돼야 한다.' + ); + $this->assertGreaterThan( + 0, + NotificationTemplate::where('channel', 'alimtalk')->count(), + 'activate() 후 alimtalk 채널 템플릿이 회원 알림에 생성돼야 한다.' + ); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php new file mode 100644 index 00000000..380af4ce --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php @@ -0,0 +1,200 @@ +plugin = new Plugin; + } + + public function test_식별자가_디렉토리명과_일치한다(): void + { + $this->assertSame('sirsoft-message_bizppurio', $this->plugin->getIdentifier()); + $this->assertSame('sirsoft', $this->plugin->getVendor()); + } + + public function test_권한이_view_manage_2개로_선언된다(): void + { + $permissions = $this->plugin->getPermissions(); + + $this->assertArrayHasKey('categories', $permissions); + $this->assertCount(1, $permissions['categories']); + + $category = $permissions['categories'][0]; + $this->assertSame('messaging', $category['identifier']); + + $actions = array_column($category['permissions'], 'action'); + $this->assertEqualsCanonicalizing(['view', 'manage'], $actions); + + foreach ($category['permissions'] as $permission) { + $this->assertContains('admin', $permission['roles']); + $this->assertSame('admin', $permission['type']); + } + } + + public function test_설정_스키마의_크리덴셜은_sensitive다(): void + { + $schema = $this->plugin->getSettingsSchema(); + + foreach (['password', 'api_key', 'sender_key'] as $credential) { + $this->assertArrayHasKey($credential, $schema); + $this->assertTrue( + $schema[$credential]['sensitive'] ?? false, + "{$credential} 는 sensitive 로 마킹되어야 한다." + ); + } + + // 비크리덴셜 필드는 존재하되 sensitive 아님 + $this->assertArrayHasKey('bizppurio_id', $schema); + $this->assertArrayHasKey('sender_number', $schema); + $this->assertArrayHasKey('is_test_mode', $schema); + $this->assertSame('boolean', $schema['is_test_mode']['type']); + $this->assertTrue($schema['is_test_mode']['default']); + } + + public function test_defaults_json의_크리덴셜은_expose_false다(): void + { + $path = dirname(__DIR__, 3).'/config/settings/defaults.json'; + $this->assertFileExists($path); + + $defaults = json_decode(file_get_contents($path), true); + $schema = $defaults['frontend_schema']; + + foreach ($schema as $field => $rule) { + $this->assertFalse( + $rule['expose'] ?? true, + "{$field} 는 프론트에 노출(expose:true)되면 안 된다." + ); + } + + foreach (['password', 'api_key', 'sender_key'] as $credential) { + $this->assertTrue($schema[$credential]['sensitive'] ?? false); + } + } + + public function test_잔액부족_알림이_관리자_대상으로_정의된다(): void + { + $definitions = $this->plugin->getNotificationDefinitions(); + + $this->assertCount(1, $definitions); + $definition = $definitions[0]; + + $this->assertSame('bizppurio_balance_low', $definition['type']); + $this->assertSame('sirsoft-message_bizppurio', $definition['hook_prefix']); + $this->assertEqualsCanonicalizing(['mail', 'database'], $definition['channels']); + + foreach ($definition['templates'] as $template) { + $recipients = $template['recipients']; + $this->assertSame('role', $recipients[0]['type']); + $this->assertSame('admin', $recipients[0]['value']); + } + } + + /** + * 관리자 메뉴를 추가하지 않는다 (화면 배치 결정 2026-07-14, ⚑ 결정 3). + * + * 플러그인 메뉴 추가는 정식 기능이 아니므로 getAdminMenus()·메뉴 sync 를 + * 폐기했다. 진입은 코어 소유 설정 페이지(/admin/plugins/{id}/settings) 하나로 + * 통일한다. 활성화해도 플러그인 소속 메뉴 row 가 생성되지 않아야 한다. + */ + public function test_activate_시_관리자_메뉴를_생성하지_않는다(): void + { + $this->plugin->activate(); + + $this->assertSame( + 0, + Menu::where('extension_identifier', 'sirsoft-message_bizppurio')->count(), + '메뉴 폐기 결정에 따라 플러그인 소속 메뉴가 생성되면 안 된다.' + ); + + $this->assertNull( + Menu::where('slug', 'sirsoft-message_bizppurio') + ->where('extension_type', ExtensionOwnerType::Plugin->value) + ->first(), + '최상위 메시지 발송 관리 메뉴가 남아 있으면 안 된다.' + ); + } + + /** + * 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_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', + ])); + } + + /** + * 마이그레이션 up→down→up 왕복 후에도 리포지토리 호출 1회전이 동작한다. + */ + public function test_마이그레이션_왕복_후_리포지토리가_동작한다(): void + { + $migrations = base_path('plugins/_bundled/sirsoft-message_bizppurio/database/migrations'); + + // 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([ + 'refkey' => 'roundtrip', + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + ]); + $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/ChannelRegistrationHookTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php new file mode 100644 index 00000000..c104b193 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php @@ -0,0 +1,164 @@ + $settings 플러그인 설정 stub + */ + private function registerListener(array $settings = []): void + { + $stub = new class($settings) extends PluginSettingsService + { + /** @param array $map */ + public function __construct(private array $map) {} + + public function get(string $identifier, ?string $key = null, mixed $default = null): mixed + { + return $this->map[$key] ?? $default; + } + }; + + $listener = new RegisterNotificationChannelsListener($stub); + + HookManager::addFilter( + 'core.notification.filter_available_channels', + fn ($channels) => $listener->addChannels($channels), + 20, + ); + HookManager::addFilter( + 'core.notification.channel_readiness', + fn ($result, $channelId) => $listener->checkReadiness($result, $channelId), + 20, + ); + HookManager::addFilter( + 'core.notification.channel_enabled', + fn ($enabled, $extType, $extId, $channelId) => $listener->gateChannelEnabled($enabled, $extType, $extId, $channelId), + 20, + ); + } + + /** + * 코어 SettingsService 의 notifications.channels 저장값을 지정합니다. + * + * @param array> $channels + */ + private function setCoreChannels(array $channels): void + { + $settings = \Mockery::mock(SettingsService::class); + $settings->shouldReceive('getSetting') + ->with('notifications.channels', []) + ->andReturn($channels); + $this->app->instance(SettingsService::class, $settings); + app(NotificationChannelService::class)->clearChannelEnabledCache(); + } + + public function test_코어_채널서비스가_sms_alimtalk을_노출한다(): void + { + $this->registerListener(); + + // 실제 코어 서비스가 filter_available_channels 훅을 발화한다 + $channels = app(NotificationChannelService::class)->getAvailableChannels(); + $ids = array_column($channels, 'id'); + + $this->assertContains('sms', $ids); + $this->assertContains('alimtalk', $ids); + } + + public function test_코어_channelservice가_비회원_허용을_인식한다(): void + { + $this->registerListener(); + + $service = app(NotificationChannelService::class); + + $this->assertTrue($service->isChannelGuestAllowed('sms'), 'sms 는 allow_guest:true 여야 한다.'); + $this->assertTrue($service->isChannelGuestAllowed('alimtalk')); + } + + public function test_코어_readiness서비스가_미설정_sms를_not_ready로_판정한다(): void + { + $this->registerListener([]); // 설정 비어 있음 + + $result = app(ChannelReadinessService::class)->check('sms'); + + $this->assertFalse($result['ready']); + $this->assertStringContainsString('readiness.sms_credentials_missing', (string) $result['reason']); + } + + public function test_코어_readiness서비스가_완비_sms를_ready로_판정한다(): void + { + $this->registerListener([ + 'bizppurio_id' => 'acme', + 'password' => 'secret', + 'sender_number' => '025550000', + ]); + + $result = app(ChannelReadinessService::class)->check('sms'); + + $this->assertTrue($result['ready']); + } + + public function test_channel_enabled_미저장_sms는_off로_덮인다(): void + { + $this->registerListener(); + // mail/database 만 저장, sms/alimtalk 엔트리 없음(미저장) + $this->setCoreChannels([ + ['id' => 'mail', 'is_active' => true], + ['id' => 'database', 'is_active' => true], + ]); + + $service = app(NotificationChannelService::class); + + // 우리 채널: 미저장 → OFF (opt-in) + $this->assertFalse($service->isChannelEnabledForExtension('core', 'core', 'sms')); + $service->clearChannelEnabledCache(); + $this->assertFalse($service->isChannelEnabledForExtension('core', 'core', 'alimtalk')); + // 코어 기본 채널: 미저장이어도 ON 유지(하위호환) + $service->clearChannelEnabledCache(); + $this->assertTrue($service->isChannelEnabledForExtension('core', 'core', 'mail')); + } + + public function test_channel_enabled_저장된_on_sms는_on이다(): void + { + $this->registerListener(); + $this->setCoreChannels([ + ['id' => 'mail', 'is_active' => true], + ['id' => 'sms', 'is_active' => true], + ]); + + $this->assertTrue( + app(NotificationChannelService::class)->isChannelEnabledForExtension('core', 'core', 'sms') + ); + } + + public function test_channel_enabled_저장된_off_sms는_off이다(): void + { + $this->registerListener(); + $this->setCoreChannels([ + ['id' => 'mail', 'is_active' => true], + ['id' => 'sms', 'is_active' => false], + ]); + + $this->assertFalse( + app(NotificationChannelService::class)->isChannelEnabledForExtension('core', 'core', 'sms') + ); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/CoreTemplateResetDefaultsTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/CoreTemplateResetDefaultsTest.php new file mode 100644 index 00000000..ded621b0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/CoreTemplateResetDefaultsTest.php @@ -0,0 +1,124 @@ +fail('config/core.php welcome 정의에 database 채널 template 이 없다.'); + } + + /** welcome 정의 + 운영자가 손본(is_default=false) alimtalk 채널 행. */ + private function modifiedAlimtalkTemplate(): NotificationTemplate + { + $definition = NotificationDefinition::create([ + 'type' => 'welcome', + 'hook_prefix' => 'core.auth', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'name' => ['ko' => '회원가입 환영', 'en' => 'Welcome'], + 'variables' => [['key' => 'name', 'description' => '이름']], + 'channels' => ['mail', 'database', 'alimtalk'], + 'hooks' => ['core.auth.after_register'], + 'is_active' => true, + ]); + + return NotificationTemplate::create([ + 'definition_id' => $definition->id, + 'channel' => 'alimtalk', + 'subject' => ['ko' => '바뀐 제목'], + 'body' => ['ko' => '바뀐 본문 {name}'], + 'recipients' => [['type' => 'role', 'value' => 'admin']], + 'is_active' => true, + 'is_default' => false, + ]); + } + + /** core.settings.update 권한을 가진 관리자의 인증 헤더. */ + private function adminHeaders(): array + { + $user = User::factory()->create(); + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + $permission = Permission::firstOrCreate( + ['identifier' => 'core.settings.update'], + ['name' => json_encode(['ko' => '설정 수정', 'en' => 'Settings update']), 'type' => 'admin'] + ); + $adminRole->permissions()->syncWithoutDetaching([$permission->id]); + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer '.$user->fresh()->createToken('test')->plainTextToken, + ]; + } + + /** + * @effects core_reset_restores_seeded_channel_template_defaults + */ + public function test_리스너_등록_후_코어_기본값_조회가_시드와_같은_alimtalk_기본값을_돌려준다(): void + { + $this->registerListener(); + + $data = app(NotificationTemplateService::class)->getDefaultTemplateData('welcome', 'alimtalk'); + + $this->assertNotEmpty($data, '복원 경로가 alimtalk 기본값을 찾아야 한다(시드만 하고 복원에 빠지면 빈 배열).'); + $this->assertSame([['type' => 'trigger_user']], $data['recipients']); + $this->assertSame($this->welcomeDatabaseBodyKo(), $data['body']['ko'], '시딩과 같은 database 평문 body 가 복원값이다.'); + } + + /** + * @effects core_reset_restores_seeded_channel_template_defaults + */ + public function test_기본값_복원_엔드포인트가_시드된_alimtalk_행을_복원한다(): void + { + $this->registerListener(); + $template = $this->modifiedAlimtalkTemplate(); + + $this->postJson('/api/admin/notification-templates/'.$template->id.'/reset', [], $this->adminHeaders()) + ->assertOk(); + + $fresh = $template->fresh(); + $this->assertTrue((bool) $fresh->is_default, '복원 후 "사용자 수정" 표시가 사라져야 한다.'); + $this->assertSame([['type' => 'trigger_user']], $fresh->recipients); + $this->assertSame($this->welcomeDatabaseBodyKo(), $fresh->body['ko']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php new file mode 100644 index 00000000..9cf9527b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php @@ -0,0 +1,228 @@ + $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_result_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건 + 연결된 dispatch 1건을 만들고 로그 id 를 반환합니다. + * + * @param array $dispatchAttrs dispatch 오버라이드 + */ + private function seedLinkedLog(array $dispatchAttrs = []): int + { + $log = NotificationLog::create([ + 'channel' => 'sms', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => '01011112222', + 'recipient_name' => '홍길동', + 'status' => 'sent', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + BizppurioDispatch::create(array_merge([ + 'refkey' => 'rk_'.uniqid(), + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => '본문', + 'notification_type' => 'welcome', + 'notification_log_id' => $log->id, + 'status' => 'success', + 'result_code' => '4100', + 'source' => 'auto', + 'sent_at' => now(), + ], $dispatchAttrs)); + + return (int) $log->id; + } + + private const RECENT_URL = '/api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent'; + + public function test_인증_없이_조회는_401이다(): void + { + $this->postJson(self::URL, ['notification_log_ids' => [1]])->assertStatus(401); + } + + public function test_recent_는_view_권한_없으면_403이다(): void + { + $user = $this->adminWith([]); + + $this->getJson(self::RECENT_URL, $this->authHeaders($user))->assertStatus(403); + } + + public function test_recent_는_파라미터_없이_최근_결과맵을_반환한다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + $logId = $this->seedLinkedLog(['result_code' => '4100']); + + $result = $this->getJson(self::RECENT_URL, $this->authHeaders($user)) + ->assertOk() + ->json("data.results.{$logId}"); + + $this->assertSame('success', $result['status']); + $this->assertSame('4100', $result['result_code']); + } + + public function test_view_권한_없으면_403이다(): void + { + $user = $this->adminWith([]); // 권한 없음 + $logId = $this->seedLinkedLog(); + + $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user)) + ->assertStatus(403); + } + + public function test_로그_id_키가_아예_없으면_422다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $this->postJson(self::URL, [], $this->authHeaders($user)) + ->assertStatus(422) + ->assertJsonValidationErrors(['notification_log_ids']); + } + + public function test_빈_배열은_200과_빈_결과맵을_반환한다(): void + { + // 코어 알림 발송 이력이 0건(또는 아직 미로드)일 때 화면은 빈 배열을 보낸다. + // 이 경우 422 가 아니라 200 + 빈 결과 맵이어야 화면이 깨지지 않는다(회귀: 브라우저 실측). + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $response = $this->postJson(self::URL, ['notification_log_ids' => []], $this->authHeaders($user)) + ->assertOk(); + + $this->assertSame([], $response->json('data.results')); + } + + public function test_성공_결과를_로그id_키_맵으로_반환한다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + $logId = $this->seedLinkedLog(); + + $response = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user)) + ->assertOk(); + + $result = $response->json("data.results.{$logId}"); + $this->assertSame('success', $result['status']); + $this->assertSame('4100', $result['result_code']); + $this->assertFalse($result['is_low_balance']); + } + + public function test_실패_음영지역은_사유_코드_라벨을_반환한다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + $logId = $this->seedLinkedLog(['status' => 'failed', 'result_code' => '4400']); + + $result = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user)) + ->assertOk() + ->json("data.results.{$logId}"); + + // result_label 은 `사유 (코드)` 형식 — result_codes lang 에 4400 정의가 있으면 사유가 붙는다. + $this->assertSame('failed', $result['status']); + $this->assertStringContainsString('4400', (string) $result['result_label']); + } + + public function test_잔액부족_코드는_is_low_balance_true다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + $logId = $this->seedLinkedLog(['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']); + + $result = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user)) + ->assertOk() + ->json("data.results.{$logId}"); + + $this->assertTrue($result['is_low_balance']); + } + + public function test_매칭되지_않는_로그id는_결과맵에서_빠진다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + $linkedId = $this->seedLinkedLog(); + + // 연결되지 않은 코어 로그(메일 등) — dispatch 없음 + $mailLog = NotificationLog::create([ + 'channel' => 'mail', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => 'a@b.com', + 'status' => 'sent', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + $results = $this->postJson( + self::URL, + ['notification_log_ids' => [$linkedId, $mailLog->id]], + $this->authHeaders($user) + )->assertOk()->json('data.results'); + + $this->assertArrayHasKey((string) $linkedId, $results); + $this->assertArrayNotHasKey((string) $mailLog->id, $results); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php new file mode 100644 index 00000000..626e5752 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php @@ -0,0 +1,159 @@ + $config) { + HookManager::addAction( + $hook, + fn (...$args) => app(LinkNotificationLogListener::class)->{$config['method']}(...$args), + $config['priority'] ?? 10, + ); + } + } + + /** + * 발송 사이클을 흉내내 pending dispatch 를 만들고 refkey 를 컨텍스트에 남깁니다. + * + * 채널 드라이버 send() 의 dispatch 생성 + linkContext->remember() 와 동일한 상태를 만든다. + * + * @param string $channel 발송 채널 + * @return string 부여한 refkey + */ + private function beginDispatchCycle(string $channel = 'sms'): string + { + $refkey = 'rk_'.uniqid(); + + BizppurioDispatch::create([ + 'refkey' => $refkey, + 'channel' => $channel, + 'to_number' => '01011112222', + 'content' => '본문', + 'notification_type' => 'welcome', + 'status' => 'pending', + 'source' => 'auto', + 'sent_at' => now(), + ]); + + // 발송 사이클과 동일한 싱글턴 인스턴스에 refkey 를 남긴다. + app(DispatchLinkContext::class)->remember($refkey); + + return $refkey; + } + + public function test_발송_성공_로그가_dispatch에_연결된다(): void + { + $refkey = $this->beginDispatchCycle(); + + // 코어 로그 서비스로 실제 로그 생성 → after_log_sent 훅 발화 → 우리 리스너가 연결 + $log = app(NotificationLogService::class)->logSent([ + 'channel' => 'sms', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => '01011112222', + 'recipient_name' => '홍길동', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + $dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first(); + $this->assertNotNull($dispatch); + $this->assertSame((int) $log->id, (int) $dispatch->notification_log_id); + } + + public function test_발송_실패_로그도_dispatch에_연결된다(): void + { + $refkey = $this->beginDispatchCycle('alimtalk'); + + $log = app(NotificationLogService::class)->logFailed([ + 'channel' => 'alimtalk', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => '01011112222', + 'error_message' => 'boom', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + $dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first(); + $this->assertSame((int) $log->id, (int) $dispatch->notification_log_id); + } + + public function test_비_비즈뿌리오_로그는_연결되지_않는다(): void + { + // remember() 를 부르지 않은 상태(mail/database 등 비-비즈뿌리오 발송 사이클) + $log = app(NotificationLogService::class)->logSent([ + 'channel' => 'mail', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => 'a@b.com', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + // 어떤 dispatch 도 이 로그에 연결되지 않아야 한다. + $linked = BizppurioDispatch::query()->where('notification_log_id', $log->id)->exists(); + $this->assertFalse($linked); + } + + public function test_한_refkey는_한_로그에만_연결된다_consume_후_비워짐(): void + { + $refkey = $this->beginDispatchCycle(); + + // 첫 로그 → 연결됨 + $first = app(NotificationLogService::class)->logSent([ + 'channel' => 'sms', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => '01011112222', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + // 같은 사이클에서 refkey 를 다시 남기지 않은 채 둘째 로그 발생(다른 채널) → 연결 안 됨 + $second = app(NotificationLogService::class)->logSent([ + 'channel' => 'database', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => '01011112222', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + $dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first(); + $this->assertSame((int) $first->id, (int) $dispatch->notification_log_id); + $this->assertFalse( + BizppurioDispatch::query()->where('notification_log_id', $second->id)->exists() + ); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php new file mode 100644 index 00000000..dc8a9408 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php @@ -0,0 +1,228 @@ +getSettingsSchema() 를 사용) + $manager = app(PluginManager::class); + $ref = new \ReflectionClass($manager); + $prop = $ref->getProperty('plugins'); + $prop->setAccessible(true); + $plugins = $prop->getValue($manager); + $plugins[self::IDENTIFIER] = new Plugin; + $prop->setValue($manager, $plugins); + + // 검증 규칙 필터 훅 등록 (실제 훅 체인 재현 — mock 없음). + $listener = new ValidateBizppurioSettingsListener; + HookManager::addFilter( + 'core.plugin_settings.update_rules', + [$listener, 'addLiveModeRules'], + 10 + ); + + $this->admin = $this->createAdminUser(); + $this->token = $this->admin->createToken('test-token')->plainTextToken; + } + + /** + * core.plugins.update 권한을 가진 admin 사용자 생성. + */ + private function createAdminUser(): User + { + $user = User::factory()->create(); + + $permission = Permission::firstOrCreate( + ['identifier' => 'core.plugins.update'], + ['name' => json_encode(['ko' => '플러그인 수정', 'en' => 'Update Plugins']), 'type' => 'admin'] + ); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $testRole = Role::create([ + 'identifier' => 'bizppurio_settings_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + $testRole->permissions()->sync([$permission->id]); + + $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(); + } + + /** + * 인증 헤더가 적용된 PUT 요청 헬퍼. + * + * @param array $body + * @return TestResponse + */ + private function putSettings(array $body) + { + return $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Accept' => 'application/json', + ])->putJson('/api/admin/plugins/'.self::IDENTIFIER.'/settings', $body); + } + + /** + * @scenario test_mode=off,credentials=empty + * + * @effects live_mode_with_empty_credentials_returns_422_with_bizppurio_id_password_sender_number_errors + */ + public function test_live_mode_with_empty_credentials_is_rejected(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => '', + 'password' => '', + 'sender_number' => '', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['bizppurio_id', 'password', 'sender_number']); + } + + /** + * @scenario test_mode=off,credentials=empty + * + * @effects api_key_and_sender_key_are_not_required_in_live_mode + */ + public function test_live_mode_does_not_require_api_key_or_sender_key(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => 'biz-account', + 'password' => 'secret', + 'sender_number' => '021234567', + 'api_key' => '', + 'sender_key' => '', + ]); + + // api_key / sender_key 는 문자 발송 필수 조건이 아니므로 빈 값이어도 통과해야 한다. + $response->assertStatus(200); + } + + /** + * @scenario test_mode=off,credentials=empty,locale=ko + * + * @effects validation_error_messages_use_korean_field_labels_not_english_keys + */ + public function test_validation_error_messages_use_korean_field_labels(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => '', + 'password' => '', + 'sender_number' => '', + ]); + + $response->assertStatus(422); + + $errors = $response->json('errors'); + // 코어 검증기 수정 없이 리스너의 Lang::addLines 로 주입한 한국어 라벨이 노출되어야 한다. + $this->assertStringContainsString('비즈뿌리오 아이디', $errors['bizppurio_id'][0]); + $this->assertStringNotContainsString('bizppurio id', $errors['bizppurio_id'][0]); + $this->assertStringContainsString('발신번호', $errors['sender_number'][0]); + } + + /** + * @scenario test_mode=off,credentials=filled,locale=ko + * + * @effects live_mode_with_filled_credentials_returns_200 + */ + public function test_live_mode_with_filled_credentials_is_accepted(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => 'biz-account', + 'password' => 'secret', + 'sender_number' => '021234567', + ]); + + $response->assertStatus(200); + } + + /** + * @scenario test_mode=on,credentials=empty,locale=ko + * + * @effects dev_mode_with_empty_credentials_returns_200 + */ + public function test_dev_mode_with_empty_credentials_is_accepted(): void + { + $response = $this->putSettings([ + 'is_test_mode' => true, + 'bizppurio_id' => '', + 'password' => '', + 'sender_number' => '', + ]); + + $response->assertStatus(200); + } + + /** + * @scenario test_mode=off,credentials=filled + * + * @effects password_is_not_stored_as_plaintext_in_settings_file, password_decrypts_back_to_original_via_settings_service_get + */ + public function test_password_is_stored_encrypted(): void + { + $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => 'biz-account', + 'password' => 'super-secret', + 'sender_number' => '021234567', + ])->assertStatus(200); + + // 저장 파일에 평문이 남지 않아야 한다 (sensitive 암호화). + // PluginTestCase 가 'plugins' 디스크를 테스트 전용 경로로 격리(#458)하므로 실제 + // storage_path 가 아니라 격리된 디스크를 통해 파일을 조회한다. + $relativePath = self::IDENTIFIER.'/settings/setting.json'; + $this->assertTrue( + \Illuminate\Support\Facades\Storage::disk('plugins')->exists($relativePath), + '설정 파일이 격리된 plugins 디스크에 저장되어야 한다.' + ); + $raw = \Illuminate\Support\Facades\Storage::disk('plugins')->get($relativePath); + $this->assertStringNotContainsString('super-secret', $raw); + + // 복호화 왕복은 원문과 일치해야 한다. + $value = app(PluginSettingsService::class)->get(self::IDENTIFIER, 'password'); + $this->assertSame('super-secret', $value); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php new file mode 100644 index 00000000..289ba568 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php @@ -0,0 +1,84 @@ +create(); + + $permission = Permission::firstOrCreate( + ['identifier' => 'core.plugins.read'], + ['name' => json_encode(['ko' => '플러그인 조회', 'en' => 'Read Plugins']), 'type' => 'admin'] + ); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $testRole = Role::create([ + 'identifier' => 'bizppurio_report_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + $testRole->permissions()->sync([$permission->id]); + + $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(); + } + + /** + * @scenario auth=admin,app_url=set + * + * @effects report_url_returns_absolute_url_built_from_app_url + */ + public function test_returns_absolute_report_url_based_on_app_url(): void + { + config(['app.url' => 'https://shop.example.com']); + + $admin = $this->createAdminUser(); + $token = $admin->createToken('test-token')->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json', + ])->getJson(self::ENDPOINT); + + $response->assertStatus(200); + $response->assertJsonPath('data.url', 'https://shop.example.com/api/plugins/sirsoft-message_bizppurio/webhook'); + } + + /** + * @scenario auth=guest + * + * @effects report_url_requires_authentication_returns_401 + */ + public function test_requires_authentication(): void + { + $response = $this->getJson(self::ENDPOINT); + + $response->assertStatus(401); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php new file mode 100644 index 00000000..6afa6f17 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php @@ -0,0 +1,180 @@ + $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_token_check_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(); + } + + /** + * 사용자 토큰으로 요청 헤더를 만듭니다. + * + * @return array + */ + private function authHeaders(User $user): array + { + return [ + 'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken, + 'Accept' => 'application/json', + ]; + } + + /** + * 비즈뿌리오 아이디/비밀번호를 저장합니다. + */ + private function storeCredentials(string $id = 'biz-account', string $password = 'secret'): void + { + app(PluginSettingsService::class)->save(self::IDENTIFIER, [ + 'bizppurio_id' => $id, + 'password' => $password, + 'is_test_mode' => true, + ]); + } + + public function test_인증_없이_요청하면_401이다(): void + { + $this->postJson(self::ENDPOINT)->assertStatus(401); + } + + public function test_manage_권한_없이_요청하면_403이다(): void + { + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT); + + $response->assertStatus(403); + } + + public function test_자격증명이_유효하면_200을_반환한다(): void + { + Http::fake([ + '*/v1/token' => Http::response(['accesstoken' => 'VALID_TOKEN', 'type' => 'Bearer'], 200), + ]); + + $this->storeCredentials(); + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT); + + $response->assertStatus(200); + $response->assertJson(['success' => true]); + } + + public function test_자격증명이_유효하면_캐시가_새_토큰으로_갱신된다(): void + { + Http::fake([ + '*/v1/token' => Http::response(['accesstoken' => 'FRESH_TOKEN', 'type' => 'Bearer'], 200), + ]); + + $this->storeCredentials(); + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']); + + // BizppurioTokenService 가 contextual binding 으로 받는 것과 동일한 캐시 + // (식별자 네임스페이스 + 기본 캐시 스토어) — 전역 app(CacheInterface::class) 는 + // 코어 네임스페이스라 별개 저장 공간이므로 사용할 수 없다. + $cache = new PluginCacheDriver(self::IDENTIFIER, config('cache.default')); + $cache->flush(); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT); + + $response->assertStatus(200); + $this->assertSame('FRESH_TOKEN', $cache->get('bizppurio:token')); + } + + public function test_비즈뿌리오_서버에_연결할_수_없으면_500_대신_422를_반환한다(): void + { + Http::fake(['*/v1/token' => fn () => throw new ConnectionException('Connection refused')]); + + $this->storeCredentials(); + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT); + + $response->assertStatus(422); + } + + public function test_비즈뿌리오가_실패_사유를_반환하면_422와_함께_원문_사유가_노출된다(): void + { + Http::fake([ + '*/v1/token' => Http::response(['code' => '3007', 'description' => 'invalid password in bizppurio'], 401), + ]); + + $this->storeCredentials(); + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT); + + $response->assertStatus(422); + $response->assertJsonPath('errors.result_code', '3007'); + // 원문 사유는 메시지 키 자리가 아니라 관리자 전용 errors 페이로드로 노출된다 + // (예외→응답 매핑 규정 — GenericCatchStatusCodeContractTest 가 키 자리 원문을 차단). + $this->assertStringContainsString('invalid password in bizppurio', $response->json('errors.bizppurio_message')); + $this->assertStringNotContainsString('invalid password in bizppurio', (string) $response->json('message')); + } + + public function test_자격증명_미설정시_422를_반환한다(): void + { + $this->storeCredentials(id: '', password: ''); + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT); + + $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..a04de131 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php @@ -0,0 +1,1101 @@ + $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 + } + + /** + * 검수자 전달 의견(comment)은 선택 입력이며 kapi request 로만 전달된다 (#597 §18.7 — 제품 결정 2026-08-23). + * + * 500자 초과는 FormRequest 가 kapi 호출 전에 422 로 끊고(행 상태 불변), 유효한 의견은 + * `template/request` 의 comment 로 원문 그대로 실린다. + * + * @effects inspection_request_carries_reviewer_comment + */ + public function test_검수_신청의_comment는_kapi_request에_실리고_500자를_넘으면_422다(): 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', ['comment' => str_repeat('가', 501)], $this->authHeaders($user)) + ->assertStatus(422) + ->assertJsonValidationErrors('comment'); + $this->assertSame(BizppurioTemplateStatus::Draft, $template->fresh()->status, '검증 실패는 kapi 호출 전에 끝나야 한다(선점 없음).'); + Http::assertNothingSent(); + + $this->postJson(self::BASE.'/'.$template->id.'/request', ['comment' => '변수 예시: #{name}=홍길동'], $this->authHeaders($user)) + ->assertOk() + ->assertJsonPath('data.template.status', 'requested'); + + Http::assertSent(function ($request) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/request') { + return false; + } + $this->assertSame('변수 예시: #{name}=홍길동', $request->data()['comment'] ?? null); + + return true; + }); + } + + /** + * @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/Feature/Webhook/BizppurioWebhookTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php new file mode 100644 index 00000000..9d50d5d4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php @@ -0,0 +1,319 @@ +|null $requestPayload 발송 시점 요청 payload (resend 포함 여부 검증용) + * @return BizppurioDispatch + */ + private function seedDispatch(string $refkey, string $channel = 'sms', ?array $requestPayload = null): BizppurioDispatch + { + return BizppurioDispatch::create([ + 'refkey' => $refkey, + 'channel' => $channel, + 'to_number' => '01011112222', + 'content' => '테스트 본문', + 'notification_type' => 'welcome', + 'status' => DispatchStatus::Sent->value, + 'source' => 'auto', + 'request_payload' => $requestPayload, + 'sent_at' => now(), + ]); + } + + /** + * 허용 IP 로 유효 리포트를 POST 하면 200 + 상태가 success 로 갱신. + * + * @scenario source=webhook,ip=allowed,result=success + * + * @effects dispatch_status_updated_to_success,reported_at_set + */ + public function test_allowed_ip_success_report_updates_status(): void + { + $dispatch = $this->seedDispatch('ref_success'); + + $response = $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, [ + 'REFKEY' => 'ref_success', + 'RESULT' => '4100', + 'MEDIA' => 'SMS', + ]); + + $response->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Success, $dispatch->status); + $this->assertSame('4100', $dispatch->result_code); + $this->assertSame('SMS', $dispatch->media); + $this->assertNotNull($dispatch->reported_at); + } + + /** + * 실패 결과코드는 status=failed 로 갱신. + * + * @scenario source=webhook,ip=allowed,result=failure + * + * @effects dispatch_status_updated_to_failed + */ + public function test_allowed_ip_failure_report_marks_failed(): void + { + $dispatch = $this->seedDispatch('ref_fail'); + + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_fail', 'RESULT' => '4400']) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertSame('4400', $dispatch->result_code); + } + + /** + * 차단 IP 는 403 + 이력 미변경. + * + * @scenario source=webhook,ip=blocked + * + * @effects webhook_blocked_ip_returns_403,dispatch_unchanged + */ + public function test_blocked_ip_returns_403(): void + { + $dispatch = $this->seedDispatch('ref_blocked'); + + $this->withServerVariables(['REMOTE_ADDR' => self::BLOCKED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_blocked', 'RESULT' => '4100']) + ->assertStatus(403); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Sent, $dispatch->status); + $this->assertNull($dispatch->reported_at); + } + + /** + * 미매칭 refkey(위조)는 200 흡수 + 아무 이력도 변경 안 됨. + * + * @scenario source=webhook,ip=allowed,refkey=unknown + * + * @effects webhook_unknown_refkey_absorbed_200 + */ + public function test_unknown_refkey_absorbed_with_200(): void + { + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'nope', 'RESULT' => '4100']) + ->assertStatus(200); + + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } + + /** + * replay(이미 reported_at 있는 이력)는 멱등 — 상태를 다시 뒤집지 않음. + * + * @scenario source=webhook,ip=allowed,replay=true + * + * @effects webhook_replay_is_idempotent + */ + public function test_replay_report_is_idempotent(): void + { + $dispatch = $this->seedDispatch('ref_replay'); + $dispatch->update([ + 'status' => DispatchStatus::Success->value, + 'result_code' => '4100', + 'reported_at' => now()->subMinute(), + ]); + $firstReportedAt = $dispatch->fresh()->reported_at; + + // 두 번째(중복) 리포트가 실패 코드로 와도 성공 상태를 유지해야 함 + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_replay', 'RESULT' => '4400']) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Success, $dispatch->status); + $this->assertSame('4100', $dispatch->result_code); + $this->assertEquals($firstReportedAt->timestamp, $dispatch->reported_at->timestamp); + } + + /** + * 잔액부족(9070 문자)은 failed + 잔액부족 자체알림 훅 1회 발화. + * + * @scenario source=webhook,ip=allowed,result=balance_low_sms + * + * @effects webhook_balance_low_marks_failed,balance_low_hook_fired + */ + public function test_balance_low_sms_marks_failed_and_fires_hook(): void + { + $dispatch = $this->seedDispatch('ref_balance', DispatchChannel::Sms->value); + + $fired = []; + HookManager::addAction('sirsoft-message_bizppurio.balance.low', function (...$args) use (&$fired) { + $fired[] = $args; + }); + + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_balance', 'RESULT' => '9070']) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertCount(1, $fired, '잔액부족 훅은 1회만 발화해야 함'); + $this->assertSame(['9070', 'sms'], $fired[0]); + } + + /** + * 잔액부족 알림은 쿨다운 동안 채널별 1회만 발송된다 (D3 중복 방지). + * + * 서로 다른 발송건이 연속으로 9070(문자 잔액부족)을 받아도, 쿨다운 내에서는 + * 관리자 알림이 1회만 발화한다. 단, 발송 이력의 실패 기록은 두 건 모두 남는다. + * + * @scenario source=webhook,ip=allowed,result=balance_low_sms,repeat=true + * + * @effects balance_low_notification_deduplicated_within_cooldown,both_dispatches_marked_failed + */ + public function test_balance_low_notification_is_deduplicated_within_cooldown(): void + { + $first = $this->seedDispatch('ref_bal_1', DispatchChannel::Sms->value); + $second = $this->seedDispatch('ref_bal_2', DispatchChannel::Sms->value); + + $fired = []; + HookManager::addAction('sirsoft-message_bizppurio.balance.low', function (...$args) use (&$fired) { + $fired[] = $args; + }); + + // 두 발송건이 각각 9070(문자 잔액부족) 리포트를 받음 + foreach (['ref_bal_1', 'ref_bal_2'] as $refkey) { + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => $refkey, 'RESULT' => '9070']) + ->assertStatus(200); + } + + // 알림은 1회만 (쿨다운 중복 방지) + $this->assertCount(1, $fired, '쿨다운 동안 잔액부족 알림은 1회만 발화해야 함'); + + // 그러나 실패 이력은 두 건 모두 기록됨 + $this->assertSame(DispatchStatus::Failed, $first->fresh()->status); + $this->assertSame(DispatchStatus::Failed, $second->fresh()->status); + $this->assertSame('9070', $first->fresh()->result_code); + $this->assertSame('9070', $second->fresh()->result_code); + } + + /** + * webhook 은 코어 토큰/IDV 미들웨어 제외 — 비인증 실 POST 가 401 이 아닌 200. + * + * @scenario source=webhook,ip=allowed,auth=none + * + * @effects webhook_bypasses_auth_middleware_returns_200 + */ + public function test_webhook_bypasses_auth_middleware(): void + { + $this->seedDispatch('ref_noauth'); + + // Authorization 헤더 없이(비인증) POST — 미들웨어 제외로 200 이어야 함 + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_noauth', 'RESULT' => '4100']) + ->assertStatus(200); + } + + /** + * 대체발송(SMS 대체발송)을 요청하지 않은 발송건(request_payload 에 resend 없음)은, + * webhook 응답에 TELRES/KAORES 값이 채워져 와도 fallback_status 가 null 로 남아야 한다. + * + * 비즈뿌리오는 대체발송 미요청 건에도 TELRES="0" 같은 값을 채워 보내므로(실서버 관측: + * alimtalk 대체발송 OFF 인데 결과 화면에 SMS 대체발송 뱃지가 표시된 회귀), webhook 값 + * 존재 여부만으로 판단하면 안 되고 우리가 실제로 요청했는지(request_payload)를 봐야 한다. + * + * @scenario source=webhook,ip=allowed,result=success,fallback_requested=false + * + * @effects fallback_status_remains_null_when_not_requested + */ + public function test_fallback_status_stays_null_when_resend_was_not_requested(): void + { + $dispatch = $this->seedDispatch( + 'ref_no_resend_requested', + DispatchChannel::Alimtalk->value, + ['account' => 'sirsoft', 'content' => ['at' => ['templatecode' => 'tpl_1']]], + ); + + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, [ + 'REFKEY' => 'ref_no_resend_requested', + 'RESULT' => '7000', + 'MEDIA' => 'KAT', + 'TELRES' => '0', + 'KAORES' => '0', + ]) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Success, $dispatch->status); + $this->assertNull($dispatch->fallback_status); + } + + /** + * 대체발송을 실제로 요청한 발송건(request_payload 에 resend 있음)은 webhook TELRES 값을 + * fallback_status 에 정상 반영한다. + * + * @scenario source=webhook,ip=allowed,result=balance_low,fallback_requested=true + * + * @effects fallback_status_set_when_resend_was_requested + */ + public function test_fallback_status_set_when_resend_was_requested(): void + { + $dispatch = $this->seedDispatch( + 'ref_resend_requested', + DispatchChannel::Alimtalk->value, + [ + 'account' => 'sirsoft', + 'content' => ['at' => ['templatecode' => 'tpl_1']], + 'resend' => ['first' => 'sms'], + ], + ); + + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, [ + 'REFKEY' => 'ref_resend_requested', + 'RESULT' => '7436', + 'MEDIA' => 'KAT', + 'TELRES' => '4100', + ]) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame('4100', $dispatch->fallback_status); + } +} 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..55b76a89 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts @@ -0,0 +1,851 @@ +/** + * E2E 정밀 점검 매트릭스: 비즈뿌리오 알림 템플릿 라이프사이클 (#597 §6.3) + * + * @scenario bizppurio_tab_integration_e2e, bizppurio_edit_modal_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_summary_only, approval_badge_two_tier, edit_modal_hosts_alimtalk_and_sms_sections, + * core_modal_hides_editor_for_hidden_template_editor_channel, unified_save_chain_alimtalk_then_core_recipients, + * 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, inspection_request_carries_reviewer_comment + * + * 계획서 §6.3 이 요구한 7대 축(T1~T7) + 선택 축(T8~T10) + 회귀 축(R1~R6)을 브라우저에서 실측한다. + * 제품 결정(2026-08-23, 계획서 §18)으로 알림톡 작성·SMS 본문 편집은 행 하단 버튼/별도 모달이 아니라 + * 코어 [편집] 모달 안의 섹션으로 통합됐다 — 모달 진입은 언제나 행의 [편집] 이고, 저장은 하단 [저장] 하나다. + * 이 매트릭스는 라운드 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'); +} + +/** 편집 모달 푸터의 통합 [저장](코어 [저장]은 hidden_template_editor 게이트로 숨는다 — 하나만 렌더된다) */ +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}`, + }; + // 카테고리 코드는 kapi 실조회 목록에 있는 값이어야 한다 — 편집 모달의 카테고리 셀렉트는 + // 목록에 없는 값을 빈 값으로 정규화하므로(실측: 더미 코드 '001001' 이 실자격증명 환경에서 + // 비워져 저장이 422 "카테고리 필수"), 목록이 있으면 첫 코드를, 없으면(자격증명 미설정) 더미를 쓴다. + const catRes = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories', { headers }); + const catJson = catRes.ok ? await catRes.json() : null; + const firstCategory = catJson?.data?.categories?.[0]?.code; + const content = { + templateName: '매트릭스 측정용', + templateMessageType: 'BA', + templateEmphasizeType: 'NONE', + templateContent: '초기 본문 #{site_name}', + categoryCode: typeof firstCategory === 'string' && firstCategory ? firstCategory : '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'); + + // 유효 자격증명 환경에서는 10c 의 [저장 후 검수 신청]이 실제로 성립해 행이 requested 로 남는다 — + // 다음 실행이 같은 행을 다시 쓰려면 신청을 취소해 draft 로 되돌린다(kapi cancel_request 실호출). + const requested = rows.find((r: { notification_type?: string; status?: string }) => + r.notification_type === wantedType && r.status === 'requested'); + if (!existing && requested) { + await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${requested.id}/cancel-request`, { method: 'POST', headers }); + await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${requested.id}`, { + method: 'PUT', headers, body: JSON.stringify({ content }), + }); + + return requested.notification_type as string; + } + + 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); +} + +/** + * 알림 행의 코어 [편집] 버튼(그 행 카드 안의 것)을 찾는다. + * + * 행 카드는 유형 코드(welcome 등)를 font-mono 텍스트로 그린다 — 그 텍스트에서 가장 가까운 + * "[편집] 버튼을 품은 조상" 이 행 카드다. 목록 순서 의존(first())을 없애 테스트 간 간섭을 막는다. + */ +function rowEditButton(page: Page, type: string) { + return page.getByText(type, { exact: true }) + .locator('xpath=ancestor::*[.//button[normalize-space()="편집"]][1]') + .getByRole('button', { name: '편집', exact: true }) + .first(); +} + +/** + * 알림 행의 코어 [편집] 모달을 열고 플러그인 섹션 루트(알림톡 템플릿 + 문자 SMS)를 돌려준다. + * + * @param page 페이지 + * @param type 알림 유형 코드(없으면 첫 행) + */ +async function openEditModal(page: Page, type?: string) { + await page.goto(SETTINGS_URL); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const edit = type ? rowEditButton(page, type) : page.getByRole('button', { name: '편집', exact: true }).first(); + await expect(edit).toBeVisible({ timeout: 20_000 }); + await edit.click(); + const modal = dialog(page).locator('#bizppurio_tpl_sections'); + await expect(modal).toBeVisible({ timeout: 15_000 }); + // 작성 폼은 draft/rejected 에서만 열린다 — 시드 행은 draft 이므로 본문 textarea 가 보여야 한다 + await expect(modal.locator('textarea[name="bz_template_content"]')).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); + + // 행 하단은 상태 요약만 — 버튼 0 + 승인 2단 배지(승인됨 | 미승인 (세부)) + const footerButtons = await rows.locator('button').count(); + const badges = (await rows.allInnerTexts()).map((t) => (t.match(/승인됨|미승인\s*\([^)]+\)/) ?? [''])[0]); + evidence('1b 행 하단 버튼 수 / 배지', { footerButtons, badges }); + expect(footerButtons).toBe(0); + expect(badges.every((b) => b.length > 0)).toBe(true); + + 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 seedTemplate(page, 'welcome'); + const modal = await openEditModal(page, 'welcome'); + + // 2c: 코어 제목/본문 입력은 숨고(hidden_template_editor) 플러그인 섹션 2종이 그 자리를 채운다 + const coreHidden = { + subjectInputs: await dialog(page).locator('#template_subject_input').count(), + bodyInputs: await dialog(page).locator('#template_body_input').count(), + previewButtons: await dialog(page).getByRole('button', { name: '미리보기', exact: true }).count(), + alimtalkSection: await dialog(page).locator('#bizppurio_tpl_alimtalk_section').count(), + smsSection: await dialog(page).locator('#bizppurio_tpl_sms_section').count(), + saveButtons: await dialog(page).getByRole('button', { name: '저장', exact: true }).count(), + }; + evidence('2c 코어 입력 숨김 / 섹션 2종 / 저장 버튼 수', coreHidden); + expect(coreHidden.subjectInputs + coreHidden.bodyInputs + coreHidden.previewButtons).toBe(0); + expect(coreHidden.alimtalkSection).toBe(1); + expect(coreHidden.smsSection).toBe(1); + expect(coreHidden.saveButtons, '저장은 하나여야 한다(코어 저장은 숨김)').toBe(1); + + 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 seedTemplate(page, 'welcome'); + const modal = await openEditModal(page, 'welcome'); + + 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, 'reset_password'); + + 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 }) => { + // 유효 자격증명 환경에서는 kapi 실호출(add·request·cancel_request·delete) 4회 + 고정 대기 7초가 + // 한 테스트에 들어가므로 기본 30초를 넘긴다(실측 31.3초). 판정 축이 시간은 아니다. + test.setTimeout(90_000); + await authenticatePage(page, messagingManageToken); + await openBizppurioTab(page); + await seedTemplate(page, 'password_changed'); + const modal = await openEditModal(page, 'password_changed'); + 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, 'password_changed'); + await modal2.locator('textarea[name="bz_template_content"]').fill('중복 신청 테스트 #{site_name}'); + // 검수자 전달 의견(제품 결정 2026-08-23 §18.7) — 신청 요청 본문의 comment 로 실려야 한다. + const REVIEW_COMMENT = '변수 예시: #{site_name}=G7 데모 사이트'; + await modal2.locator('textarea[name="bz_request_comment"]').fill(REVIEW_COMMENT); + const requestBodies: string[] = []; + page.on('request', (r) => { + if (r.method() === 'POST' && /\/admin\/templates\/\d+\/request$/.test(r.url())) requestBodies.push(r.postData() ?? ''); + }); + 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); + evidence('10c 신청 comment 전달', { 본문수: requestBodies.length, comment일치: requestBodies.filter((b) => b.includes(REVIEW_COMMENT)).length }); + expect(requestBodies.length, '검수 신청 요청이 관측되지 않았다').toBeGreaterThan(0); + for (const body of requestBodies) expect(JSON.parse(body).comment, '신청 본문의 comment').toBe(REVIEW_COMMENT); + + // 정리: 유효 자격증명 환경에서는 신청이 실제로 성립한다 — 신청 취소 후 행을 지워 + // 카카오측에 측정용 템플릿이 남지 않게 한다(자격증명 미설정 환경에서는 둘 다 no-op 에 가깝다). + const cleanup = await 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 row = (list?.data?.templates ?? []).find((r: { notification_type?: string }) => r.notification_type === 'password_changed'); + if (!row) return { row: null }; + let cancel: number | null = null; + if (row.status === 'requested') { + cancel = (await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${row.id}/cancel-request`, { method: 'POST', headers })).status; + } + const del = (await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${row.id}`, { method: 'DELETE', headers })).status; + + return { row: row.id, status: row.status, cancel, del }; + }); + evidence('10c 정리(신청 취소 + 삭제)', cleanup); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +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, 'welcome'); + 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('#bizppurio_tpl_sections').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, 'welcome'); + 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: 모달에 값을 넣고 저장 없이 닫은 뒤 다시 열면 이전 입력이 남지 않는다(마운트 시 재시딩) + await seedTemplate(page, 'welcome'); + const modal = dialog(page).locator('#bizppurio_tpl_sections'); + await rowEditButton(page, 'welcome').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 rowEditButton(page, 'welcome').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('알림톡'); + } + + // 8c: 그 면의 코어 [편집] 모달에도 섹션 2종이 주입되고 코어 본문 입력은 숨는다(3면 공유 1본) + if (rows > 0) { + await page.getByRole('button', { name: '편집', exact: true }).first().click(); + const sections = dialog(page).locator('#bizppurio_tpl_sections'); + await expect(sections).toBeVisible({ timeout: 15_000 }); + const prefix = url.includes('/boards/') ? 'board_' : 'ecommerce_'; + const measured = { + alimtalk: await dialog(page).locator('#bizppurio_tpl_alimtalk_section').count(), + sms: await dialog(page).locator('#bizppurio_tpl_sms_section').count(), + coreBody: await dialog(page).locator(`#${prefix}template_body_input`).count(), + saveButtons: await dialog(page).getByRole('button', { name: '저장', exact: true }).count(), + }; + evidence(`8c ${face} 편집 모달 섹션`, measured); + expect(measured.alimtalk).toBe(1); + expect(measured.sms).toBe(1); + expect(measured.coreBody).toBe(0); + expect(measured.saveButtons).toBe(1); + await dialog(page).getByRole('button', { name: '취소', exact: true }).first().click(); + } + 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 seedTemplate(page, 'welcome'); + const modal = await openEditModal(page, 'welcome'); + 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..3b587486 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts @@ -0,0 +1,227 @@ +/** + * 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, + * manage_list_mobile_card_view_and_unified_toolbar + * + * 검증 대상(브라우저에서만 잡히는 축): + * 1. 코어 알림 설정 서브탭 통합 — '비즈뿌리오' 단일 탭 노출, 문자·알림톡 개별 탭 부재 + * (탭 필터 표현식 3면 수정의 소비처 실렌더) + * 2. 행 하단 라이프사이클 UI — 상태 배지 + 작성 버튼 + SMS 체크박스 렌더, i18n 원문 노출 0 + * 3. 작성 모달 — 강조 유형 전환(없음→강조표기→이미지→아이템리스트) 시 조건부 필드 등장/소멸 + * 4. 플러그인 관리 화면 — 상태 필터가 URL(bz_status)에 보존되는 목록 왕복(mergeQuery) + * + * 카카오 API(kapi) 실호출이 필요한 검수 신청·동기화 경로는 브라우저 E2E 범위 밖이다 + * (자격증명·실검수 필요 — PHPUnit 이 Http::fake 로 전수 가드, 실연동은 별도 수행 단계). + * + * 계획서 §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_summary_only, approval_badge_two_tier + test('알림 행 하단에 알림톡 승인 2단 배지와 SMS 설정 요약만 렌더된다(버튼 0)', 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 }); + + // 알림톡 라벨 + 승인 2단 배지(승인됨 | 미승인 (세부)) + SMS 설정 요약 — 버튼은 없다(조작은 [편집] 모달) + const firstRow = rows.first(); + await expect(firstRow.getByText('알림톡', { exact: false }).first()).toBeVisible(); + await expect(firstRow.getByText(/승인됨|미승인/).first()).toBeVisible(); + await expect(firstRow.getByText('대체 SMS', { exact: false }).first()).toBeVisible(); + await expect(firstRow.getByText('SMS 단독', { exact: false }).first()).toBeVisible(); + // SMS 사용 여부는 문구("대체 SMS: 미사용")가 아니라 배지(사용/미사용)로 렌더된다 (제품 결정 2026-08-23) + expect(await firstRow.locator('span.rounded', { hasText: /^(사용|미사용)$/ }).count()).toBeGreaterThanOrEqual(2); + expect(await firstRow.getByText(/대체 SMS:|SMS 단독:/).count()).toBe(0); + expect(await rows.locator('button').count()).toBe(0); + + // 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_edit_modal_e2e, bizppurio_compose_modal_e2e + // @effects edit_modal_hosts_alimtalk_and_sms_sections, core_modal_hides_editor_for_hidden_template_editor_channel, 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 }); + + // 행의 코어 [편집] → 같은 모달 안에 알림톡 템플릿·문자(SMS) 섹션이 주입된다(작성 폼은 draft/rejected/미작성 행에서 열린다) + const editButton = page.getByRole('button', { name: '편집', exact: true }).first(); + await expect(editButton).toBeVisible({ timeout: 30_000 }); + await editButton.click(); + + const dialog = page.getByRole('dialog'); + const modal = dialog.locator('#bizppurio_tpl_sections'); + await expect(modal).toBeVisible({ timeout: 15_000 }); + await expect(dialog.locator('#bizppurio_tpl_sms_section')).toBeVisible(); + // 코어 제목/본문 입력은 hidden_template_editor 게이트로 숨는다 — 본문 입력은 알림톡 섹션 하나뿐 + await expect(dialog.locator('#template_body_input')).toHaveCount(0); + await expect(dialog.getByRole('button', { name: '저장', exact: true })).toHaveCount(1); + + // 본문 textarea 는 상시 + await expect(modal.locator('textarea[name="bz_template_content"]')).toBeVisible(); + + // 검수자 전달 의견 입력란은 바로연결 블록 아래 한 줄을 통째로 차지한다 + // (회귀: 라벨+[바로연결 추가] flex 행 안에 끼어 라벨이 세로로 찌그러짐 — 화면 검수 지적 2026-08-23) + const commentBox = modal.locator('textarea[name="bz_request_comment"]'); + await expect(commentBox).toBeVisible(); + const [quickAddBox, commentBounds, bodyBox] = await Promise.all([ + modal.getByRole('button', { name: '바로연결 추가' }).boundingBox(), + commentBox.boundingBox(), + modal.locator('textarea[name="bz_template_content"]').boundingBox(), + ]); + expect(commentBounds!.y, '의견란은 [바로연결 추가] 아래에 있어야 한다').toBeGreaterThanOrEqual(quickAddBox!.y + quickAddBox!.height); + expect(Math.abs(commentBounds!.x - bodyBox!.x), '의견란은 본문 입력과 같은 왼쪽 선에 맞는다').toBeLessThan(4); + + // 없음(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 }); + + // 목록 SMS 열은 평문(단독/대체/-)이 아니라 상태 배지(단독·대체=초록, 미사용=회색) — 행 하단과 같은 규칙 (제품 결정 2026-08-23) + await expect(manageView.locator('span.rounded', { hasText: /^(단독|대체|미사용)$/ }).first()).toBeVisible({ timeout: 15_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.'); + }); + // @scenario bizppurio_manage_round_trip_e2e + // @effects manage_list_mobile_card_view_and_unified_toolbar + test('portable(375px) 에서 목록이 카드로 전환되고 도구줄 버튼이 btn 계열로 통일된다', 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'); + if (await readiness.isVisible().catch(() => false)) { + await expect(readiness.getByText('환경설정', { exact: false }).first()).toBeVisible(); + return; + } + await expect(manageView).toBeVisible({ timeout: 30_000 }); + + // 데스크톱: grid 헤더 노출 + 도구줄 버튼 계열 + await expect(manageView.locator('#bz_manage_list_header')).toBeVisible(); + const searchBtn = manageView.getByRole('button', { name: '검색', exact: true }); + await expect(searchBtn).toHaveClass(/btn-primary/); + // 행 액션에도 동명 [새로고침](행 동기화) 버튼이 있어 aria-label 로 도구줄 버튼만 집는다 + const refreshBtn = manageView.getByLabel('새로고침', { exact: true }); + await expect(refreshBtn).toHaveClass(/btn-icon/); + await expect(refreshBtn.locator('i.fa-arrows-rotate')).toBeVisible(); + + // portable 로 전환 → 헤더 숨김 + 카드 메타 줄(신청·동기화 합본) 노출 + await page.setViewportSize({ width: 375, height: 812 }); + await expect(manageView.locator('#bz_manage_list_header')).toBeHidden(); + const firstRowInner = manageView.locator('#bz_manage_list_rows > div > div').first(); + if (await firstRowInner.isVisible().catch(() => false)) { + await expect(firstRowInner.getByText(/동기화/).first()).toBeVisible(); + // 카드에서 별도 열(소속/신청일/동기화일 셀)은 숨는다 — grid 클래스가 아닌 flex 컨테이너 + const rowClass = (await firstRowInner.getAttribute('class')) ?? ''; + expect(rowClass).toContain('flex-wrap'); + expect(rowClass).not.toContain('grid-cols-12'); + } + await page.setViewportSize({ width: 1280, height: 800 }); + }); + +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php new file mode 100644 index 00000000..e957d824 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php @@ -0,0 +1,227 @@ +|null + */ + private ?array $hookListenerRegistrarSnapshot = null; + + /** 테스트 격리용 임시 plugins 스토리지 루트 (setUp 에서 생성, tearDown 에서 제거). */ + private ?string $isolatedPluginsRoot = null; + + /** + * 테스트 환경 설정 + */ + protected function setUp(): void + { + parent::setUp(); + + $this->isolatePluginStorage(); + $this->snapshotHookManager(); + $this->snapshotHookListenerRegistrar(); + $this->activateSelfForMiddlewareGate(); + } + + /** + * 자기 플러그인을 미들웨어 게이트 인덱스에 활성 등록한다. + * + * getMiddleware() self-gate 선언(webhook IP 화이트리스트)은 코어 게이트가 + * 활성 플러그인 registry 를 대조해 실행하므로, 테스트 환경에서도 plugins 테이블 + * 활성 행 + PluginManager 인스턴스 등록 + 인덱스 무효화가 있어야 매칭된다. + * 누락 시 게이트가 미들웨어를 부착하지 못해 차단 IP 도 통과한다. + */ + protected function activateSelfForMiddlewareGate(): void + { + Plugin::query()->updateOrCreate( + ['identifier' => 'sirsoft-message_bizppurio'], + [ + 'vendor' => 'sirsoft', + 'name' => json_encode(['ko' => 'sirsoft-message_bizppurio', 'en' => 'sirsoft-message_bizppurio']), + 'version' => '1.0.0', + 'status' => ExtensionStatus::Active->value, + ] + ); + PluginManager::invalidatePluginStatusCache(); + + $pluginManager = $this->app->make(PluginManager::class); + $property = new \ReflectionProperty($pluginManager, 'plugins'); + $property->setAccessible(true); + $plugins = $property->getValue($pluginManager); + $plugins['sirsoft-message_bizppurio'] = new \Plugins\Sirsoft\MessageBizppurio\Plugin; + $property->setValue($pluginManager, $plugins); + + ExtensionMiddlewareRegistry::flush(); + } + + /** + * tearDown 에 HookManager 상태 + plugins 스토리지 복원. + */ + protected function tearDown(): void + { + $this->restoreHookManager(); + $this->restoreHookListenerRegistrar(); + $this->restorePluginStorage(); + + parent::tearDown(); + } + + /** + * 플러그인 스토리지('plugins' 디스크)를 테스트 전용 임시 디렉토리로 격리한다. + * + * 설정 저장 테스트(PluginSettingsService::save)는 코어 'plugins' 디스크 + * (root = storage_path('app/plugins'))에 setting.json 을 쓴다. 격리하지 않으면 + * 테스트가 실제 로컬 런타임 설정 파일을 덮어써 검수 모드/자격증명이 오염된다 + * (RefreshDatabase 는 DB 만 롤백하고 파일시스템은 되돌리지 않음). 디스크 root 를 + * 임시 경로로 바꾸고 resolved 인스턴스를 purge 하여 실제 파일을 원천적으로 못 + * 건드리게 한다. (회귀 배경: #458) + */ + private function isolatePluginStorage(): void + { + // Laravel 이 테스트용 쓰기 공간으로 보장하는 storage/framework/testing 하위를 사용한다. + // sys_get_temp_dir() 은 CI/컨테이너/open_basedir 제약 환경에서 위치가 다르거나 + // 쓰기 불가일 수 있어 프로젝트 내부의 격리 경로를 쓴다 (Laravel 규약 준수). + $this->isolatedPluginsRoot = storage_path( + 'framework/testing/plugin-storage-'.uniqid('', true) + ); + + File::ensureDirectoryExists($this->isolatedPluginsRoot); + + config(['filesystems.disks.plugins.root' => $this->isolatedPluginsRoot]); + + // 이미 resolve 된 'plugins' 디스크 인스턴스를 폐기해 새 root 로 재생성되게 한다. + Storage::forgetDisk('plugins'); + } + + /** + * 격리 임시 디렉토리를 제거한다 (테스트 간 잔여 파일 격리). + */ + private function restorePluginStorage(): void + { + if ($this->isolatedPluginsRoot !== null && is_dir($this->isolatedPluginsRoot)) { + File::deleteDirectory($this->isolatedPluginsRoot); + } + + $this->isolatedPluginsRoot = null; + + Storage::forgetDisk('plugins'); + } + + /** + * HookManager static $hooks / $filters / $dispatching 를 스냅샷. + */ + private function snapshotHookManager(): void + { + $ref = new \ReflectionClass(HookManager::class); + $this->hookSnapshot = [ + 'hooks' => $ref->getProperty('hooks')->getValue(), + 'filters' => $ref->getProperty('filters')->getValue(), + 'dispatching' => $ref->getProperty('dispatching')->getValue(), + ]; + } + + /** + * 스냅샷 시점으로 HookManager 복원. + */ + private function restoreHookManager(): void + { + if ($this->hookSnapshot === null) { + return; + } + + $ref = new \ReflectionClass(HookManager::class); + $ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']); + $ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']); + $ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']); + + $this->hookSnapshot = null; + } + + /** + * HookListenerRegistrar static $registered 를 스냅샷. + */ + private function snapshotHookListenerRegistrar(): void + { + $ref = new \ReflectionClass(HookListenerRegistrar::class); + $prop = $ref->getProperty('registered'); + $this->hookListenerRegistrarSnapshot = $prop->getValue(); + } + + /** + * 스냅샷 시점으로 HookListenerRegistrar::$registered 를 복원. + */ + private function restoreHookListenerRegistrar(): void + { + if ($this->hookListenerRegistrarSnapshot === null) { + return; + } + + $ref = new \ReflectionClass(HookListenerRegistrar::class); + $ref->getProperty('registered')->setValue(null, $this->hookListenerRegistrarSnapshot); + + $this->hookListenerRegistrarSnapshot = null; + } + + /** + * 마이그레이션 경로를 반환합니다. + * + * RefreshDatabase 의 migrate:fresh 명령에 코어 + 번들 확장 마이그레이션 경로를 전달합니다. + * + * @return array + */ + protected function migrateFreshUsing(): array + { + $paths = ['database/migrations']; + foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { + $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p); + } + foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { + $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p); + } + + return [ + '--drop-views' => $this->shouldDropViews(), + '--drop-types' => $this->shouldDropTypes(), + '--seed' => false, + '--path' => $paths, + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/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/Enums/EnumTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php new file mode 100644 index 00000000..6d3718c9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php @@ -0,0 +1,64 @@ +assertEqualsCanonicalizing(['sms', 'lms', 'alimtalk'], DispatchChannel::values()); + $this->assertTrue(DispatchChannel::Sms->isText()); + $this->assertTrue(DispatchChannel::Lms->isText()); + $this->assertFalse(DispatchChannel::Alimtalk->isText()); + $this->assertNull(DispatchChannel::tryFrom('invalid')); + } + + public function test_dispatch_status_값과_final_판정(): void + { + $this->assertEqualsCanonicalizing(['pending', 'sent', 'success', 'failed'], DispatchStatus::values()); + $this->assertFalse(DispatchStatus::Pending->isFinal()); + $this->assertFalse(DispatchStatus::Sent->isFinal()); + $this->assertTrue(DispatchStatus::Success->isFinal()); + $this->assertTrue(DispatchStatus::Failed->isFinal()); + } + + public function test_dispatch_source_값(): void + { + $this->assertEqualsCanonicalizing(['auto', 'manual', 'bulk'], DispatchSource::values()); + $this->assertSame('auto', DispatchSource::Auto->value); + } + + public function test_result_category_분류_판정(): void + { + $this->assertEqualsCanonicalizing( + ['success', 'retry', 'permanent_failure', 'balance_low'], + ResultCategory::values() + ); + + $this->assertTrue(ResultCategory::Retry->isRetryable()); + $this->assertFalse(ResultCategory::Success->isRetryable()); + + $this->assertTrue(ResultCategory::PermanentFailure->isFailure()); + $this->assertTrue(ResultCategory::BalanceLow->isFailure()); + $this->assertFalse(ResultCategory::Success->isFailure()); + $this->assertFalse(ResultCategory::Retry->isFailure()); + } + + public function test_label은_다국어_문자열을_반환한다(): void + { + // label() 이 예외 없이 문자열을 반환하는지 (lang 키 해석 스모크) + $this->assertIsString(DispatchChannel::Sms->label()); + $this->assertIsString(DispatchStatus::Success->label()); + $this->assertIsString(DispatchSource::Auto->label()); + $this->assertIsString(ResultCategory::BalanceLow->label()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php new file mode 100644 index 00000000..975f797e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php @@ -0,0 +1,187 @@ + 'acct', 'type' => 'sms', 'from' => '070', 'to' => '010', + 'refkey' => 'ref1', 'content' => ['sms' => ['message' => 'hi']], + ]; + + /** + * sendMessage 응답을 반환하는 ApiClient mock 을 만듭니다. + * + * @param array $result sendMessage 반환값 + */ + private function makeClient(array $result): BizppurioApiClient + { + $mock = Mockery::mock(BizppurioApiClient::class); + $mock->shouldReceive('sendMessage')->with($this->payload)->andReturn($result); + $mock->shouldReceive('isSuccess') + ->andReturnUsing(fn ($r) => (string) ($r['code'] ?? '') === '1000'); + + return $mock; + } + + /** + * 컨테이너에서 실제 리포지토리(Provider 바인딩)를 해석해 반환. + */ + private function dispatches(): BizppurioDispatchRepositoryInterface + { + return app(BizppurioDispatchRepositoryInterface::class); + } + + /** + * refkey 로 pending 이력을 1건 seed. + */ + private function seedPending(string $refkey): BizppurioDispatch + { + return BizppurioDispatch::create([ + 'refkey' => $refkey, + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'hi', + 'status' => DispatchStatus::Pending->value, + 'source' => 'auto', + ]); + } + + public function test_성공시_예외없이_종료하고_이력을_sent로_갱신한다(): void + { + $dispatch = $this->seedPending('ref1'); + + $job = new SendMessageJob($this->payload, 'ref1'); + $job->handle($this->makeClient(['code' => 1000, 'messagekey' => 'mk']), $this->dispatches()); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Sent, $dispatch->status); + $this->assertSame('mk', $dispatch->messagekey); + } + + public function test_일시오류_결과코드는_예외를_던져_재시도한다(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + + $this->expectException(BizppurioApiException::class); + + try { + $job->handle($this->makeClient(['code' => 5003, 'description' => 'temp']), $this->dispatches()); + } catch (BizppurioApiException $e) { + $this->assertSame('5003', $e->getResultCode()); + throw $e; + } + } + + public function test_알림톡_일시오류_결과코드는_예외를_던져_재시도한다(): void + { + // 알림톡 일시오류(7306 카카오 시스템오류·7307 처리지연·7421 타임아웃·7437 요청실패)는 + // 다시 보내면 성공할 수 있으므로 예외를 던져 큐가 재시도해야 한다. + foreach (['7306', '7307', '7421', '7437'] as $code) { + $job = new SendMessageJob($this->payload, 'ref1'); + + try { + $job->handle($this->makeClient(['code' => (int) $code, 'description' => 'temp']), $this->dispatches()); + $this->fail("코드 {$code} 는 재시도(예외)여야 한다"); + } catch (BizppurioApiException $e) { + $this->assertSame($code, $e->getResultCode(), "코드 {$code}"); + } + } + } + + public function test_영구실패_결과코드는_예외없이_종료하고_이력을_failed로_갱신한다(): void + { + $dispatch = $this->seedPending('ref1'); + + // 3006(계정오류) = 영구실패 → 재시도 안 함(예외 없음) + $job = new SendMessageJob($this->payload, 'ref1'); + $job->handle($this->makeClient(['code' => 3006, 'description' => 'account error']), $this->dispatches()); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertSame('3006', $dispatch->result_code); + } + + public function test_tries와_backoff_기본값(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + + $this->assertSame(2, $job->tries); + $this->assertSame(2, $job->backoff()); + } + + public function test_after_commit_활성화(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + + $this->assertTrue($job->afterCommit); + } + + public function test_최종실패_콜백은_pending이력을_failed로_마감하고_사유를_기록한다(): void + { + // 타임아웃·연결실패는 결과코드 없는 ConnectionException 으로 재시도 소진 후 failed() 로 온다. + $dispatch = $this->seedPending('ref1'); + + $job = new SendMessageJob($this->payload, 'ref1'); + $job->failed(new ConnectionException('cURL error 28: Connection timed out')); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertNull($dispatch->result_code); // 전송 실패라 결과코드 없음 + $this->assertStringContainsString('timed out', (string) $dispatch->result_message); + } + + public function test_최종실패_콜백은_bizppurio_api_exception의_결과코드를_보존한다(): void + { + $dispatch = $this->seedPending('ref1'); + + $job = new SendMessageJob($this->payload, 'ref1'); + $job->failed(new BizppurioApiException('일시 오류', resultCode: '5003')); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertSame('5003', $dispatch->result_code); + } + + public function test_이미_확정된_이력은_최종실패_콜백이_덮어쓰지_않는다(): void + { + // webhook 이 먼저 success 로 확정한 뒤 failed() 가 늦게 불려도 멱등해야 한다. + $dispatch = $this->seedPending('ref1'); + $dispatch->update(['status' => DispatchStatus::Success->value, 'result_code' => '4000']); + + $job = new SendMessageJob($this->payload, 'ref1'); + $job->failed(new ConnectionException('timed out')); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Success, $dispatch->status); + $this->assertSame('4000', $dispatch->result_code); + } + + public function test_이력이_없으면_최종실패_콜백은_아무것도_하지_않는다(): void + { + // 이력 seed 없이 호출 — 예외 없이 조용히 종료해야 한다. + $job = new SendMessageJob($this->payload, 'missing-ref'); + $job->failed(new ConnectionException('timed out')); + + $this->assertDatabaseMissing('bizppurio_dispatches', ['refkey' => 'missing-ref']); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php new file mode 100644 index 00000000..eb099957 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php @@ -0,0 +1,127 @@ +listener = new BalanceLowNotificationDataListener; + } + + private function emptyResult(): array + { + return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []]; + } + + /** + * 문자(sms) 잔액부족: channel_label="문자", result_code 주입 + 사이트 메타 채움. + */ + public function test_injects_variables_for_sms_balance_low(): void + { + $out = $this->listener->injectBalanceLowData( + $this->emptyResult(), + 'bizppurio_balance_low', + ['9070', 'sms'], + ); + + $data = $out['data']; + $this->assertSame('9070', $data['result_code']); + $this->assertSame('문자', $data['channel_label']); + $this->assertSame('{recipient_name}', $data['name']); + $this->assertNotSame('', $data['app_name']); + $this->assertStringContainsString('/admin/plugins/sirsoft-message_bizppurio/settings', $data['settings_url']); + $this->assertArrayHasKey('site_url', $data); + } + + /** + * 알림톡(alimtalk) 잔액부족: channel_label="알림톡", result_code=7436. + */ + public function test_injects_variables_for_alimtalk_balance_low(): void + { + $out = $this->listener->injectBalanceLowData( + $this->emptyResult(), + 'bizppurio_balance_low', + ['7436', 'alimtalk'], + ); + + $this->assertSame('7436', $out['data']['result_code']); + $this->assertSame('알림톡', $out['data']['channel_label']); + } + + /** + * lms 도 문자군으로 "문자" 라벨. + */ + public function test_lms_maps_to_text_label(): void + { + $out = $this->listener->injectBalanceLowData( + $this->emptyResult(), + 'bizppurio_balance_low', + ['9071', 'lms'], + ); + + $this->assertSame('문자', $out['data']['channel_label']); + $this->assertSame('9071', $out['data']['result_code']); + } + + /** + * 다른 알림 유형은 원본을 그대로 통과(변수 미주입). + */ + public function test_ignores_other_notification_types(): void + { + $result = $this->emptyResult(); + $out = $this->listener->injectBalanceLowData($result, 'order_confirmed', ['9070', 'sms']); + + $this->assertSame($result, $out); + $this->assertArrayNotHasKey('result_code', $out['data']); + } + + /** + * 알 수 없는 채널은 원본 문자열을 라벨로 유지(치환 실패보다 원본 노출). + */ + public function test_unknown_channel_keeps_raw(): void + { + $out = $this->listener->injectBalanceLowData( + $this->emptyResult(), + 'bizppurio_balance_low', + ['9999', 'unknown'], + ); + + $this->assertSame('unknown', $out['data']['channel_label']); + } + + /** + * 주입된 data 로 알림 본문 템플릿의 변수가 실제 값으로 치환된다(엔드투엔드). + */ + public function test_injected_data_resolves_template_body(): void + { + $out = $this->listener->injectBalanceLowData( + $this->emptyResult(), + 'bizppurio_balance_low', + ['9071', 'alimtalk'], + ); + + $body = '비즈뿌리오 잔액 부족으로 {channel_label} 발송이 실패했습니다 (코드: {result_code}).'; + $rendered = strtr($body, [ + '{channel_label}' => $out['data']['channel_label'], + '{result_code}' => $out['data']['result_code'], + ]); + + $this->assertSame('비즈뿌리오 잔액 부족으로 알림톡 발송이 실패했습니다 (코드: 9071).', $rendered); + $this->assertStringNotContainsString('{', $rendered); + } +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php new file mode 100644 index 00000000..ac2c241b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php @@ -0,0 +1,95 @@ +listener = new GuestPhoneExtractListener; + } + + /** + * isGuestOrder 판정과 배송지 orderer_phone 을 지정할 수 있는 Order 더블을 만듭니다. + * + * 리스너가 이커머스 관행대로 `$order->shippingAddress?->orderer_phone` 를 직접 읽으므로, + * shippingAddress 관계 접근자와 그 orderer_phone 속성을 함께 stub 한다. Eloquent 이벤트 + * 재인스턴스화(new static)와 충돌하지 않도록 서브클래스 대신 Mockery partial mock 을 쓴다. + */ + private function orderDouble(bool $guest, ?string $phone): Order + { + $order = \Mockery::mock(Order::class)->makePartial(); + $order->shouldReceive('isGuestOrder')->andReturn($guest); + + // 배송지 관계 접근(getAttribute('shippingAddress'))이 orderer_phone 을 가진 객체를 반환 + $address = $phone === null ? null : (object) ['orderer_phone' => $phone]; + $order->shouldReceive('getAttribute')->with('shippingAddress')->andReturn($address); + + return $order; + } + + private function makeResult(array $data = []): array + { + return ['notifiable' => null, 'notifiables' => null, 'data' => $data, 'context' => []]; + } + + public function test_비회원_주문알림에_전화번호가_주입된다(): void + { + $order = $this->orderDouble(guest: true, phone: '010-1234-5678'); + + $out = $this->listener->injectGuestPhone( + $this->makeResult(['name' => '홍길동']), + 'order_confirmed', + [$order], + ); + + $this->assertSame('010-1234-5678', $out['data'][SmsChannelDriver::RECIPIENT_PHONE_KEY]); + $this->assertSame('홍길동', $out['data']['name'], '기존 data 는 보존되어야 한다.'); + } + + public function test_회원_주문은_전화번호를_주입하지_않는다(): void + { + $order = $this->orderDouble(guest: false, phone: '010-1234-5678'); + + $out = $this->listener->injectGuestPhone($this->makeResult(), 'order_confirmed', [$order]); + + $this->assertArrayNotHasKey(SmsChannelDriver::RECIPIENT_PHONE_KEY, $out['data']); + } + + public function test_주문알림이_아니면_원본을_통과시킨다(): void + { + $order = $this->orderDouble(guest: true, phone: '010-1234-5678'); + + $out = $this->listener->injectGuestPhone($this->makeResult(['x' => 1]), 'inquiry_replied', [$order]); + + $this->assertArrayNotHasKey(SmsChannelDriver::RECIPIENT_PHONE_KEY, $out['data']); + } + + public function test_전화번호가_없으면_주입하지_않는다(): void + { + $order = $this->orderDouble(guest: true, phone: null); + + $out = $this->listener->injectGuestPhone($this->makeResult(), 'order_shipped', [$order]); + + $this->assertArrayNotHasKey(SmsChannelDriver::RECIPIENT_PHONE_KEY, $out['data']); + } + + public function test_구독_훅과_filter_타입을_선언한다(): void + { + $hooks = GuestPhoneExtractListener::getSubscribedHooks(); + + $this->assertArrayHasKey('sirsoft-ecommerce.notification.extract_data', $hooks); + $this->assertSame('filter', $hooks['sirsoft-ecommerce.notification.extract_data']['type']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php new file mode 100644 index 00000000..419ad67d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php @@ -0,0 +1,110 @@ +cache = new PluginCacheDriver(self::IDENTIFIER, config('cache.default')); + $this->cache->flush(); + } + + /** + * 리스너를 실제 훅 파이프라인에 등록합니다. + */ + private function registerListener(): void + { + $tokenService = new BizppurioTokenService($this->cache, app(PluginSettingsService::class)); + $listener = new InvalidateTokenOnSettingsSaveListener($tokenService); + + HookManager::addAction( + 'core.plugin_settings.after_save', + [$listener, 'invalidateToken'], + 10, + ); + } + + public function test_저장_성공시_본_플러그인_캐시가_무효화된다(): void + { + $this->cache->put(self::CACHE_KEY, 'STALE_TOKEN', 3600); + $this->assertTrue($this->cache->has(self::CACHE_KEY)); + + $this->registerListener(); + + HookManager::doAction('core.plugin_settings.after_save', self::IDENTIFIER, ['bizppurio_id' => 'acct'], true); + + $this->assertFalse($this->cache->has(self::CACHE_KEY)); + } + + public function test_저장_실패시_캐시를_건드리지_않는다(): void + { + $this->cache->put(self::CACHE_KEY, 'EXISTING_TOKEN', 3600); + + $this->registerListener(); + + HookManager::doAction('core.plugin_settings.after_save', self::IDENTIFIER, ['bizppurio_id' => 'acct'], false); + + $this->assertTrue($this->cache->has(self::CACHE_KEY)); + $this->assertSame('EXISTING_TOKEN', $this->cache->get(self::CACHE_KEY)); + } + + public function test_다른_플러그인_저장시_캐시를_건드리지_않는다(): void + { + $this->cache->put(self::CACHE_KEY, 'EXISTING_TOKEN', 3600); + + $this->registerListener(); + + HookManager::doAction('core.plugin_settings.after_save', 'sirsoft-other-plugin', [], true); + + $this->assertTrue($this->cache->has(self::CACHE_KEY)); + } + + /** + * 실제 저장 API(PluginSettingsService::save) 를 호출해 코어 저장 경로 → + * after_save 훅 → 캐시 무효화까지 end-to-end 로 관찰한다. + */ + public function test_설정_저장_API_호출시_실제_저장_경로를_통해_캐시가_무효화된다(): void + { + $this->cache->put(self::CACHE_KEY, 'STALE_TOKEN', 3600); + + $this->registerListener(); + + app(PluginSettingsService::class)->save(self::IDENTIFIER, [ + 'bizppurio_id' => 'new-account', + 'password' => 'new-secret', + 'is_test_mode' => true, + ]); + + $this->assertFalse($this->cache->has(self::CACHE_KEY)); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php new file mode 100644 index 00000000..982308aa --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php @@ -0,0 +1,252 @@ + $settings plugin 설정 키→값 + */ + private function makeListener(array $settings = []): RegisterNotificationChannelsListener + { + $stub = new class($settings) extends PluginSettingsService + { + /** @param array $map */ + public function __construct(private array $map) + { + // 부모 생성자(의존성) 우회 — 테스트 stub 은 get()만 사용 + } + + public function get(string $identifier, ?string $key = null, mixed $default = null): mixed + { + return $this->map[$key] ?? $default; + } + }; + + return new RegisterNotificationChannelsListener($stub); + } + + /** 완비된 설정값 */ + private function fullSettings(): array + { + return [ + 'bizppurio_id' => 'acme', + 'password' => 'secret', + 'sender_number' => '025550000', + 'api_key' => 'key-40', + 'sender_key' => 'sender-40', + ]; + } + + public function test_사용가능_채널에_sms와_alimtalk이_추가된다(): void + { + $listener = $this->makeListener(); + + $channels = $listener->addChannels([ + ['id' => 'mail'], + ['id' => 'database'], + ]); + + $ids = array_column($channels, 'id'); + $this->assertContains('sms', $ids); + $this->assertContains('alimtalk', $ids); + + $sms = collect($channels)->firstWhere('id', 'sms'); + $this->assertTrue($sms['allow_guest'], 'sms 는 비회원 발송 허용(allow_guest:true)이어야 한다.'); + $this->assertArrayHasKey('name_key', $sms); + $this->assertStringContainsString('sirsoft-message_bizppurio::', $sms['name_key']); + + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertTrue($alimtalk['allow_guest']); + } + + public function test_채널메타에_is_test_mode가_실린다(): void + { + // is_test_mode=true(검수) → 상태 배너 노출 기준. 채널 메타에 실려 프론트로 전달된다. + $listener = $this->makeListener(array_merge($this->fullSettings(), ['is_test_mode' => true])); + $channels = $listener->addChannels([]); + + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertTrue($alimtalk['is_test_mode'], '검수 모드면 채널 메타 is_test_mode=true.'); + + $sms = collect($channels)->firstWhere('id', 'sms'); + $this->assertTrue($sms['is_test_mode']); + } + + public function test_검수모드_해제시_is_test_mode가_false다(): void + { + $listener = $this->makeListener(array_merge($this->fullSettings(), ['is_test_mode' => false])); + $channels = $listener->addChannels([]); + + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $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): 알림톡 탭도 코어 기본 목록을 그대로 쓴다. 코어 목록을 + // 숨기던 uses_custom_list 게이트·플래그는 폐기되었으므로 어느 채널에도 있으면 안 된다. + $channels = $this->makeListener()->addChannels([]); + + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertArrayNotHasKey('uses_custom_list', $alimtalk, '알림톡은 코어 기본 목록을 쓰므로 플래그가 없어야 한다(재설계 A).'); + + $sms = collect($channels)->firstWhere('id', 'sms'); + $this->assertArrayNotHasKey('uses_custom_list', $sms, 'sms 는 코어 기본 목록을 쓰므로 플래그가 없어야 한다.'); + } + + public function test_이미_존재하는_채널은_중복_추가되지_않는다(): void + { + $channels = $this->makeListener()->addChannels([['id' => 'sms', 'source' => 'other']]); + + $smsCount = count(array_filter($channels, fn ($c) => $c['id'] === 'sms')); + $this->assertSame(1, $smsCount, 'sms 가 중복 추가되면 안 된다.'); + } + + public function test_readiness_sms는_필수설정_부족시_ready_false다(): void + { + $listener = $this->makeListener([]); + + $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'sms'); + + $this->assertFalse($result['ready']); + $this->assertStringContainsString('readiness.sms_credentials_missing', $result['reason']); + } + + public function test_readiness_sms는_발신번호만_없으면_해당_사유를_반환한다(): void + { + $listener = $this->makeListener([ + 'bizppurio_id' => 'acme', + 'password' => 'secret', + ]); + + $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'sms'); + + $this->assertFalse($result['ready']); + $this->assertStringContainsString('readiness.sms_sender_number_missing', $result['reason']); + } + + public function test_readiness_sms는_완비시_ready_true다(): void + { + $listener = $this->makeListener($this->fullSettings()); + + $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'sms'); + + $this->assertTrue($result['ready']); + $this->assertNull($result['reason']); + } + + public function test_readiness_alimtalk는_apikey나_senderkey_없으면_ready_false다(): void + { + $listener = $this->makeListener([ + 'bizppurio_id' => 'acme', + 'password' => 'secret', + 'sender_number' => '025550000', + // api_key / sender_key 누락 + ]); + + $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'alimtalk'); + + $this->assertFalse($result['ready']); + $this->assertStringContainsString('readiness.alimtalk_api_key_missing', $result['reason']); + } + + public function test_readiness_alimtalk는_완비시_ready_true다(): void + { + $listener = $this->makeListener($this->fullSettings()); + + $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'alimtalk'); + + $this->assertTrue($result['ready']); + } + + public function test_readiness_우리채널이_아니면_원본_판정을_통과시킨다(): void + { + $listener = $this->makeListener([]); + $original = ['ready' => false, 'reason' => 'notification.readiness.mail_smtp_host_empty']; + + $result = $listener->checkReadiness($original, 'mail'); + + $this->assertSame($original, $result, 'mail 등 타 채널 판정은 변형 없이 통과해야 한다.'); + } + + public function test_채널후보에_sms와_alimtalk이_더해진다(): void + { + $result = $this->makeListener()->addChannelCandidates(['mail'], 'welcome', null); + + $this->assertContains('mail', $result); + $this->assertContains('sms', $result); + $this->assertContains('alimtalk', $result); + } + + public function test_채널후보_중복은_제거된다(): void + { + $result = $this->makeListener()->addChannelCandidates(['sms'], 'welcome', null); + + $this->assertSame(1, count(array_filter($result, fn ($c) => $c === 'sms'))); + } + + public function test_구독_훅에_3영역_channels가_포함된다(): void + { + $hooks = RegisterNotificationChannelsListener::getSubscribedHooks(); + + $this->assertArrayHasKey('core.notification.filter_available_channels', $hooks); + $this->assertArrayHasKey('core.notification.channel_readiness', $hooks); + $this->assertArrayHasKey('core.notification.channel_enabled', $hooks); + $this->assertArrayHasKey('core.auth.notification.channels', $hooks); + $this->assertArrayHasKey('sirsoft-ecommerce.notification.channels', $hooks); + $this->assertArrayHasKey('sirsoft-board.notification.channels', $hooks); + + // 전부 filter 타입 명시(반환값 무시 회귀 차단) + foreach ($hooks as $config) { + $this->assertSame('filter', $config['type']); + } + } + + public function test_channel_enabled_우리채널이_아니면_원본을_통과시킨다(): void + { + $listener = $this->makeListener(); + + // mail/database 등 타 채널은 저장소 조회 없이 코어 판정($enabled)을 그대로 반환 + $this->assertTrue($listener->gateChannelEnabled(true, 'core', 'core', 'mail')); + $this->assertFalse($listener->gateChannelEnabled(false, 'core', 'core', 'database')); + $this->assertTrue($listener->gateChannelEnabled(true, 'module', 'sirsoft-board', 'slack')); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php new file mode 100644 index 00000000..213c8684 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php @@ -0,0 +1,189 @@ + 'trigger_user']]): array + { + return [ + 'welcome' => [ + 'hook_prefix' => 'core.auth', + 'channels' => ['mail', 'database'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => $recipients, 'subject' => ['ko' => '제목'], 'body' => ['ko' => '

{name}

']], + ['channel' => 'database', 'recipients' => $recipients, 'subject' => ['ko' => '알림'], 'body' => ['ko' => '{name}님 환영합니다']], + ], + ], + ]; + } + + public function test_회원_알림에_sms와_alimtalk_채널이_추가된다(): void + { + $result = $this->listener()->augment($this->coreDefinition()); + + $channels = $result['welcome']['channels']; + $this->assertContains('sms', $channels); + $this->assertContains('alimtalk', $channels); + $this->assertContains('mail', $channels, '기존 채널은 보존되어야 한다.'); + + $addedChannels = array_column($result['welcome']['templates'], 'channel'); + $this->assertContains('sms', $addedChannels); + $this->assertContains('alimtalk', $addedChannels); + } + + public function test_추가된_채널_body는_database_평문을_재활용한다(): void + { + $result = $this->listener()->augment($this->coreDefinition()); + + $alimtalk = collect($result['welcome']['templates'])->firstWhere('channel', 'alimtalk'); + $this->assertSame('{name}님 환영합니다', $alimtalk['body']['ko'], 'database 평문 body 를 재활용해야 한다(HTML 아님).'); + } + + public function test_database가_없으면_mail_body의_htm_l을_제거해_쓴다(): void + { + $def = [ + 'welcome' => [ + 'channels' => ['mail'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '

{name}님

환영합니다

']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $sms = collect($result['welcome']['templates'])->firstWhere('channel', 'sms'); + $this->assertSame('{name}님 환영합니다', $sms['body']['ko'], 'HTML 태그는 제거되고 변수는 보존되어야 한다.'); + } + + public function test_관리자_전용_알림은_증강되지_않는다(): void + { + $def = [ + 'balance_low' => [ + 'channels' => ['mail', 'database'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => [['type' => 'role', 'value' => 'admin']], 'body' => ['ko' => 'x']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $channels = $result['balance_low']['channels']; + $this->assertNotContains('sms', $channels, '관리자 전용 알림에는 문자/알림톡을 추가하지 않는다.'); + $this->assertNotContains('alimtalk', $channels); + } + + public function test_모듈형_순차배열도_증강한다(): void + { + // 모듈: getNotificationDefinitions() 는 순차 배열 [['type'=>..., ...], ...] + $def = [ + [ + 'type' => 'order_confirmed', + 'channels' => ['mail', 'database'], + 'templates' => [ + ['channel' => 'database', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '주문 완료']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $this->assertContains('alimtalk', $result[0]['channels']); + $this->assertSame('order_confirmed', $result[0]['type'], 'type 등 기존 필드는 보존되어야 한다.'); + } + + public function test_이미_alimtalk이_있으면_중복_추가하지_않는다(): void + { + $def = [ + 'welcome' => [ + 'channels' => ['mail', 'alimtalk'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => 'x']], + ['channel' => 'alimtalk', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '기존']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $alimtalkCount = count(array_filter($result['welcome']['templates'], fn ($t) => $t['channel'] === 'alimtalk')); + $this->assertSame(1, $alimtalkCount, 'alimtalk template 이 중복 추가되면 안 된다.'); + } + + public function test_recipients가_없는_알림은_회원_대상으로_보고_증강한다(): void + { + $def = [ + 'generic' => [ + 'channels' => ['mail'], + 'templates' => [ + ['channel' => 'mail', 'body' => ['ko' => '알림']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $this->assertContains('alimtalk', $result['generic']['channels']); + // recipients 미지정 → 기본 trigger_user + $alimtalk = collect($result['generic']['templates'])->firstWhere('channel', 'alimtalk'); + $this->assertSame('trigger_user', $alimtalk['recipients'][0]['type']); + } + + /** + * 코어 [기본값 복원] 경로(getDefaultTemplateData)가 통과하는 필터에도 같은 증강을 건다 + * (#597 §18.7 C2 — 시드만 하고 복원 출처에 빠져 있어 "기본 템플릿 데이터를 찾을 수 없습니다" 로 실패했다). + * + * @effects core_reset_restores_seeded_channel_template_defaults + */ + public function test_기본값_복원_필터를_시딩_훅과_같은_메서드로_구독한다(): void + { + $hooks = SeedChannelTemplatesListener::getSubscribedHooks(); + + $this->assertArrayHasKey('core.notification.filter_default_definitions', $hooks); + $this->assertSame('augment', $hooks['core.notification.filter_default_definitions']['method']); + $this->assertSame('filter', $hooks['core.notification.filter_default_definitions']['type'], '반환값을 쓰는 필터다 — type 누락 시 증강이 버려진다.'); + $this->assertGreaterThan(20, $hooks['core.notification.filter_default_definitions']['priority'], '게시판·이커머스가 자기 정의를 보태는 priority 20 뒤에 돌아야 모듈 정의도 증강된다.'); + + // 시딩 3훅과 동일 메서드 — 시드 출처와 복원 출처가 갈리면 복원값이 시드값과 달라진다. + foreach (['seed.notifications.translations', 'seed.sirsoft-board.notifications.translations', 'seed.sirsoft-ecommerce.notifications.translations'] as $seedHook) { + $this->assertSame($hooks[$seedHook]['method'], $hooks['core.notification.filter_default_definitions']['method']); + } + } + + /** + * @effects core_reset_restores_seeded_channel_template_defaults + */ + public function test_기본값_복원_경로의_순차_정의_배열에서_alimtalk_기본값을_찾을_수_있다(): void + { + // getDefaultTemplateData 는 config/core.php 정의에 type/extension_* 를 붙인 순차 배열을 필터에 넘긴다 + $definitions = [ + array_merge($this->coreDefinition()['welcome'], ['type' => 'welcome', 'extension_type' => 'core', 'extension_identifier' => 'core']), + ]; + + $result = $this->listener()->augment($definitions, ['type' => 'welcome', 'channel' => 'alimtalk']); + + $alimtalk = collect($result[0]['templates'])->firstWhere('channel', 'alimtalk'); + $this->assertNotNull($alimtalk, '복원 경로가 찾는 channel=alimtalk template 이 있어야 한다.'); + $this->assertSame([['type' => 'trigger_user']], $alimtalk['recipients']); + $this->assertSame('{name}님 환영합니다', $alimtalk['body']['ko'], '시딩과 같은 database 평문 body 가 복원값이다.'); + $this->assertSame('welcome', $result[0]['type'], '기존 필드는 보존된다.'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php new file mode 100644 index 00000000..f0ef37f7 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php @@ -0,0 +1,90 @@ + 'r1', + 'channel' => 'lms', + 'to_number' => '01011112222', + 'content' => 'x', + 'status' => 'pending', + 'source' => 'auto', + ]); + + $this->assertSame(DispatchChannel::Lms, $dispatch->channel); + $this->assertSame(DispatchStatus::Pending, $dispatch->status); + } + + /** + * 마스킹 접근자는 가운데 4자리를 가린다. + */ + public function test_masked_number(): void + { + $dispatch = new BizppurioDispatch(['to_number' => '01012345678']); + $this->assertSame('010-****-5678', $dispatch->masked_number); + } + + /** + * 하이픈 포함 번호도 정규화 후 마스킹한다. + */ + public function test_masked_number_with_hyphens(): void + { + $dispatch = new BizppurioDispatch(['to_number' => '010-1234-5678']); + $this->assertSame('010-****-5678', $dispatch->masked_number); + } + + /** + * to_user_id 로 회원 관계를 로드한다. + */ + public function test_user_relation(): void + { + $user = User::factory()->create(); + $dispatch = BizppurioDispatch::create([ + 'refkey' => 'r2', + 'channel' => 'sms', + 'to_number' => '01011112222', + 'to_user_id' => $user->id, + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + ]); + + $this->assertNotNull($dispatch->user); + $this->assertSame($user->id, $dispatch->user->id); + } + + /** + * byRefkey 스코프로 조회한다. + */ + public function test_by_refkey_scope(): void + { + BizppurioDispatch::create([ + 'refkey' => 'unique_ref', + 'channel' => 'sms', + 'to_number' => '01000000000', + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + ]); + + $found = BizppurioDispatch::query()->byRefkey('unique_ref')->first(); + $this->assertNotNull($found); + $this->assertSame('unique_ref', $found->refkey); + } +} 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/BizppurioDispatchRepositoryTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php new file mode 100644 index 00000000..00ee566a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php @@ -0,0 +1,135 @@ +repo = new BizppurioDispatchRepository; + } + + private function makeDispatch(array $overrides = []): BizppurioDispatch + { + return $this->repo->create(array_merge([ + 'refkey' => 'r'.uniqid(), + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + 'sent_at' => now(), + ], $overrides)); + } + + public function test_create_and_find_by_refkey(): void + { + $this->makeDispatch(['refkey' => 'known']); + $this->assertNotNull($this->repo->findByRefkey('known')); + $this->assertNull($this->repo->findByRefkey('missing')); + } + + public function test_update(): void + { + $dispatch = $this->makeDispatch(['refkey' => 'upd', 'status' => 'sent']); + $this->repo->update($dispatch, ['status' => 'success', 'result_code' => '4100']); + + $this->assertSame('success', $dispatch->fresh()->status->value); + $this->assertSame('4100', $dispatch->fresh()->result_code); + } + + public function test_paginate_filters_by_channel_and_status(): void + { + $this->makeDispatch(['channel' => 'sms', 'status' => 'success']); + $this->makeDispatch(['channel' => 'alimtalk', 'status' => 'failed']); + $this->makeDispatch(['channel' => 'sms', 'status' => 'failed']); + + $smsPage = $this->repo->paginate(['channel' => 'sms']); + $this->assertSame(2, $smsPage->total()); + + $failedPage = $this->repo->paginate(['status' => 'failed']); + $this->assertSame(2, $failedPage->total()); + } + + public function test_paginate_keyword_matches_number_or_refkey(): void + { + $this->makeDispatch(['refkey' => 'refABC', 'to_number' => '01099998888']); + $this->makeDispatch(['refkey' => 'other', 'to_number' => '01000000000']); + + $byRefkey = $this->repo->paginate(['keyword' => 'refABC']); + $this->assertSame(1, $byRefkey->total()); + + $byNumber = $this->repo->paginate(['keyword' => '9999']); + $this->assertSame(1, $byNumber->total()); + } + + public function test_link_notification_log_by_refkey(): void + { + $this->makeDispatch(['refkey' => 'to_link']); + + $linked = $this->repo->linkNotificationLog('to_link', 777); + $this->assertTrue($linked); + $this->assertSame(777, (int) $this->repo->findByRefkey('to_link')->notification_log_id); + } + + public function test_link_notification_log_missing_refkey_returns_false(): void + { + $this->assertFalse($this->repo->linkNotificationLog('nope', 1)); + } + + public function test_find_by_notification_log_ids_keyed(): void + { + $this->makeDispatch(['refkey' => 'a', 'notification_log_id' => 10]); + $this->makeDispatch(['refkey' => 'b', 'notification_log_id' => 20]); + $this->makeDispatch(['refkey' => 'c', 'notification_log_id' => null]); // 미연결 + + $map = $this->repo->findByNotificationLogIdsKeyed([10, 20, 30]); + + $this->assertCount(2, $map); + $this->assertSame('a', $map[10]->refkey); + $this->assertSame('b', $map[20]->refkey); + $this->assertFalse($map->has(30)); + } + + public function test_find_by_notification_log_ids_empty_returns_empty(): void + { + $this->assertTrue($this->repo->findByNotificationLogIdsKeyed([])->isEmpty()); + } + + public function test_recent_linked_keyed_excludes_unlinked_and_keys_by_log_id(): void + { + $this->makeDispatch(['refkey' => 'x', 'notification_log_id' => 11]); + $this->makeDispatch(['refkey' => 'y', 'notification_log_id' => 22]); + $this->makeDispatch(['refkey' => 'z', 'notification_log_id' => null]); // 미연결 → 제외 + + $map = $this->repo->recentLinkedKeyed(); + + $this->assertCount(2, $map); + $this->assertSame('x', $map[11]->refkey); + $this->assertSame('y', $map[22]->refkey); + } + + public function test_recent_linked_keyed_respects_limit(): void + { + foreach (range(1, 5) as $i) { + $this->makeDispatch(['refkey' => 'r'.$i, 'notification_log_id' => $i]); + } + + // 최신(id 큰) 것부터 limit 만큼 — 최근 2건만 + $map = $this->repo->recentLinkedKeyed(2); + + $this->assertCount(2, $map); + $this->assertTrue($map->has(5)); + $this->assertTrue($map->has(4)); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php new file mode 100644 index 00000000..277bdbe1 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -0,0 +1,625 @@ +|null $approved approved_content + */ + 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 $row; + } + + /** + * findByType 반환값을 지정한 템플릿 리포지토리 mock 을 만듭니다. + */ + private function fakeTemplates(?BizppurioTemplate $row): BizppurioTemplateRepositoryInterface + { + $repo = Mockery::mock(BizppurioTemplateRepositoryInterface::class); + $repo->shouldReceive('findByType')->andReturn($row); + + return $repo; + } + + /** + * 드라이버를 조립합니다. + */ + private function makeDriver( + ?BizppurioTemplate $row, + ?MessagePayloadBuilder $builder = null, + bool $isTestMode = true, + ): AlimtalkChannelDriver { + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get') + ->with('sirsoft-message_bizppurio', 'is_test_mode', true) + ->andReturn($isTestMode); + + // 스킵 예외 메시지의 알림 유형 라벨 조회 — 정의 없음(null)이면 resolveTypeLabel 이 코드값으로 폴백. + $definitionService = Mockery::mock(NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->andReturn(null); + + return new AlimtalkChannelDriver( + $definitionService, + $this->fakeTemplates($row), + $builder ?? $this->spyBuilder(), + new BizppurioDispatchRepository, + new DispatchLinkContext, + new AlimtalkPayloadMapper, + $pluginSettings, + ); + } + + /** + * buildAlimtalk 호출 인자를 기록하는 payload 빌더 스파이를 만듭니다. + */ + private function spyBuilder(): MessagePayloadBuilder + { + return new class extends MessagePayloadBuilder + { + /** @var array> */ + public array $calls = []; + + public function __construct() {} + + public function buildAlimtalk( + string $to, + string $templateCode, + string $message, + string $refkey, + array $extra = [], + ): array { + $this->calls[] = [ + 'to' => $to, + 'templateCode' => $templateCode, + 'message' => $message, + 'refkey' => $refkey, + ]; + + return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'templatecode' => $templateCode]; + } + }; + } + + private function notification(array $data = ['name' => '김철수']): GenericNotification + { + return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); + } + + /** + * @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']); + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($this->templateRow(isActive: false)) + ->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } + } + + /** + * @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(NotificationDefinition::class); + $definition->shouldReceive('getLocalizedName')->andReturn('주문 완료'); + + $definitionService = Mockery::mock(NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->with('order_confirmed')->andReturn($definition); + + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->andReturn(true); + + $driver = new AlimtalkChannelDriver( + $definitionService, + $this->fakeTemplates(null), + $this->spyBuilder(), + new BizppurioDispatchRepository, + new DispatchLinkContext, + new AlimtalkPayloadMapper, + $pluginSettings, + ); + + try { + $driver->send($member, $this->notification()); + $this->fail('NotificationSendSkippedException 가 발생해야 한다.'); + } catch (NotificationSendSkippedException $e) { + $this->assertStringContainsString('주문 완료', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.'); + $this->assertStringNotContainsString('order_confirmed', $e->getMessage()); + } + } + + public function test_스냅샷_치환_결과_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($this->templateRow(approved: ['templateContent' => ' '])) + ->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } + } + + /** + * @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->templateRow(), $builder)->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertCount(1, $builder->calls); + $this->assertSame('01012345678', $builder->calls[0]['to']); + $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(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + $data = ['name' => '홍길동', AlimtalkChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; + $builder = $this->spyBuilder(); + + $this->makeDriver($this->templateRow(), $builder)->send($guest, $this->notification($data)); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertSame('01099990000', $builder->calls[0]['to']); + } + + /** + * @effects snapshot_content_substituted_with_notification_data + */ + public function test_스냅샷_본문의_변수를_알림_data로_치환해_발송한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + $builder = $this->spyBuilder(); + + $this->makeDriver( + $this->templateRow(approved: ['templateContent' => '#{name}님 #{order_number} 주문 완료']), + $builder, + )->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1'])); + + $this->assertSame( + '김철수님 A1 주문 완료', + $builder->calls[0]['message'], + '승인 스냅샷의 #{var} 를 알림 data 로 치환해야 한다.', + ); + } + + /** + * @effects snapshot_buttons_mapped_to_dispatch_button_fields + */ + public function test_스냅샷_버튼을_발송_extra로_전달한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + // 등록 페이로드(buttons[].linkMo)와 발송 규격(button[].url_mobile)의 필드 대응이 + // 매퍼에서 유지돼야 한다 — 스냅샷은 kapi add 페이로드 형태 그대로다. + $builder = new class extends MessagePayloadBuilder + { + /** @var array> */ + public array $extras = []; + + public function __construct() {} + + public function buildAlimtalk(string $to, string $templateCode, string $message, string $refkey, array $extra = []): array + { + $this->extras[] = $extra; + + return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'content' => ['at' => array_merge(['message' => $message], $extra)]]; + } + }; + + $this->makeDriver( + $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]; + $this->assertSame('WL', $button['type']); + $this->assertSame('https://m.shop/orders/A1', $button['url_mobile'], '버튼 URL 변수도 치환돼 발송돼야 한다.'); + } + + /** + * @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']); + + $builder = new class extends MessagePayloadBuilder + { + public function __construct() {} + + public function buildAlimtalk(string $to, string $templateCode, string $message, string $refkey, array $extra = []): array + { + return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'content' => ['at' => ['message' => $message]]]; + } + }; + + // 대체 SMS 본문 소스는 코어 템플릿이 아니라 행의 sms_body(#{var} 치환)다 (#597 §3.5). + $this->makeDriver( + $this->templateRow(fallback: true, smsBody: '[샵] #{name} 님 주문 완료'), + $builder, + )->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->assertSame('[샵] 김철수 님 주문 완료', $job->payload['recontent']['sms']['message'] ?? null, '대체 SMS 본문은 행의 sms_body 를 치환한 값이어야 한다.'); + + return true; + }); + } + + /** + * @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->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) { + $this->assertArrayNotHasKey('resend', $job->payload); + + return true; + }); + } + + /** + * @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->templateRow())->send($member, $this->notification()); + + $this->assertDatabaseCount('bizppurio_dispatches', 1); + $dispatch = BizppurioDispatch::first(); + $this->assertSame('pending', $dispatch->status->value); + $this->assertSame('alimtalk', $dispatch->channel->value); + $this->assertSame($member->id, $dispatch->to_user_id); + $this->assertSame('order_confirmed', $dispatch->notification_type); + } + + public function test_발송_시_실제_전송_payload가_이력에_저장된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + $builder = $this->spyBuilder(); + + $this->makeDriver($this->templateRow(), $builder)->send($member, $this->notification()); + + $dispatch = BizppurioDispatch::first(); + $this->assertNotNull($dispatch->request_payload, '실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.'); + $this->assertSame('g7_abcd1234_1', $dispatch->request_payload['templatecode'] ?? null); + } + + public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($this->templateRow())->send($guest, $this->notification(['name' => '홍길동'])); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } + } + + public function test_generic_notification이_아니면_무시한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + $this->makeDriver($this->templateRow())->send($member, new Notification); + + Bus::assertNotDispatched(SendMessageJob::class); + } + + public function test_검수_모드_설정값이_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->templateRow(), isTestMode: true)->send($member, $this->notification()); + + $this->assertTrue(BizppurioDispatch::first()->is_test_mode); + } + + public function test_운영_모드_설정값도_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $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 new file mode 100644 index 00000000..2b500a91 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php @@ -0,0 +1,347 @@ +mapper()->map( + ['templateContent' => '#{name}님 주문 #{order_no} 완료'], + ['name' => '홍길동', 'order_no' => 'A123'], + ); + + $this->assertSame('홍길동님 주문 A123 완료', $result['message']); + } + + /** + * @effects snapshot_buttons_mapped_to_dispatch_button_fields + */ + public function test_웹링크_버튼을_발송형식으로_변환하고_url변수를_치환한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + [ + 'name' => '주문조회', + 'linkType' => 'WL', + 'linkMo' => 'https://m.shop/orders/#{order_no}', + 'linkPc' => 'https://shop/orders/#{order_no}', + ], + ], + ], + ['order_no' => 'A123'], + ); + + $button = $result['extra']['button'][0]; + $this->assertSame('주문조회', $button['name']); + $this->assertSame('WL', $button['type']); + $this->assertSame('https://m.shop/orders/A123', $button['url_mobile']); + $this->assertSame('https://shop/orders/A123', $button['url_pc']); + // 카카오 필드명(linkType/linkMo)은 발송 payload 에 남지 않아야 한다. + $this->assertArrayNotHasKey('linkType', $button); + $this->assertArrayNotHasKey('linkMo', $button); + } + + /** + * @effects snapshot_buttons_mapped_to_dispatch_button_fields + */ + public function test_앱링크_전화_플러그인_버튼_필드를_매핑한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '앱', 'linkType' => 'AL', 'linkAnd' => 'myapp://a', 'linkIos' => 'myapp://i'], + ['name' => '전화', 'linkType' => 'TN', 'telNumber' => '1588-0000'], + ['name' => '플러그인', 'linkType' => 'P1', 'pluginId' => 'PLUG_1'], + ], + ], + [], + ); + + $buttons = $result['extra']['button']; + $this->assertSame('myapp://a', $buttons[0]['scheme_android']); + $this->assertSame('myapp://i', $buttons[0]['scheme_ios']); + $this->assertSame('1588-0000', $buttons[1]['tel_number']); + $this->assertSame('PLUG_1', $buttons[2]['plugin_id']); + } + + public function test_quick_replies를_quickreply로_변환한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'quickReplies' => [ + ['name' => '바로가기', 'linkType' => 'WL', 'linkMo' => 'https://m.shop'], + ], + ], + [], + ); + + $qr = $result['extra']['quickreply'][0]; + $this->assertSame('바로가기', $qr['name']); + $this->assertSame('WL', $qr['type']); + $this->assertSame('https://m.shop', $qr['url_mobile']); + } + + public function test_title_header를_치환해_매핑한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'templateTitle' => '#{name}님', + 'templateHeader' => '주문 안내', + ], + ['name' => '홍길동'], + ); + + $this->assertSame('홍길동님', $result['extra']['title']); + $this->assertSame('주문 안내', $result['extra']['header']); + } + + public function test_item과_itemhighlight를_매핑하고_치환한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'templateItem' => [ + 'list' => [ + ['title' => '상품', 'description' => '#{product}'], + ], + 'summary' => ['title' => '합계', 'description' => '#{total}원'], + ], + 'templateItemHighlight' => ['title' => '#{name}님', 'description' => '주문완료'], + ], + ['product' => '티셔츠', 'total' => '20,000', 'name' => '홍길동'], + ); + + $this->assertSame('티셔츠', $result['extra']['item']['list'][0]['description']); + $this->assertSame('20,000원', $result['extra']['item']['summary']['description']); + $this->assertSame('홍길동님', $result['extra']['itemhighlight']['title']); + } + + public function test_대표링크를_link로_변환하고_치환한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'templateRepresentLink' => [ + 'linkMo' => 'https://m.shop/#{id}', + 'linkPc' => 'https://shop/#{id}', + ], + ], + ['id' => 'X1'], + ); + + $this->assertSame('https://m.shop/X1', $result['extra']['link']['url_mobile']); + $this->assertSame('https://shop/X1', $result['extra']['link']['url_pc']); + } + + public function test_원문에_프로토콜_접두어가_있고_변수값도_완전url이면_중복을_제거한다(): void + { + // 결함② — 카카오 콘솔에 `http://#{action_url}` 로 등록된 버튼. action_url 자체가 + // config('app.url') 기반 완전 URL(https://...)이라 단순 치환 시 프로토콜이 중복된다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'http://#{action_url}'], + ], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame( + 'https://ehkim.gnuboard.net/login', + $result['extra']['button'][0]['url_mobile'], + '원문 접두어(http://)를 제거해 프로토콜 중복(http://https://...)을 방지해야 한다.', + ); + } + + public function test_원문_접두어가_https이고_변수값도_https이면_중복을_제거한다(): void + { + // 실제 카카오 콘솔 등록값(관리자 화면 상세조회 스크린샷 확인, 2026-07-24) — + // "회원가입 환영" 템플릿 버튼이 http:// 가 아니라 https://#{action_url} 로 등록돼 있다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'https://#{action_url}'], + ], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame( + 'https://ehkim.gnuboard.net/login', + $result['extra']['button'][0]['url_mobile'], + '원문 접두어가 https:// 인 경우도 동일하게 중복(https://https://...)을 방지해야 한다.', + ); + } + + public function test_원문에_프로토콜_접두어가_없으면_변수값을_그대로_둔다(): void + { + // 가이드 문서 원안(#{action_url} 만 등록)대로면 애초에 중복이 없으므로 손대지 않는다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => '#{action_url}'], + ], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame('https://ehkim.gnuboard.net/login', $result['extra']['button'][0]['url_mobile']); + } + + public function test_원문_접두어가_있어도_변수값이_상대경로면_접두어를_유지한다(): void + { + // 변수값 자체가 프로토콜을 포함하지 않는 경우(상대경로 등) 접두어는 진짜로 필요한 + // 부분이므로 제거하면 안 된다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '이동', 'linkType' => 'WL', 'linkMo' => 'http://#{path}'], + ], + ], + ['path' => '/foo'], + ); + + $this->assertSame('http:///foo', $result['extra']['button'][0]['url_mobile']); + } + + public function test_대표링크도_프로토콜_중복을_제거한다(): void + { + // mapLinkFields(대표링크)도 mapButtons 와 동일 규칙을 공유해야 한다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'templateRepresentLink' => ['linkMo' => 'http://#{action_url}'], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame('https://ehkim.gnuboard.net/login', $result['extra']['link']['url_mobile']); + } + + public function test_변수가_data에_없으면_원문을_유지한다(): void + { + // data 에 없는 변수는 원문(#{key}) 유지 정책 — 프로토콜 중복 방어 로직이 이 정책을 + // 깨서는 안 된다(원문이 http:// 로 시작하지만 치환 자체가 안 일어나므로 그대로 둔다). + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'http://#{unknown_var}'], + ], + ], + [], + ); + + $this->assertSame('http://#{unknown_var}', $result['extra']['button'][0]['url_mobile']); + } + + public function test_부재_필드는_extra에_넣지_않는다(): void + { + // 본문만 있는 단순 템플릿 → extra 는 비어야 한다. + $result = $this->mapper()->map(['templateContent' => '본문'], []); + + $this->assertSame('본문', $result['message']); + $this->assertSame([], $result['extra']); + } + + public function test_빈_버튼배열은_extra에_button을_만들지_않는다(): void + { + $result = $this->mapper()->map( + ['templateContent' => '본문', 'buttons' => []], + [], + ); + + $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 new file mode 100644 index 00000000..45174ef0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php @@ -0,0 +1,132 @@ +shouldReceive('get')->with(self::IDENTIFIER) + ->andReturn(['bizppurio_id' => 'biz01', 'api_key' => 'key01']); + + return new AlimtalkTemplateService(new BizppurioKakaoApiClient($pluginSettings)); + } + + /** + * 카테고리 전체 조회는 data 배열을 그대로 반환한다. + */ + public function test_카테고리는_data_배열을_반환한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'data' => [ + ['code' => '001001', 'name' => '회원가입', 'groupName' => '회원'], + ['code' => '002001', 'name' => '구매완료', 'groupName' => '구매'], + ], + ], 200), + ]); + + $categories = $this->service()->categories(); + + $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'); + } + + /** + * 카테고리 조회가 실패 코드로 응답하면 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 안에 있으므로 그 배열을 반환해야 한다. + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'data' => [ + 'success' => [ + ['senderKey' => 'SK_40', 'name' => '테스트채널', 'status' => 'A'], + ], + 'fail' => [], + ], + ], 200), + ]); + + $profiles = $this->service()->senderProfiles(); + + // success 배열이 그대로 반환되어야 한다(껍데기 {success,fail} 가 아님). + $this->assertCount(1, $profiles); + $this->assertSame('SK_40', $profiles[0]['senderKey']); + $this->assertSame('테스트채널', $profiles[0]['name']); + $this->assertArrayNotHasKey('success', $profiles); + $this->assertArrayNotHasKey('fail', $profiles); + } + + /** + * 발신프로필 조회가 실패 코드로 응답하면 예외를 던진다. + */ + public function test_발신프로필_실패코드시_예외(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '7204', 'message' => '발신프로필을 찾을 수 없습니다.'], 200), + ]); + + try { + $this->service()->senderProfiles(); + $this->fail('BizppurioApiException 이 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame('발신프로필을 찾을 수 없습니다.', $e->getMessage()); + $this->assertSame('7204', $e->getResultCode()); + } + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php new file mode 100644 index 00000000..e36ba1f8 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php @@ -0,0 +1,177 @@ + 'acct', + 'type' => 'sms', + 'from' => '07012345678', + 'to' => '01011112222', + 'refkey' => 'ref1', + 'content' => ['sms' => ['message' => 'hi']], + ]; + + private function makeSettings(bool $isTestMode = true): PluginSettingsService + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get') + ->with(self::IDENTIFIER, 'is_test_mode', true) + ->andReturn($isTestMode); + + return $mock; + } + + /** + * getToken/refreshToken 을 순차 반환하는 TokenService mock. + * + * @param string $token getToken 반환 토큰 + * @param string|null $refreshed refreshToken 반환 토큰(null 이면 미기대) + */ + private function makeToken(string $token, ?string $refreshed = null): BizppurioTokenService + { + $mock = Mockery::mock(BizppurioTokenService::class); + $mock->shouldReceive('getToken')->andReturn($token); + + if ($refreshed !== null) { + $mock->shouldReceive('refreshToken')->once()->andReturn($refreshed); + } + + return $mock; + } + + public function test_발송_성공(): void + { + Http::fake([ + '*/v3/message' => Http::response([ + 'code' => 1000, 'description' => 'Success', 'refkey' => 'ref1', 'messagekey' => 'mk1', + ], 200), + ]); + + $client = new BizppurioApiClient($this->makeToken('TOKEN'), $this->makeSettings()); + $result = $client->sendMessage($this->payload); + + $this->assertTrue($client->isSuccess($result)); + $this->assertSame('mk1', $result['messagekey']); + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer TOKEN')); + } + + public function test_토큰무효_3002는_재발급_후_1회_재시도(): void + { + Http::fake([ + '*/v3/message' => Http::sequence() + ->push(['code' => 3002, 'description' => 'Invalid token'], 200) + ->push(['code' => 1000, 'description' => 'Success', 'messagekey' => 'mk2'], 200), + ]); + + $client = new BizppurioApiClient( + $this->makeToken('OLD', 'NEW'), + $this->makeSettings(), + ); + $result = $client->sendMessage($this->payload); + + $this->assertTrue($client->isSuccess($result)); + Http::assertSentCount(2); + } + + public function test_운영_모드는_live_도메인_호출(): void + { + Http::fake([ + 'api.bizppurio.com/*' => Http::response(['code' => 1000], 200), + ]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings(false)); + $client->sendMessage($this->payload); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bizppurio.com')); + } + + public function test_http_실패시_예외(): void + { + Http::fake(['*/v3/message' => Http::response([], 500)]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings()); + + $this->expectException(BizppurioApiException::class); + $client->sendMessage($this->payload); + } + + /** + * HTTP 실패 응답에 code·description 이 있으면 예외에 사유·결과코드로 담긴다. + * + * 회귀 배경: 실패 시 "메시지 발송 요청에 실패했습니다"만 뜨고 비즈뿌리오가 준 실제 사유·코드를 + * 버려, 발송 이력·로그로 원인을 알 수 없던 문제. 실패 body 를 추출해 예외에 실어 이력에 남긴다. + */ + public function test_http_실패시_응답_body_의_사유와_코드가_예외에_담긴다(): void + { + Http::fake([ + '*/v3/message' => Http::response( + ['code' => 7103, 'description' => '발신 프로필 키가 유효하지 않음'], + 400, + ), + ]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings()); + + try { + $client->sendMessage($this->payload); + $this->fail('예외가 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame('7103', $e->getResultCode(), '실패 body 의 code 가 예외에 담겨야 한다.'); + $this->assertSame('발신 프로필 키가 유효하지 않음', $e->getMessage(), '실패 body 의 description 이 예외 메시지로 담겨야 한다.'); + } + } + + /** + * HTTP 실패 응답에 body 가 없으면 기본 사유로 폴백하고 결과코드는 null. + */ + public function test_http_실패시_body_없으면_기본_사유_폴백(): void + { + Http::fake(['*/v3/message' => Http::response([], 500)]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings()); + + try { + $client->sendMessage($this->payload); + $this->fail('예외가 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertNull($e->getResultCode()); + $this->assertNotSame('', $e->getMessage()); + } + } + + public function test_영구실패_결과코드는_그대로_반환(): void + { + Http::fake([ + '*/v3/message' => Http::response(['code' => 3006, 'description' => 'Account error'], 200), + ]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings()); + $result = $client->sendMessage($this->payload); + + $this->assertFalse($client->isSuccess($result)); + $this->assertSame(3006, $result['code']); + // 3006 은 재시도 대상 아님 → HTTP 1회 + Http::assertSentCount(1); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php new file mode 100644 index 00000000..440e5b75 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php @@ -0,0 +1,338 @@ + $settings + */ + private function makeSettings(array $settings): PluginSettingsService + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); + + return $mock; + } + + private function client(): BizppurioKakaoApiClient + { + return new BizppurioKakaoApiClient( + $this->makeSettings(['bizppurio_id' => 'biz01', 'api_key' => 'key01']), + ); + } + + public function test_발신프로필_조회는_bizid_apikey를_body에_싣는다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['success' => []]], 200), + ]); + + $result = $this->client()->getSenderProfiles(); + + $this->assertTrue($this->client()->isSuccess($result)); + Http::assertSent(function ($request) { + return str_contains($request->url(), 'kapi.ppurio.com/v3/kakao/profile/use') + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_템플릿_목록은_senderkey를_포함한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200), + ]); + + $this->client()->getTemplateList('SENDER_KEY', ['count' => 20]); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/list') + && $request['senderKey'] === 'SENDER_KEY' + && $request['count'] === 20 + && $request['bizId'] === 'biz01'; + }); + } + + public function test_템플릿_상세_조회(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['templateCode' => 'TW_1']], 200), + ]); + + $result = $this->client()->getTemplateDetail('SK', 'TW_1'); + + $this->assertSame('TW_1', $result['data']['templateCode']); + } + + public function test_임의_엔드포인트_request_위임(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200'], 200), + ]); + + $this->client()->request('/v3/kakao/template/add', ['templateName' => 'T']); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/add') + && $request['templateName'] === 'T'); + } + + public function test_자격증명_미설정시_예외(): void + { + $client = new BizppurioKakaoApiClient( + $this->makeSettings(['bizppurio_id' => 'biz01', 'api_key' => '']), + ); + + $this->expectException(BizppurioApiException::class); + $client->getSenderProfiles(); + } + + public function test_http_실패시_예외(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response([], 500)]); + + $this->expectException(BizppurioApiException::class); + $this->client()->getSenderProfiles(); + } + + public function test_http_실패시_카카오_message와_code를_예외에_싣는다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response( + ['code' => '403', 'message' => '접근할 수 없는 IP 입니다. (114.207.113.206)'], + 403, + ), + ]); + + try { + $this->client()->getSenderProfiles(); + $this->fail('BizppurioApiException 이 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame('접근할 수 없는 IP 입니다. (114.207.113.206)', $e->getMessage()); + $this->assertSame('403', $e->getResultCode()); + $this->assertSame(403, $e->getHttpStatus()); + } + } + + public function test_http_실패_body에_message가_없으면_일반문구로_폴백한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response([], 500)]); + + try { + $this->client()->getSenderProfiles(); + $this->fail('BizppurioApiException 이 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame( + __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), + $e->getMessage(), + ); + $this->assertNull($e->getResultCode()); + $this->assertSame(500, $e->getHttpStatus()); + } + } + + 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(); + parent::tearDown(); + } +} 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..e8fc1077 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php @@ -0,0 +1,788 @@ + $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'); + } + + /** + * 검수자 전달 의견은 kapi request 의 comment 로만 실린다 (#597 §18.7 — 제품 결정 2026-08-23). + * + * 카카오 심사 가이드가 요구하는 변수 '예시 텍스트' 를 전할 유일한 통로가 request comment 다. + * 등록(add) 페이로드에는 섞이지 않고, 행에도 남지 않는다. + * + * @effects inspection_request_carries_reviewer_comment + */ + public function test_검수_신청의_comment는_kapi_request에만_실린다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + $template = $this->row(); + + $this->makeService()->requestInspection($template, ' 변수 예시: #{name}=홍길동, #{order_number}=20260823-0001 '); + + Http::assertSent(function ($request) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/request') { + return false; + } + $this->assertSame('변수 예시: #{name}=홍길동, #{order_number}=20260823-0001', $request->data()['comment'] ?? null, '앞뒤 공백만 제거하고 원문 그대로 싣는다.'); + + return true; + }); + Http::assertSent(function ($request) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/add') { + return false; + } + $this->assertArrayNotHasKey('comment', $request->data(), 'comment 는 등록(add) 페이로드에 섞이지 않는다.'); + + return true; + }); + } + + /** + * @effects inspection_request_carries_reviewer_comment + */ + public function test_검수_신청의_comment가_공백이면_kapi_request에_comment_키를_싣지_않는다(): void + { + Http::fake([self::KAPI => Http::response($this->kapiResponse())]); + + $this->makeService()->requestInspection($this->row(), ' '); + + Http::assertSent(function ($request) { + if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/request') { + return false; + } + $this->assertArrayNotHasKey('comment', $request->data(), '공백만 있는 의견은 미전달 — 클라이언트가 빈 comment 를 생략한다.'); + + return true; + }); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php new file mode 100644 index 00000000..6d453d83 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php @@ -0,0 +1,253 @@ + $store 참조로 전달되는 캐시 저장소 + */ + private function makeCache(array &$store): CacheInterface + { + $mock = Mockery::mock(CacheInterface::class); + + $mock->shouldReceive('remember')->andReturnUsing( + function (string $key, callable $callback) use (&$store) { + if (! array_key_exists($key, $store)) { + $store[$key] = $callback(); + } + + return $store[$key]; + } + ); + + $mock->shouldReceive('forget')->andReturnUsing( + function (string $key) use (&$store) { + unset($store[$key]); + + return true; + } + ); + + $mock->shouldReceive('put')->andReturnUsing( + function (string $key, $value) use (&$store) { + $store[$key] = $value; + + return true; + } + ); + + return $mock; + } + + /** + * bizppurio_id/password/is_test_mode 를 반환하는 설정 mock. + * + * @param array $settings + */ + private function makeSettings(array $settings): PluginSettingsService + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); + + return $mock; + } + + public function test_토큰_발급_후_캐시에서_재사용된다(): void + { + Http::fake([ + '*/v1/token' => Http::response(['accesstoken' => 'TOKEN_A', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), + ); + + $this->assertSame('TOKEN_A', $service->getToken()); + // 두 번째 호출은 캐시에서 → HTTP 1회만 + $this->assertSame('TOKEN_A', $service->getToken()); + Http::assertSentCount(1); + } + + public function test_refresh는_forget_후_재발급한다(): void + { + $tokens = ['TOKEN_A', 'TOKEN_B']; + Http::fake([ + '*/v1/token' => Http::sequence() + ->push(['accesstoken' => 'TOKEN_A', 'type' => 'Bearer'], 200) + ->push(['accesstoken' => 'TOKEN_B', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), + ); + + $this->assertSame('TOKEN_A', $service->getToken()); + // refresh → 캐시 무효화 후 새 토큰 + $this->assertSame('TOKEN_B', $service->refreshToken()); + Http::assertSentCount(2); + } + + public function test_검수_모드는_dev_도메인을_호출한다(): void + { + Http::fake([ + 'dev-api.bizppurio.com/*' => Http::response(['accesstoken' => 'T', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), + ); + + $service->getToken(); + Http::assertSent(fn ($request) => str_contains($request->url(), 'dev-api.bizppurio.com')); + } + + public function test_운영_모드는_live_도메인을_호출한다(): void + { + Http::fake([ + 'api.bizppurio.com/*' => Http::response(['accesstoken' => 'T', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => false]), + ); + + $service->getToken(); + Http::assertSent(fn ($request) => str_contains($request->url(), 'api.bizppurio.com') + && ! str_contains($request->url(), 'dev-api.bizppurio.com')); + } + + public function test_자격증명_미설정시_예외(): void + { + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => '', 'password' => '']), + ); + + $this->expectException(BizppurioApiException::class); + $service->getToken(); + } + + public function test_http_실패시_예외(): void + { + Http::fake(['*/v1/token' => Http::response([], 500)]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), + ); + + $this->expectException(BizppurioApiException::class); + $service->getToken(); + } + + public function test_verifyCredentials는_캐시를_거치지_않고_재발급한다(): void + { + Http::fake([ + '*/v1/token' => Http::response(['accesstoken' => 'FRESH_TOKEN', 'type' => 'Bearer'], 200), + ]); + + $store = ['bizppurio:token' => 'STALE_TOKEN']; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), + ); + + $this->assertSame('FRESH_TOKEN', $service->verifyCredentials()); + Http::assertSentCount(1); + $this->assertSame('FRESH_TOKEN', $store['bizppurio:token']); + } + + public function test_verifyCredentials_실패시_예외를_전파하고_캐시를_건드리지_않는다(): void + { + Http::fake(['*/v1/token' => Http::response([], 500)]); + + $store = ['bizppurio:token' => 'EXISTING_TOKEN']; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), + ); + + try { + $service->verifyCredentials(); + $this->fail('예외가 발생해야 합니다.'); + } catch (BizppurioApiException $e) { + // 실패 시 캐시는 그대로 보존 — 발송이 기존 토큰(만료 전까지)을 계속 사용 + $this->assertSame('EXISTING_TOKEN', $store['bizppurio:token']); + } + } + + public function test_토큰_발급_실패시_비즈뿌리오_사유가_메시지에_포함된다(): void + { + Http::fake([ + '*/v1/token' => Http::response(['code' => '3007', 'description' => 'invalid password'], 401), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), + ); + + try { + $service->getToken(); + $this->fail('예외가 발생해야 합니다.'); + } catch (BizppurioApiException $e) { + $this->assertStringContainsString('invalid password', $e->getMessage()); + $this->assertSame('3007', $e->getResultCode()); + } + } + + public function test_토큰_발급_실패시_사유_없으면_고정_메시지로_폴백한다(): void + { + Http::fake(['*/v1/token' => Http::response([], 500)]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), + ); + + try { + $service->getToken(); + $this->fail('예외가 발생해야 합니다.'); + } catch (BizppurioApiException $e) { + $this->assertSame( + __('sirsoft-message_bizppurio::messages.error.token_issue_failed'), + $e->getMessage() + ); + $this->assertNull($e->getResultCode()); + } + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php new file mode 100644 index 00000000..c6608454 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php @@ -0,0 +1,145 @@ +service = new DispatchResultService( + new BizppurioDispatchRepository, + new ResultCodeResolver, + ); + } + + /** + * 코어 로그 id 에 연결된 dispatch 1건을 만듭니다. + * + * @param array $overrides dispatch 오버라이드 + */ + private function linkedDispatch(int $logId, array $overrides = []): BizppurioDispatch + { + return BizppurioDispatch::create(array_merge([ + 'refkey' => 'r'.uniqid(), + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'x', + 'notification_type' => 'welcome', + 'notification_log_id' => $logId, + 'status' => 'success', + 'result_code' => '4100', + 'source' => 'auto', + 'sent_at' => now(), + ], $overrides)); + } + + public function test_성공_결과를_로그id_키_맵으로_반환한다(): void + { + $this->linkedDispatch(10); + + $results = $this->service->resultsForLogIds([10]); + + $this->assertArrayHasKey(10, $results); + $this->assertSame('success', $results[10]['status']); + $this->assertSame('4100', $results[10]['result_code']); + $this->assertFalse($results[10]['is_low_balance']); + // 민감정보(전화번호)는 결과에 포함하지 않는다. + $this->assertArrayNotHasKey('to_number', $results[10]); + } + + public function test_잔액부족_코드는_is_low_balance_true다(): void + { + $this->linkedDispatch(20, ['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']); + + $results = $this->service->resultsForLogIds([20]); + + $this->assertTrue($results[20]['is_low_balance']); + } + + public function test_대체발송_상태가_결과에_포함된다(): void + { + $this->linkedDispatch(30, ['fallback_status' => '성공']); + + $results = $this->service->resultsForLogIds([30]); + + $this->assertSame('성공', $results[30]['fallback_status']); + } + + public function test_result_code_없으면_result_label_null이다(): void + { + $this->linkedDispatch(40, ['status' => 'sent', 'result_code' => null]); + + $results = $this->service->resultsForLogIds([40]); + + $this->assertNull($results[40]['result_label']); + $this->assertNotNull($results[40]['status_label']); // 상태 라벨은 있음(발송중) + } + + public function test_검수_모드_스냅샷이_결과에_포함된다(): void + { + $this->linkedDispatch(70, ['is_test_mode' => true]); + + $results = $this->service->resultsForLogIds([70]); + + $this->assertTrue($results[70]['is_test_mode']); + } + + public function test_검수_모드_스냅샷이_없는_과거_이력은_null이다(): void + { + $this->linkedDispatch(71, ['is_test_mode' => null]); + + $results = $this->service->resultsForLogIds([71]); + + $this->assertNull($results[71]['is_test_mode']); + } + + public function test_매칭되지_않는_로그id는_맵에서_빠진다(): void + { + $this->linkedDispatch(50); + + $results = $this->service->resultsForLogIds([50, 999]); + + $this->assertArrayHasKey(50, $results); + $this->assertArrayNotHasKey(999, $results); + } + + public function test_recent_results_는_연결된_최근_결과를_로그id_키_맵으로_반환한다(): void + { + $this->linkedDispatch(60, ['result_code' => '4100']); + $this->linkedDispatch(61, ['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']); + + $results = $this->service->recentResults(); + + $this->assertSame('success', $results[60]['status']); + $this->assertTrue($results[61]['is_low_balance']); + } + + /** + * 실제 발송 본문(dispatch.content)이 결과에 포함된다 — 알림톡은 코어 + * notification_logs.body(대체발송용 코어 템플릿 값)와 실제 카카오 발송 내용이 달라, + * 화면이 채널별로 구분해 실제 발송 내용을 보여줄 수 있도록 결과에 담아야 한다. + */ + public function test_실제_발송_본문이_결과에_포함된다(): void + { + $this->linkedDispatch(80, ['content' => '[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.']); + + $results = $this->service->resultsForLogIds([80]); + + $this->assertSame('[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.', $results[80]['content']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php new file mode 100644 index 00000000..af70caf9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php @@ -0,0 +1,172 @@ + $settings + */ + private function makeBuilder(array $settings): MessagePayloadBuilder + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get') + ->with(self::IDENTIFIER, Mockery::type('string'), Mockery::any()) + ->andReturnUsing(fn ($id, $key, $default) => $settings[$key] ?? $default); + + return new MessagePayloadBuilder($mock); + } + + public function test_sms_payload_구조(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildSms('01011112222', '테스트 본문', 'ref123'); + + $this->assertSame('acct01', $payload['account']); + $this->assertSame('sms', $payload['type']); + $this->assertSame('07012345678', $payload['from']); + $this->assertSame('01011112222', $payload['to']); + $this->assertSame('ref123', $payload['refkey']); + $this->assertSame('테스트 본문', $payload['content']['sms']['message']); + } + + public function test_lms_payload는_subject를_포함한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildLms('01011112222', '긴 본문', 'ref456', '제목'); + + $this->assertSame('lms', $payload['type']); + $this->assertSame('제목', $payload['content']['lms']['subject']); + $this->assertSame('긴 본문', $payload['content']['lms']['message']); + } + + public function test_lms_subject_null이면_생략된다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildLms('01011112222', '본문', 'ref789', null); + + $this->assertArrayNotHasKey('subject', $payload['content']['lms']); + } + + public function test_알림톡_payload는_senderkey와_templatecode를_포함한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + 'sender_key' => 'senderkey40', + ]); + + $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999', [ + 'button' => [['name' => '확인', 'type' => 'WL', 'url_mobile' => 'https://x']], + ]); + + $this->assertSame('at', $payload['type']); + $at = $payload['content']['at']; + $this->assertSame('senderkey40', $at['senderkey']); + $this->assertSame('TW_1234', $at['templatecode']); + $this->assertSame('알림톡 본문', $at['message']); + // extra(버튼) 병합 확인 + $this->assertSame('확인', $at['button'][0]['name']); + } + + public function test_forHistory는_sms_payload에서_to_refkey_type_message를_제외한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildSms('01011112222', '테스트 본문', 'ref123'); + $history = $builder->forHistory($payload); + + $this->assertArrayNotHasKey('to', $history); + $this->assertArrayNotHasKey('refkey', $history); + $this->assertArrayNotHasKey('type', $history); + $this->assertArrayNotHasKey('message', $history['content']['sms']); + // 다른 컬럼에 없는 값은 유지돼야 한다. + $this->assertSame('acct01', $history['account']); + $this->assertSame('07012345678', $history['from']); + } + + public function test_forHistory는_lms_payload에서_subject는_유지하고_message만_제외한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildLms('01011112222', '긴 본문', 'ref456', '제목'); + $history = $builder->forHistory($payload); + + $this->assertArrayNotHasKey('message', $history['content']['lms']); + $this->assertSame('제목', $history['content']['lms']['subject'], 'subject 는 다른 컬럼에 없으므로 유지돼야 한다.'); + } + + public function test_forHistory는_알림톡_payload에서_message만_제외하고_버튼_등_요소는_유지한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + 'sender_key' => 'senderkey40', + ]); + + $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999', [ + 'button' => [['name' => '확인', 'type' => 'WL', 'url_mobile' => 'https://x']], + ]); + $history = $builder->forHistory($payload); + + $at = $history['content']['at']; + $this->assertArrayNotHasKey('message', $at, '치환된 본문은 content 컬럼과 중복이라 제외돼야 한다.'); + $this->assertSame('senderkey40', $at['senderkey'], 'senderkey 는 다른 컬럼에 없으므로 유지돼야 한다.'); + $this->assertSame('TW_1234', $at['templatecode'], 'templatecode 는 다른 컬럼에 없으므로 유지돼야 한다.'); + $this->assertSame('확인', $at['button'][0]['name'], '버튼(요소)은 결함② URL 검증에 필요하므로 유지돼야 한다.'); + } + + public function test_forHistory는_대체발송_recontent_message도_제외한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + 'sender_key' => 'senderkey40', + ]); + + $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999'); + $payload['resend'] = ['first' => 'sms']; + $payload['recontent'] = ['sms' => ['message' => '대체 SMS 본문']]; + + $history = $builder->forHistory($payload); + + $this->assertArrayNotHasKey('message', $history['recontent']['sms'], '대체발송 본문도 개인식별 텍스트이므로 제외돼야 한다.'); + $this->assertSame(['first' => 'sms'], $history['resend'], 'resend 구조 자체는 다른 컬럼에 없으므로 유지돼야 한다.'); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php new file mode 100644 index 00000000..35fb3c85 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php @@ -0,0 +1,129 @@ +resolver = new ResultCodeResolver; + } + + /** + * 성공 코드(발송 1000 / SMS 4100 / LMS 6600 / 알림톡 7000 / 카카오 200)는 Success. + */ + public function test_success_codes_categorized_as_success(): void + { + foreach (['1000', '4100', '6600', '7000', '200'] as $code) { + $this->assertSame(ResultCategory::Success, $this->resolver->categorize($code), "코드 {$code}"); + $this->assertTrue($this->resolver->isSuccess($code), "코드 {$code}"); + } + } + + /** + * 잔액부족(9070 문자 / 7436 알림톡 / 9071 후불 한도초과)은 BalanceLow + isBalanceLow true. + */ + public function test_balance_low_codes(): void + { + foreach (['9070', '7436', '9071'] as $code) { + $this->assertSame(ResultCategory::BalanceLow, $this->resolver->categorize($code), "코드 {$code}"); + $this->assertTrue($this->resolver->isBalanceLow($code), "코드 {$code}"); + $this->assertFalse($this->resolver->isSuccess($code), "코드 {$code}"); + } + } + + /** + * 후불 한도초과(9071) 는 사유 문구가 lang 에 정의되어 코드만 노출되지 않는다. + */ + public function test_postpaid_limit_code_has_reason(): void + { + $this->assertNotNull($this->resolver->reason('9071')); + $this->assertStringContainsString('9071', $this->resolver->label('9071')); + } + + /** + * 최신 명세 정합화로 추가된 코드(IP 오류·수신거부·발신프로필 키 무효)에 사유가 정의돼 + * 코드만 노출되지 않는다. + */ + public function test_newly_added_codes_have_reason(): void + { + foreach (['3003', '3010', '4431', '7103'] as $code) { + $this->assertNotNull($this->resolver->reason($code), "코드 {$code} 사유 누락"); + } + } + + /** + * 재시도 대상으로 분류된 코드는 전부 사유가 lang 에 정의돼 있어야 한다(코드값만 노출 금지). + * + * 회귀: RETRYABLE_CODES 에 5002 가 분류돼 있었으나 lang/{ko,en}/result_codes.php 와 ja + * 번들에 5002 키가 누락되어 label('5002') 가 사유 없이 코드만 반환하던 결함. + */ + public function test_all_retryable_codes_have_reason(): void + { + foreach (['5002', '5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) { + $this->assertNotNull($this->resolver->reason($code), "코드 {$code} 사유 누락"); + } + } + + /** + * 일시오류 코드는 Retry. + * + * 공통(5002 요청 과다·5003/5004/5005/9000/3011/3013)에 더해, 알림톡 일시오류(7306 카카오 + * 시스템오류·7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 재시도 대상이다. + * 7305(성공 불확실)는 이미 발송됐을 수 있어 중복발송 위험이 있으므로 재시도 대상에서 제외한다. + */ + public function test_retryable_codes_categorized_as_retry(): void + { + foreach (['5002', '5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) { + $this->assertSame(ResultCategory::Retry, $this->resolver->categorize($code), "코드 {$code}"); + } + } + + /** + * 7305(성공 불확실)는 중복발송 위험으로 재시도 대상이 아니다(영구실패로 분류). + */ + public function test_success_uncertain_7305_is_not_retryable(): void + { + $this->assertNotSame(ResultCategory::Retry, $this->resolver->categorize('7305')); + } + + /** + * 위 분류에 없는 코드는 영구 실패. + */ + public function test_unknown_and_failure_codes_are_permanent_failure(): void + { + foreach (['4400', '6606', '7204', '2000', '9999'] as $code) { + $this->assertSame(ResultCategory::PermanentFailure, $this->resolver->categorize($code), "코드 {$code}"); + } + } + + /** + * reason 은 lang 정의 코드는 사유를, 미정의 코드는 null 을 반환. + */ + public function test_reason_resolves_defined_codes_and_null_for_unknown(): void + { + $this->assertSame('음영 지역', $this->resolver->reason('4400')); + $this->assertNull($this->resolver->reason('9999')); + } + + /** + * label 은 정의 코드는 "사유 (코드)", 미정의 코드는 코드만 반환. + */ + public function test_label_formats_reason_with_code(): void + { + $this->assertSame('음영 지역 (4400)', $this->resolver->label('4400')); + $this->assertSame('9999', $this->resolver->label('9999')); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php new file mode 100644 index 00000000..2275afbb --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -0,0 +1,469 @@ +|null $smsBody 로케일 맵 또는 ko 본문 + */ + 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 $row; + } + + /** + * findByType 반환값을 지정한 driver 를 만듭니다. + */ + private function makeDriver( + ?BizppurioTemplate $row, + ?MessagePayloadBuilder $builder = null, + bool $isTestMode = true, + ?NotificationDefinition $definition = null, + ): SmsChannelDriver { + $templates = Mockery::mock(BizppurioTemplateRepositoryInterface::class); + $templates->shouldReceive('findByType')->andReturn($row); + + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get') + ->with('sirsoft-message_bizppurio', 'is_test_mode', true) + ->andReturn($isTestMode); + + // 스킵 예외 메시지의 알림 유형 라벨 조회 — 정의 없음(null)이면 resolveTypeLabel 이 코드값으로 폴백. + $definitionService = Mockery::mock(NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->andReturn($definition); + + return new SmsChannelDriver( + $definitionService, + $templates, + new AlimtalkPayloadMapper, + new SmsTypeResolver, + $builder ?? $this->spyBuilder(), + new BizppurioDispatchRepository, + new DispatchLinkContext, + $pluginSettings, + ); + } + + /** + * build* 호출 인자를 기록하는 payload 빌더 스파이를 만듭니다. + */ + private function spyBuilder(): MessagePayloadBuilder + { + return new class extends MessagePayloadBuilder + { + public array $calls = []; + + public function __construct() {} + + public function buildSms(string $to, string $message, string $refkey): array + { + $this->calls[] = ['type' => 'sms', 'to' => $to, 'message' => $message, 'refkey' => $refkey]; + + return ['type' => 'sms', 'to' => $to, 'refkey' => $refkey]; + } + + public function buildLms(string $to, string $message, string $refkey, ?string $subject = null): array + { + $this->calls[] = ['type' => 'lms', 'to' => $to, 'message' => $message, 'refkey' => $refkey, 'subject' => $subject]; + + return ['type' => 'lms', 'to' => $to, 'refkey' => $refkey]; + } + }; + } + + private function notification(array $data = []): GenericNotification + { + return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); + } + + /** + * @effects sms_skipped_when_body_empty_or_row_missing + */ + public function test_템플릿_행이_없으면_예외를_던지고_발송하지_않는다(): void + { + Bus::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); + } + } + + /** + * @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']); + + $this->expectException(NotificationSendSkippedException::class); + + try { + $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->templateRow(), $builder)->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertSame('01012345678', $builder->calls[0]['to']); + } + + public function test_비회원은_data의_전화번호로_발송한다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + $data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; + $builder = $this->spyBuilder(); + + $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->templateRow())->send($member, $this->notification()); + + $this->assertDatabaseCount('bizppurio_dispatches', 1); + $dispatch = BizppurioDispatch::first(); + $this->assertSame('pending', $dispatch->status->value); + $this->assertSame($member->id, $dispatch->to_user_id); + $this->assertSame('order_confirmed', $dispatch->notification_type); + } + + public function test_비회원_발송_이력은_회원id가_null이다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + $data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; + + $this->makeDriver($this->templateRow())->send($guest, $this->notification($data)); + + $this->assertNull(BizppurioDispatch::first()->to_user_id); + } + + public function test_발송하지_않으면_이력도_생성하지_않는다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + + try { + $this->makeDriver($this->templateRow())->send($guest, $this->notification()); + } catch (NotificationSendSkippedException) { + // 전화번호 없음 skip — 이력 미생성 검증이 목적 + } + + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } + + public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($this->templateRow())->send($guest, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } + } + + /** + * @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']); + + $shortBuilder = $this->spyBuilder(); + $this->makeDriver($this->templateRow(smsBody: '짧은 본문'), $shortBuilder) + ->send($member, $this->notification()); + $this->assertSame('sms', $shortBuilder->calls[0]['type']); + + $longBuilder = $this->spyBuilder(); + $this->makeDriver($this->templateRow(smsBody: str_repeat('가', 100)), $longBuilder) + ->send($member, $this->notification()); + $this->assertSame('lms', $longBuilder->calls[0]['type']); + } + + public function test_generic_notification이_아니면_무시한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + $this->makeDriver($this->templateRow())->send($member, new Notification); + + Bus::assertNotDispatched(SendMessageJob::class); + } + + public function test_검수_모드_설정값이_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->templateRow(), isTestMode: true)->send($member, $this->notification()); + + $this->assertTrue(BizppurioDispatch::first()->is_test_mode); + } + + public function test_운영_모드_설정값도_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $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/SmsTypeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php new file mode 100644 index 00000000..32832a0e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php @@ -0,0 +1,63 @@ +resolver = new SmsTypeResolver; + } + + public function test_영문_짧은_본문은_sms(): void + { + // 영문 45바이트 → SMS + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('a', 45))); + } + + public function test_한글은_euckr_2바이트로_계산된다(): void + { + // 한글 45자 = 90byte → 경계 안(SMS) + $this->assertSame(90, $this->resolver->byteLength(str_repeat('가', 45))); + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('가', 45))); + + // 한글 46자 = 92byte → SMS 경계 초과 → LMS + $this->assertSame(92, $this->resolver->byteLength(str_repeat('가', 46))); + $this->assertSame(DispatchChannel::Lms, $this->resolver->resolve(str_repeat('가', 46))); + } + + public function test_영문_90바이트_경계와_초과(): void + { + // 정확히 90byte → SMS + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('a', 90))); + // 91byte → LMS + $this->assertSame(DispatchChannel::Lms, $this->resolver->resolve(str_repeat('a', 91))); + } + + public function test_이모지는_제거되어_byte에_포함되지_않는다(): void + { + // 영문 10 + 이모지 → 이모지 제거 후 10byte 만 계산(EUC-KR 미표현 제거) + $this->assertSame(10, $this->resolver->byteLength('aaaaaaaaaa😀')); + // 이모지만 있는 경우 0byte → SMS + $this->assertSame(0, $this->resolver->byteLength('😀😀')); + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve('😀')); + } + + public function test_lms_한도_초과_판정(): void + { + // 영문 2000byte → 한도 이내(false) + $this->assertFalse($this->resolver->exceedsLmsLimit(str_repeat('a', 2000))); + // 2001byte → 초과(true) + $this->assertTrue($this->resolver->exceedsLmsLimit(str_repeat('a', 2001))); + } +} 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..375df5c6 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-template-lifecycle.yaml @@ -0,0 +1,156 @@ +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 규칙) + 행 하단 상태 요약(승인 2단 배지) + 코어 [편집] 모달에 녹인 알림톡·SMS 섹션(통합 저장, + 4×4 유형) + 플러그인 관리 화면(DB 목록). 제품 결정 2026-08-23: 별도 작성/SMS 모달 폐지 + + 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 + - inspection_request_carries_reviewer_comment + - 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 — 제품 결정 2026-08-23: 편집 모달 통합) + - bizppurio_tab_replaces_sms_and_alimtalk_tabs + - tab_visible_when_any_of_tab_channels_active + - row_footer_shows_status_summary_only + - approval_badge_two_tier + - edit_modal_hosts_alimtalk_and_sms_sections + - core_modal_hides_editor_for_hidden_template_editor_channel + - unified_save_chain_alimtalk_then_core_recipients + - compose_modal_switches_conditional_fields_by_type + - manage_screen_lists_db_rows_with_merge_query_round_trip + # 관리 목록 반응형 (제품 결정 2026-08-24) — portable 카드 전환 + 도구줄 btn 계열 통일 + - manage_list_mobile_card_view_and_unified_toolbar + - sms_modal_saves_body_via_delivery_upsert + - sms_modal_edits_body_per_locale_tab + # 코어 [기본값 복원] — 플러그인이 시드한 alimtalk/sms 행도 복원된다 (§18.7 C2 실측 결함) + - core_reset_restores_seeded_channel_template_defaults + # 업로드 진행 상태 배선 (라운드 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: 코어 알림 설정에서 문자·알림톡 개별 탭이 사라지고 '비즈뿌리오' 통합 탭이 노출되며, 행 하단에 승인 2단 배지 + SMS 설정 요약만 렌더된다 (버튼 0, i18n 원문 노출 0) + effects: + - bizppurio_tab_replaces_sms_and_alimtalk_tabs + - row_footer_shows_status_summary_only + - approval_badge_two_tier + - id: bizppurio_edit_modal_e2e + description: 행 [편집]을 누르면 코어 모달의 제목/본문이 숨고 알림톡 템플릿·문자(SMS) 섹션이 렌더되며, 하단 [저장] 한 번으로 알림톡 내용·발송 설정·SMS 본문(·바뀐 수신자 규칙)이 저장된다 + effects: + - edit_modal_hosts_alimtalk_and_sms_sections + - core_modal_hides_editor_for_hidden_template_editor_channel + - unified_save_chain_alimtalk_then_core_recipients + - inspection_request_carries_reviewer_comment + - 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 + - manage_list_mobile_card_view_and_unified_toolbar + +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/tests/Feature/Notification/CoreTemplateResetDefaultsTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.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 + # 코어 [편집] 모달의 확장 지점 2종 + hidden_template_editor 게이트 — 소유 확장(템플릿/모듈)의 자기 러너가 수집한다 + - templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-notification-template-modal-extension.test.ts + - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts + - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts + # 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 new file mode 100644 index 00000000..a40bf24b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml @@ -0,0 +1,73 @@ +# audit:allow scenario-scale-axis-required reason: SendMessageJob 은 알림 1건당 1 dispatch(payload 1건·refkey 1건)로 볼륨 무관 상수 메모리다. 루프/chunk/대량 적재가 없어 #79 형 붕괴 구조가 아니므로 scale 축이 무의미하다. +# audit:allow test-scenario-coverage reason: | +# 발송 결과 기록·webhook 수신·결과코드 해석의 axes cross product 는 PHPUnit +# (BizppurioWebhookTest·ResultCodeResolverTest·SmsChannelDriverTest·SendMessageJobTest· +# BizppurioDispatchTest·리포지토리·Installation)로 실제 HTTP/DB 왕복을 관찰한다. +# 발송 이력의 "표시 화면"은 이번 단계에 두지 않는다 — 코어 알림 발송 이력 화면에 비즈뿌리오 +# 결과(도달·실패사유)를 얹는 방식(A-2, 후속 Phase)으로 이관하기로 결정했기 때문이다. +# 따라서 이 매니페스트는 결과 저장·수신·해석까지만 커버하며, 표시 UI axis/effects 는 +# A-2 착수 시 그 화면(코어 이력 + 주입)에 대한 매니페스트로 추가한다. + +feature: 비즈뿌리오 발송 결과 기록 + webhook 수신 + 결과코드 (Phase 4) + +description: | + 발송 결과를 bizppurio_dispatches 에 기록하고, 비즈뿌리오 webhook(URL PUSH)으로 상태를 + 갱신하며, 결과코드를 사유+분류로 해석한다. (표시 화면은 A-2 후속에서 코어 이력에 통합) + + 핵심 동작: + - 발송 시 SmsChannelDriver 가 pending 이력 생성(회원=to_user_id, 비회원=null) + - SendMessageJob 이 발송 응답으로 sent(+messagekey) 또는 failed(+result_code) 갱신 + - webhook: IP 화이트리스트(115.71.53.78/79/94/95, 환경 우회 없음) → refkey 매칭 → + replay 멱등(reported_at) → RESULT 분류 → 상태전이 + media/fallback/raw_payload/reported_at + - 잔액부족(9070 문자 / 7436 알림톡): failed 뒤집기 + 관리자 자체알림(쿨다운 내 1회) + - webhook 은 코어 토큰/IDV 미들웨어 제외(라우트 withoutMiddleware) — 비인증 실 POST 200 + +axes: + channel: [sms, lms, alimtalk] + result_category: [success, failure, retry, balance_low] + recipient_kind: [member, guest] + webhook_ip: [allowed, blocked] + +exclusions: + - { webhook_ip: blocked, result_category: success, reason: "차단 IP 는 결과코드와 무관하게 403 — 분류 조합 무의미" } + - { channel: alimtalk, result_category: retry, reason: "알림톡 발송 드라이버는 Phase 6 — 이번 범위는 채널 등록/이력 스키마까지" } + +effects: + # 발송 시 이력 생성 (백엔드) + - sms_send_creates_pending_dispatch_with_member_user_id + - guest_send_creates_pending_dispatch_with_null_user_id + - skip_send_creates_no_dispatch + # 발송 응답 → 이력 갱신 (백엔드) + - send_success_marks_sent_and_stores_messagekey + - send_permanent_failure_marks_failed_with_result_code + - send_retryable_throws_for_queue_retry + # webhook 수신 (백엔드) + - allowed_ip_success_report_updates_status_to_success + - allowed_ip_failure_report_marks_failed + - blocked_ip_returns_403_and_leaves_dispatch_unchanged + - unknown_refkey_absorbed_with_200 + - replay_report_is_idempotent + - balance_low_marks_failed_and_fires_admin_notification_once + - balance_low_notification_deduplicated_within_cooldown_but_all_dispatches_marked_failed + - webhook_bypasses_auth_middleware_returns_200 + # 결과코드 해석 (백엔드) + - success_codes_categorized_as_success + - balance_low_codes_9070_7436_categorized_as_balance_low + - retryable_codes_categorized_as_retry + - unknown_codes_categorized_as_permanent_failure + - label_formats_reason_with_code + # DB (마이그레이션) + - phase4_tables_created_with_expected_columns + - migration_roundtrip_up_down_up_preserves_repository_ops + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php + - 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/Feature/Installation/InstallationTest.php + +audit: + status: codified diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml new file mode 100644 index 00000000..af0a9a93 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml @@ -0,0 +1,91 @@ +# audit:allow test-scenario-coverage reason: | +# A-2 는 코어 알림 발송 이력 화면에 비즈뿌리오 결과(도달·실패사유·잔액부족·대체발송)를 얹는 +# 작업이다(코어 앱·코어 테이블 무수정). axes cross product 의 런타임 효과는 계층별로 전수 가드된다: +# - 연결고리(코어 로그↔dispatch): NotificationLogLinkTest — 실제 훅 체인(after_log_sent/ +# after_log_failed)으로 dispatch.notification_log_id 연결, 비-비즈뿌리오 로그 미연결, refkey +# consume 후 비워짐(오매칭 방지)까지 관찰. +# - 링크·배치조회 Repository: BizppurioDispatchRepositoryTest — link/미존재/keyed/빈배열. +# - 결과 표시 파생(상태·사유(코드)·잔액부족·대체발송·미매칭 제외): DispatchResultServiceTest. +# - 결과 조회 API(권한·검증·성공/실패/잔액부족/미매칭): DispatchResultEndpointTest. +# - overlay 결과 컬럼 실제 렌더(성공/잔액부족/대체발송/빈셀 조건부 렌더): notification_log_result.test.tsx. +# e2e_* 축(브라우저 왕복: 코어 이력 화면 진입→결과 컬럼 확인→회원 링크/배지)은 실제 발송·webhook +# 왕복 결과가 필요해, 계획서 §9 방침대로 직접 수행하는 실연동 검증 단계에서 Playwright spec 을 +# 그때 @scenario/@effects 로 이 매니페스트에 매핑한다. Vitest 렌더 테스트가 프론트 overlay 컬럼 +# 주입·조건부 렌더를 가드하므로 이번 단계는 spec 미도입 +# (frontend-change-requires-e2e 는 이 매니페스트의 e2e axis + Vitest 렌더 커버로 충족). + +feature: 비즈뿌리오 발송 결과를 코어 알림 발송 이력 화면에 주입 (A-2) + +description: | + 비즈뿌리오로 보낸 문자·알림톡의 실제 결과(성공/실패·사유·잔액부족·대체발송)를 코어 "알림 발송 + 이력" 화면의 각 행에 붙여 한 화면에서 본다. 독립 페이지·메뉴 없음. 코어 앱·코어 테이블 무수정 — + 연결 표식은 비즈뿌리오 쪽(bizppurio_dispatches.notification_log_id)에만 두고, layout_extensions + overlay 로 결과 컬럼을 코어 이력 datagrid 에 _append 한다. + + 핵심 동작: + - 연결고리: 채널 드라이버가 발송 사이클에서 dispatch(refkey) 생성 후 DispatchLinkContext 에 + refkey 를 남기고, 직후 발화하는 코어 after_log_sent/after_log_failed 훅에서 + LinkNotificationLogListener 가 그 refkey 로 dispatch 에 코어 로그 id 를 기록한다(직접 표식, + 복합키 근사 매칭 아님 — 동일 시각 중복 발송 오매칭 방지). + - 결과 조회 API: 코어 이력 화면이 현재 페이지 로그 id 배열을 POST 로 넘기면(N+1 회피) 결과 맵 + 반환. 상태·`사유 (코드)`(ResultCodeResolver 재사용)·잔액부족 여부·대체발송 상태. 전화번호 등 + 민감정보 미포함(결과 컬럼엔 노출 안 함). + - overlay: 코어 datagrid columns 에 결과 컬럼 1개 _append. row.id 로 결과 맵 매칭. 매칭 안 되는 + 행(메일·DB 등 비-비즈뿌리오)은 빈 셀(-). + +axes: + channel: [sms, lms, alimtalk] + result_category: [success, failure, balance_low] + fallback: [none, present] + recipient_kind: [member, guest] + link_outcome: [linked, unmatched] + e2e_view_action: [open_history, view_result_cell, open_member] + +exclusions: + - { link_outcome: unmatched, result_category: success, reason: "미매칭(비-비즈뿌리오) 행은 결과 자체가 없어 result_category 조합 무의미 — 빈 셀 한 케이스로 충분" } + - { link_outcome: unmatched, fallback: present, reason: "미매칭 행은 대체발송 결과도 없음" } + - { recipient_kind: guest, e2e_view_action: open_member, reason: "비회원은 회원 상세 링크 없음(일반 텍스트) — 회원 축과 무관" } + +effects: + # 연결고리 (백엔드 — 실제 훅 체인) + - sent_log_links_dispatch_via_refkey_in_same_cycle + - failed_log_links_dispatch_via_refkey_in_same_cycle + - non_bizppurio_log_leaves_dispatch_unlinked + - refkey_consumed_once_prevents_cross_cycle_mismatch + # 링크·배치조회 Repository (백엔드) + - link_notification_log_by_refkey_sets_log_id + - link_missing_refkey_returns_false + - find_by_log_ids_returns_keyed_map_excluding_unmatched + # 결과 표시 파생 (백엔드) + - success_result_derives_status_and_reason_code_label + - balance_low_result_flags_is_low_balance_true + - fallback_status_surfaced_in_result + - missing_result_code_yields_null_result_label_with_status_label + - unmatched_log_id_excluded_from_result_map + - result_map_excludes_recipient_phone_masking_not_needed + # 결과 조회 API (백엔드) + - lookup_requires_messaging_view_permission + - lookup_requires_log_ids_array + - lookup_returns_results_keyed_by_log_id + # overlay 결과 컬럼 렌더 (프론트) + - overlay_appends_result_column_to_core_history_datagrid + - result_column_batch_fetches_by_current_page_log_ids + - success_cell_renders_reason_code_label + - balance_low_cell_renders_low_balance_badge + - non_low_balance_cell_omits_low_balance_badge + - fallback_cell_renders_fallback_badge + - unmatched_row_renders_empty_cell + # e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상) + - e2e_open_history_shows_result_column + - e2e_result_cell_shows_status_reason_and_badges + - e2e_member_recipient_links_to_user_detail + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx + +audit: + status: codified diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml new file mode 100644 index 00000000..bbe622e9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -0,0 +1,114 @@ +# audit:allow test-scenario-coverage reason: | +# 환경설정 화면의 입력·저장은 코어 /api/admin/plugins/{id}/settings 에 위임되는 자동바인딩 +# 폼이라, axes cross product 의 UI 구조·검증·i18n effects 는 Vitest(plugin_settings.test.tsx) +# 정적 검증으로 가드된다. 검수 모드 off(운영) 조건부 검증 왕복(422/200)과 리포트 수신 주소 +# 조회는 PHPUnit Feature(BizppurioLiveModeSettingsValidationTest·ReportUrlEndpointTest) 로 +# 실제 HTTP 왕복을 관찰한다. (§529) 연결 확인 API 왕복(200/422 + result_code)은 +# TokenCheckEndpointTest, 저장 시 토큰 캐시 무효화는 실제 훅 체인(core.plugin_settings. +# after_save) + 실제 CacheInterface 로 InvalidateTokenOnSettingsSaveListenerTest 가, +# verifyCredentials/describeFailure 는 BizppurioTokenServiceTest 가 각각 관찰한다. +# 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증(직접 수행, 인증·발송 +# 환경 필요) 단계에서 최초 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을 +# 추가한다. 그때까지 매니페스트는 명세로 유지한다. + +feature: 비즈뿌리오 메시지 발송 환경설정 (Phase 1) + +description: | + 관리자 환경설정 화면(admin/plugin_settings.json)에서 비즈뿌리오 연동 정보를 입력·저장한다. + 코어 플러그인 설정 인프라(/api/admin/plugins/{id}/settings)에 자동바인딩(_local.form)으로 + 위임하며, 상단 안내 패널 + 2 섹션(API 연동 / 발송 설정) + 리포트 수신 설정 카드로 구성된다. + + 핵심 동작: + - 검수 모드: is_test_mode Toggle (독립 카드). 켜짐=검수(dev) 도메인, 꺼짐=운영(live) 도메인 + - API 연동: 비즈뿌리오 아이디 + 비즈뿌리오 모듈 비밀번호 + API 키 + - 발송 설정: 발신번호 + 알림톡 발신프로필 키 + - 검수 모드를 끄면(운영) 경고 박스를 표시하고, 저장 시 발송 필수 자격증명 + (bizppurio_id/password/sender_number)을 required 로 강제(조건부 검증 필터 훅) + - 저장 실패 시 각 필드 하단에 인라인 에러 메시지 표시 + - 리포트 수신 설정: 발송 결과 수신 주소를 사이트 도메인 기준 절대 URL 로 조회·복사 + - 크리덴셜(password/api_key/sender_key)은 type=password 마스킹 + frontend_schema expose:false + - 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT + - 설치·활성화 시 권한 2개(view/manage)·잔액부족 알림 정의 생성 + 설정 초기값 로드 + (관리자 메뉴는 추가하지 않음 — ⚑ 결정 3, 진입은 코어 소유 설정 페이지 하나) + - (§529) 저장 성공 시 인증 토큰 캐시를 무조건 무효화 — 비밀번호 변경 후 옛 자격증명으로 + 발급된 토큰이 만료(23h)까지 재사용되는 것을 방지 + - (§529) "연결 확인" 버튼 — 비밀번호 필드와 분리된 독립 필드. 저장된 자격증명으로 + 캐시를 거치지 않고 즉시 `/v1/token` 재검증. hasChanges=true(미저장 변경 존재)면 + API 호출 없이 "먼저 저장해주세요" toast 만 표시. 결과는 toast 로만 안내(상시 표시 없음) + + ※ 리포트 실제 수신 처리(POST /webhook)·알림톡 채널 기본 body 시드는 Phase 4~6 으로 이관. + +axes: + credential_state: [empty, filled] + test_mode: [on, off] + field_kind: [credential, plain, readonly] + connection_check_result: [valid_credentials, invalid_credentials, unsaved_changes] + +exclusions: + - { field_kind: readonly, credential_state: filled, reason: "리포트 수신 주소는 서버 조회 표시값 — 운영자 입력 대상 아님" } + - { connection_check_result: unsaved_changes, credential_state: empty, reason: "unsaved_changes 분기는 hasChanges 게이트만 검증 — 자격증명 값 자체는 무관(가드가 API 호출 자체를 막음)" } + +effects: + # 섹션 구조 + - info_panel_test_mode_api_sending_report_sections_present + - test_mode_card_has_is_test_mode_toggle + - test_mode_card_shows_separate_account_notice_always + - live_mode_warning_shown_when_test_mode_off + # 입력 필드 + - five_input_fields_bound_by_name + - credential_fields_masked_as_password + - plain_fields_use_text_input + - inline_error_node_present_per_field + # 조건부 검증 (백엔드) + - test_mode_off_empty_credentials_return_422_for_bizppurio_id_password_sender_number + - test_mode_off_does_not_require_api_key_or_sender_key + - test_mode_on_empty_credentials_return_200 + - test_mode_off_filled_credentials_return_200 + - validation_error_messages_use_localized_field_labels + - test_mode_off_selects_live_domain_test_mode_on_selects_dev_domain + # 리포트 수신 설정 + - report_url_endpoint_returns_absolute_url_from_app_url + - report_url_endpoint_requires_authentication + - report_section_shows_readonly_url_with_copy_button + # 저장 + - save_button_disabled_without_changes + - save_puts_to_core_plugin_settings_api + - only_registered_handlers_used + # 연결 확인 + 저장 시 토큰 캐시 무효화 (§529) + - password_field_label_distinguishes_module_password_from_g7_login_password + - connection_check_field_is_separate_node_from_password_field + - connection_check_button_shows_plug_icon_idle_spinner_icon_loading + - connection_check_calls_token_check_api_only_when_no_unsaved_changes + - connection_check_shows_unsaved_changes_toast_without_api_call_when_has_changes + - connection_check_result_shown_via_toast_only_no_persistent_indicator + - token_check_endpoint_returns_200_on_valid_credentials + - token_check_endpoint_returns_422_with_bizppurio_reason_and_result_code_on_failure + - token_check_endpoint_requires_authentication_and_manage_permission + - 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 조건부 패널 재평가 필요 + - tab_buttons_use_navigate_not_replaceurl # replaceUrl 은 URL만 바꿔 if 재평가 없음 → 화면 미전환 회귀 + # 구 '알림톡 템플릿'(kapi 실시간 목록 조회) 탭의 오류 배너 effect 는 제거했다 — 그 화면 자체가 + # #597 §3.5 에서 DB 기반 라이프사이클로 대체돼 사라졌고, 남겨 두면 존재하지 않는 화면을 + # 서술하는 계약이 된다. 현 오류 표면화 계약은 bizppurio-template-lifecycle.yaml 이 소유한다. + # 다국어 + - all_referenced_i18n_keys_exist_in_ko_and_en + # 설치·활성화 (백엔드) + - install_creates_view_and_manage_permissions + - activate_does_not_create_admin_menus # ⚑ 결정 3: 플러그인 메뉴 폐기, 진입은 설정 페이지 하나로 통일 + - balance_low_notification_targets_admin + - settings_defaults_load_with_test_mode_on + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php + +audit: + status: codified 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 new file mode 100644 index 00000000..a947679d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml @@ -0,0 +1,82 @@ +# audit:allow test-scenario-coverage reason: | +# Phase 3 는 코어 알림 채널 등록 + 문자 발송 위임 배선(백엔드)이다. axes cross product 의 +# 런타임 효과(채널 노출/readiness/전화번호 해석/발송 Job 위임)는 PHPUnit 로 전수 가드된다: +# RegisterNotificationChannelsListenerTest(14) + SmsChannelDriverTest(7) + +# GuestPhoneExtractListenerTest(6) + ChannelRegistrationHookTest(4, 실제 훅 체인). +# 프론트 변경 없음(레이아웃 JSON/컴포넌트 미변경) → frontend-change-requires-e2e 비대상이라 +# Playwright spec 은 이번 Phase 미도입. 실발송 왕복(실제 API·webhook)은 계획서 §9 방침대로 +# 직접 수행하는 실연동 검증 단계에서 spec 최초 도입하며, 그때 @scenario/@effects 를 매핑한다. + +feature: 비즈뿌리오 문자 채널 등록 + 자동발송 (Phase 3) + +description: | + 코어 알림 시스템에 sms·alimtalk 채널을 등록(코어 무수정 — filter 훅)하고, 3영역 + (코어/게시판/이커머스) 알림 이벤트 발생 시 문자를 자동발송한다. 실제 발송 실행·재시도는 + Phase 2 SendMessageJob 에 위임한다. + + 핵심 동작: + - filter_available_channels: sms·alimtalk 메타 노출(name_key/allow_guest:true) + - channel_readiness: 채널별 환경설정 충족 판정(sms=아이디+비번+발신번호, alimtalk=+api_key+sender_key) + - {core.auth, sirsoft-ecommerce, sirsoft-board}.notification.channels: 3영역 후보 확장 + - ChannelManager::extend('sms'): SmsChannelDriver 로 발송 위임(alimtalk 은 Phase 6, no-op 스텁) + - 전화번호 해석: 회원=users.mobile / 비회원=data 의 _recipient_phone(D1) + - 비회원 전화번호 주입: 이커머스 extract_data 구독으로 orderer_phone → data(GuestPhoneExtractListener) + - 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] + readiness_state: [ready, missing_credentials, missing_sender] + test_mode: [on, off] + +exclusions: + - { channel: alimtalk, recipient: guest, reason: "알림톡 실발송은 Phase 6 — 이번 Phase 는 채널 등록/readiness 만" } + - { readiness_state: missing_sender, channel: alimtalk, reason: "alimtalk readiness 는 자체 키(api_key/sender_key) 부족을 별도 판정 — sender 축은 sms 로 대표" } + +effects: + # 채널 등록 (filter_available_channels) + - sms_channel_exposed_in_available_channels + - alimtalk_channel_exposed_in_available_channels + - both_channels_allow_guest_true + - alimtalk_has_no_uses_custom_list_flag_yet + - existing_channel_not_duplicated + # readiness (channel_readiness) + - sms_ready_when_id_password_sender_number_set + - sms_not_ready_reports_credentials_missing_key + - sms_not_ready_reports_sender_number_missing_key + - alimtalk_not_ready_when_api_key_or_sender_key_missing + - non_owned_channel_readiness_passes_through + # 3영역 후보 (prefix.notification.channels) + - three_prefixes_subscribed_core_auth_ecommerce_board + - channel_candidates_deduplicated + # 발송 위임 (SmsChannelDriver) + - member_phone_resolved_from_mobile + - guest_phone_resolved_from_data_recipient_phone + - dispatch_skipped_when_no_template + - dispatch_skipped_when_no_phone + - dispatch_skipped_when_body_blank + - short_body_dispatched_as_sms + - long_body_dispatched_as_lms_with_subject + - send_message_job_dispatched_with_refkey + # 게스트 전화번호 주입 (extract_data) + - guest_order_phone_injected_into_data + - member_order_phone_not_injected + - non_order_notification_passthrough + # 훅 위생 + - all_channel_hooks_declared_as_filter_type + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php + +audit: + status: codified diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json b/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json new file mode 100644 index 00000000..170e1528 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["resources/js/*"] + } + }, + "include": ["resources/js/**/*.ts", "resources/js/**/*.tsx"] +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts new file mode 100644 index 00000000..215bb6b0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from 'vite'; +import path from 'path'; + +/** + * 비즈뿌리오 메시징 플러그인 프론트엔드 에셋 빌드 설정. + * + * 플러그인 엔트리(resources/js/index.ts)를 IIFE 번들로 빌드해 활성화 시 로드한다. + * 출력물은 dist/js/plugin.iife.js 이며 plugin.json assets.js.output 과 일치한다. + */ +export default defineConfig({ + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + + build: { + lib: { + entry: path.resolve(__dirname, 'resources/js/index.ts'), + name: 'SirsoftMessageBizppurio', + fileName: 'plugin', + formats: ['iife'], + }, + outDir: 'dist', + emptyOutDir: true, + // 소스맵 생성 + // 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다. + // 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다. + sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''), + rollupOptions: { + output: { + entryFileNames: 'js/plugin.iife.js', + chunkFileNames: 'js/[name]-[hash].js', + }, + }, + minify: 'esbuild', + target: 'es2020', + chunkSizeWarningLimit: 500, + }, + + resolve: { + alias: { + '@': path.resolve(__dirname, 'resources/js'), + }, + }, +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts new file mode 100644 index 00000000..ae686dc2 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + include: ['resources/js/__tests__/**/*.test.{ts,tsx}'], + setupFiles: ['./resources/js/__tests__/setup.ts'], + }, + resolve: { + alias: { + '@': resolve(__dirname, 'resources/js'), + '@core': resolve(__dirname, '../../../resources/js/core'), + }, + }, +}); 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 88cf888a..cb4e511e 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`] = `517`; exports[`BindingShape 라우팅 diff 하네스 > 판정이 갈리는 식의 분류별 건수 요약 1`] = ` { @@ -256,6 +256,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 표현식/경로 판정: 구 "modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json", "plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/ckeditor5_uploads.json", "plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json", + "plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json", "templates/_bundled/sirsoft-admin_basic/layouts/_admin_base.json", "templates/_bundled/sirsoft-admin_basic/layouts/admin_module_list.json", "templates/_bundled/sirsoft-admin_basic/layouts/admin_plugin_list.json", @@ -281,6 +282,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 표현식/경로 판정: 구 "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_mail_templates.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json", + "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_modal_manual_install.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_tab_admin.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_tab_user.json", diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index fd74cf5d..273887c7 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -4,12 +4,21 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.7] - 2026-08-22 +## [1.0.7] - 2026-08-24 ### Added - 환경설정 > SEO 탭에서 사이트 기본 공유 이미지(og:image)를 업로드할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 링크 미리보기 카드에 사용됩니다. +### Changed + +- 관리자 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 확장이 여러 발송 채널을 하나의 탭으로 묶어 표시할 수 있으며(예: 비즈뿌리오 문자·알림톡 → '비즈뿌리오' 단일 탭), 묶인 채널 중 하나라도 켜져 있으면 탭이 노출됩니다. +- 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다. + +### Fixed + +- 알림 설정 화면에서 「사이트맵 재생성 완료」·「사이트맵 재생성 실패」 알림의 이름이 다국어 키 원문으로 표시되던 문제를 수정했습니다. + ## [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/__tests__/layouts/admin-settings-notification-template-modal-extension.test.ts b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-notification-template-modal-extension.test.ts new file mode 100644 index 00000000..bfd46b73 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-notification-template-modal-extension.test.ts @@ -0,0 +1,138 @@ +// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio) +// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다. +/** + * 코어 알림 템플릿 [편집] 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (범용 seam) + * + * 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록: + * - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞) + * - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이) + * - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다 + * (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일. + * + * 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은 + * 기대값을 함께 고치면 통과한다. + * + * @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections + */ + +import { describe, it, expect } from 'vitest'; +import modal from '../../layouts/partials/admin_settings/_modal_notification_template_form.json'; + +type Node = Record; +const PREFIX = ''; +const STATE_KEY = 'notification_template_form_modal'; +const MODAL_ID = 'modal_notification_template_form'; +const REFETCH_ID = 'notificationDefinitions'; + +const findById = (node: unknown, id: string): Node | null => { + if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; } + if (node && typeof node === 'object') { + if ((node as Node).id === id) return node as Node; + for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; } + } + return null; +}; +const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => { + if (Array.isArray(node)) { + const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id); + if (idx >= 0) return { arr: node, idx }; + for (const n of node) { const r = findParentArray(n, id); if (r) return r; } + return null; + } + if (node && typeof node === 'object') { + for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; } + } + return null; +}; +const evalIf = (expr: string, scope: Record): boolean => { + const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + const names = Object.keys(scope); + // eslint-disable-next-line no-new-func + return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n]))); +}; +const scope = (channel: string, hidden: boolean | undefined, extra: Record = {}) => ({ + availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } }, + _global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } }, +}); + +const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId']; + +describe('알림 템플릿 편집 모달 — 확장 슬롯 2종', () => { + it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => { + const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`); + expect(loc).toBeTruthy(); + const { arr, idx } = loc!; + expect(arr[idx].type).toBe('extension_point'); + expect(arr[idx].name).toBe('notification_template_form_sections'); + expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`); + expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`); + }); + + it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const right = footer.children[1].children as Node[]; + expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']); + expect(right[1].name).toBe('notification_template_form_footer_actions'); + expect(JSON.stringify(right[0])).toContain('$t:common.cancel'); + expect(JSON.stringify(right[2])).toContain('$t:common.save'); + }); + + it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => { + const ep = findById(modal, id)!; + expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort()); + expect(ep.props.modalId).toBe(MODAL_ID); + expect(ep.props.stateKey).toBe(STATE_KEY); + expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID); + expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`); + expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`); + expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`); + expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`); + }); +}); + +describe('알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => { + const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info']; + + it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node.if, `${id} if`).toBeTruthy(); + expect(evalIf(node.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(node.if, scope('mail', undefined))).toBe(true); + expect(evalIf(node.if, scope('sms', false))).toBe(true); + }); + + it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_click_url_input`)!; + expect(evalIf(node.if, scope('database', undefined))).toBe(true); + expect(evalIf(node.if, scope('mail', undefined))).toBe(false); + expect(evalIf(node.if, scope('database', true))).toBe(false); + }); + + it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => { + const node = findById(modal, `${PREFIX}template_variables_info`)!; + const noVars = { ...scope('mail', undefined) } as Node; + noVars._global[STATE_KEY].definition = { variables: [] }; + expect(evalIf(node.if, noVars)).toBe(false); + }); + + it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => { + const footer = findById(modal, `${PREFIX}template_modal_footer`)!; + const preview = footer.children[0].children[0] as Node; + const right = footer.children[1].children as Node[]; + const cancel = right[0]; + const save = right[2]; + for (const b of [preview, save]) { + expect(evalIf(b.if, scope('alimtalk', true))).toBe(false); + expect(evalIf(b.if, scope('mail', undefined))).toBe(true); + } + expect(cancel.if).toBeUndefined(); + }); + + it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => { + for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) { + const node = findById(modal, `${PREFIX}${id}`)!; + expect(node, id).toBeTruthy(); + expect(String(node.if ?? '')).not.toContain('hidden_template_editor'); + } + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index a0961cea..8be629f6 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1858,7 +1858,9 @@ "types": { "welcome": "Welcome", "reset_password": "Password Reset", - "password_changed": "Password Changed" + "password_changed": "Password Changed", + "sitemap_regenerated": "Sitemap Regeneration Complete", + "sitemap_regenerate_failed": "Sitemap Regeneration Failed" }, "channels_label": "Channels:", "edit_modal": { diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index 636c0507..8b70a95d 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1862,7 +1862,9 @@ "types": { "welcome": "회원가입 환영", "reset_password": "비밀번호 재설정", - "password_changed": "비밀번호 변경 알림" + "password_changed": "비밀번호 변경 알림", + "sitemap_regenerated": "사이트맵 재생성 완료", + "sitemap_regenerate_failed": "사이트맵 재생성 실패" }, "channels_label": "채널:", "edit_modal": { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json index fb44af34..03582b6d 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_notification_template_form.json @@ -533,6 +533,21 @@ } ] }, + { + "id": "template_form_sections_ext", + "comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.", + "type": "extension_point", + "name": "notification_template_form_sections", + "props": { + "definition": "{{_global.notification_template_form_modal?.definition}}", + "template": "{{_global.notification_template_form_modal?.template}}", + "channel": "{{_global.notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_notification_template_form", + "stateKey": "notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "notificationDefinitions" + } + }, { "id": "template_lang_tabs", "type": "basic", @@ -564,7 +579,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "template_subject_input", @@ -621,7 +637,8 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "template_body_input", @@ -679,13 +696,14 @@ } ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" }, { "id": "template_click_url_input", "type": "basic", "name": "Div", - "if": "{{_global.notification_template_form_modal?.template?.channel === 'database'}}", + "if": "{{(_global.notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "props": { "className": "row-stack" }, @@ -738,7 +756,7 @@ "id": "template_variables_info", "type": "basic", "name": "Div", - "if": "{{(_global.notification_template_form_modal?.definition?.variables ?? []).length > 0}}", + "if": "{{((_global.notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}", "props": { "className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4" }, @@ -855,17 +873,18 @@ "target": "modal_notification_template_preview" } ], - "onError": [ - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{error.message}}" - } - } - ] + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ] } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] }, @@ -893,6 +912,21 @@ } ] }, + { + "id": "template_form_footer_ext", + "comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.", + "type": "extension_point", + "name": "notification_template_form_footer_actions", + "props": { + "definition": "{{_global.notification_template_form_modal?.definition}}", + "template": "{{_global.notification_template_form_modal?.template}}", + "channel": "{{_global.notification_template_form_modal?.template?.channel ?? ''}}", + "modalId": "modal_notification_template_form", + "stateKey": "notification_template_form_modal", + "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.notification_template_form_modal?.template?.id}}", + "refetchDataSourceId": "notificationDefinitions" + } + }, { "type": "basic", "name": "Button", @@ -992,7 +1026,8 @@ ] } } - ] + ], + "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}" } ] } 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": [ diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 47f13363..581444bd 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.2] - 2026-08-22 +## [1.1.2] - 2026-08-24 ### Changed diff --git a/tests/Feature/Api/Admin/Identity/AdminIdentityMessageControllerTest.php b/tests/Feature/Api/Admin/Identity/AdminIdentityMessageControllerTest.php index ce2649fe..e36f008d 100644 --- a/tests/Feature/Api/Admin/Identity/AdminIdentityMessageControllerTest.php +++ b/tests/Feature/Api/Admin/Identity/AdminIdentityMessageControllerTest.php @@ -104,7 +104,10 @@ class AdminIdentityMessageControllerTest extends TestCase $template->refresh(); $this->assertSame('운영자 제목', $template->subject['ko']); - $this->assertContains('subject', $template->user_overrides ?? []); + // subject/body 는 translatableTrackableFields — 수정이 dot-path sub-key 단위로 + // 기록되어야 시더 재실행·언어팩 병합이 수정된 로케일만 보존한다 (#597 교정) + $this->assertContains('subject.ko', $template->user_overrides ?? []); + $this->assertContains('subject.en', $template->user_overrides ?? []); $this->assertFalse($template->is_default, '운영자 편집 후 is_default 가 false 로 전환되어야 합니다.'); } diff --git a/tests/Feature/LanguagePack/IdentitySeedInjectionTest.php b/tests/Feature/LanguagePack/IdentitySeedInjectionTest.php index 4e86e3aa..5a6bd38d 100644 --- a/tests/Feature/LanguagePack/IdentitySeedInjectionTest.php +++ b/tests/Feature/LanguagePack/IdentitySeedInjectionTest.php @@ -5,7 +5,10 @@ namespace Tests\Feature\LanguagePack; use App\Enums\LanguagePackScope; use App\Enums\LanguagePackStatus; use App\Extension\HookManager; +use App\Models\IdentityMessageDefinition; +use App\Models\IdentityMessageTemplate; use App\Models\LanguagePack; +use App\Services\IdentityMessageTemplateService; use App\Services\LanguagePack\LanguagePackRegistry; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\File; @@ -39,6 +42,11 @@ class IdentitySeedInjectionTest extends TestCase parent::tearDown(); } + /** + * @scenario domain=identity, entry=seeder_filter + * + * @effects seeder_filter_injects_active_pack_locales + */ public function test_apply_filters_injects_ja_translation_when_active_pack_has_seed(): void { File::put($this->packRoot.'/seed/identity_messages.json', json_encode([ @@ -111,4 +119,77 @@ class IdentitySeedInjectionTest extends TestCase // 활성 ja 팩 없음 → 입력 그대로 반환 $this->assertSame($definitions, $result); } + + /** + * [기본값 복원] 경로 회귀 — IdentityMessageTemplateService::resetToDefault 는 + * core.identity.filter_default_message_definitions 필터의 기본값(ko/en)으로 + * subject/body 를 대체하므로, 언어팩 주입에 참여하지 않으면 팩이 주입해 둔 + * 로케일(ja)이 복원 시 영구 소실된다 (notification 쪽 실사례의 동형 — #597 보완 실측). + * + * @scenario domain=identity, entry=reset_default + * + * @effects identity_reset_default_preserves_lang_pack_locales + */ + public function test_reset_to_default_preserves_lang_pack_locales(): void + { + File::put($this->packRoot.'/seed/identity_messages.json', json_encode([ + 'mail.purpose.signup' => [ + 'definition' => ['name' => '会員登録認証', 'description' => ''], + 'templates' => ['mail' => ['subject' => '[アプリ] 認証コード', 'body' => '

{code}

']], + ], + ])); + + LanguagePack::query()->create([ + 'identifier' => 'test-feature-idv-ja', + 'vendor' => 'test', + 'scope' => LanguagePackScope::Core->value, + 'target_identifier' => null, + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'status' => LanguagePackStatus::Active->value, + 'is_protected' => false, + 'manifest' => [], + 'source_type' => 'zip', + ]); + $this->app->make(LanguagePackRegistry::class)->invalidate(); + + // config/core.php 실항목을 그대로 픽스처로 사용 — 복원 매칭(provider/scope) 정합 보장. + $cfg = config('core.identity_messages')['mail.purpose.signup']; + + $definition = IdentityMessageDefinition::query()->create([ + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'provider_id' => $cfg['provider_id'], + 'scope_type' => $cfg['scope_type'], + 'scope_value' => $cfg['scope_value'], + 'name' => $cfg['name'], + 'description' => $cfg['description'] ?? null, + 'channels' => $cfg['channels'] ?? ['mail'], + 'variables' => [], + 'is_active' => true, + 'is_default' => true, + ]); + + $template = IdentityMessageTemplate::query()->create([ + 'definition_id' => $definition->id, + 'channel' => 'mail', + 'subject' => ['ko' => '운영자가 바꾼 제목'], + 'body' => ['ko' => '운영자가 바꾼 본문 {code}'], + 'is_active' => true, + 'is_default' => false, + ]); + + $updated = $this->app->make(IdentityMessageTemplateService::class) + ->resetToDefault($template); + + $this->assertTrue((bool) $updated->is_default, '복원 후 is_default 가 참이어야 한다'); + $this->assertSame('[アプリ] 認証コード', $updated->subject['ja'] ?? null, + '복원 기본값에 활성 팩 seed 의 ja subject 가 병합되어야 한다 — 없으면 복원이 팩 번역을 지운다'); + $this->assertSame('

{code}

', $updated->body['ja'] ?? null, + '복원 기본값에 활성 팩 seed 의 ja body 가 병합되어야 한다'); + $this->assertArrayHasKey('ko', $updated->subject, 'ko 는 config 원본에서 보존'); + } } diff --git a/tests/Feature/LanguagePack/NotificationSeedInjectionTest.php b/tests/Feature/LanguagePack/NotificationSeedInjectionTest.php index 60e16d00..993a4051 100644 --- a/tests/Feature/LanguagePack/NotificationSeedInjectionTest.php +++ b/tests/Feature/LanguagePack/NotificationSeedInjectionTest.php @@ -7,6 +7,7 @@ use App\Enums\LanguagePackStatus; use App\Extension\HookManager; use App\Models\LanguagePack; use App\Services\LanguagePack\LanguagePackRegistry; +use App\Services\NotificationTemplateService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\File; use Tests\TestCase; @@ -40,6 +41,11 @@ class NotificationSeedInjectionTest extends TestCase parent::tearDown(); } + /** + * @scenario domain=notification, entry=seeder_filter + * + * @effects seeder_filter_injects_active_pack_locales + */ public function test_apply_filters_injects_ja_translation_when_active_pack_has_seed(): void { File::put($this->packRoot.'/seed/notifications.json', json_encode([ @@ -93,6 +99,114 @@ class NotificationSeedInjectionTest extends TestCase $this->assertSame('Welcome', $result[0]['name']['en']); } + /** + * 실경로 회귀 — 코어 시더(loadConfigSeed)는 config/core.php 원형(연관 배열, + * 항목에 type 키 없음)을 그대로 필터에 넘긴다. injector 가 $def['type'] 만 보면 + * 전 항목이 스킵되어 ja 가 조용히 주입되지 않는다 (2026-08-24 실측 — #597 보완 실측). + * + * @scenario domain=notification, entry=seeder_filter + * + * @effects seeder_filter_injects_ja_for_real_seeder_payload + */ + public function test_apply_filters_injects_ja_for_config_shaped_associative_definitions(): void + { + File::put($this->packRoot.'/seed/notifications.json', json_encode([ + 'welcome' => [ + 'definition' => ['name' => 'ようこそ通知', 'description' => '新規会員へのウェルカム'], + 'templates' => ['mail' => ['subject' => 'ようこそ', 'body' => '

{user_name}

']], + ], + ])); + + LanguagePack::query()->create([ + 'identifier' => 'test-feature-notif-ja', + 'vendor' => 'test', + 'scope' => LanguagePackScope::Core->value, + 'target_identifier' => null, + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'status' => LanguagePackStatus::Active->value, + 'is_protected' => false, + 'manifest' => [], + 'source_type' => 'zip', + ]); + $this->app->make(LanguagePackRegistry::class)->invalidate(); + + // config/core.php notification_definitions 원형과 동일한 형태 — 키가 곧 type. + $definitions = [ + 'welcome' => [ + 'name' => ['ko' => '회원가입 환영', 'en' => 'Welcome'], + 'description' => ['ko' => '신규 회원 환영', 'en' => 'New member welcome'], + 'templates' => [ + [ + 'channel' => 'mail', + 'subject' => ['ko' => '환영합니다', 'en' => 'Welcome'], + 'body' => ['ko' => '

{user_name}

', 'en' => '

{user_name}

'], + ], + ], + ], + ]; + + $result = HookManager::applyFilters('seed.notifications.translations', $definitions); + + $this->assertSame('ようこそ通知', $result['welcome']['name']['ja'] ?? null, + '연관 배열(실제 시더 페이로드) 형태에서 definition name 에 ja 가 병합되어야 한다'); + $this->assertSame('ようこそ', $result['welcome']['templates'][0]['subject']['ja'] ?? null, + '연관 배열 형태에서 템플릿 subject 에 ja 가 병합되어야 한다'); + // ko/en 보존 + $this->assertSame('회원가입 환영', $result['welcome']['name']['ko']); + } + + /** + * [기본값 복원] 경로 회귀 — NotificationTemplateService::getDefaultTemplateData 는 + * core.notification.filter_default_definitions 필터만 타고 언어팩 주입에는 참여하지 + * 않아, 복원을 누르면 팩이 주입해 둔 로케일(ja)이 ko/en 대체로 영구 소실됐다 + * (실사례: 2026-08-23 복원 실측이 welcome ja 를 지움 — #597 보완 실측에서 확정). + * 복원 기본값에도 활성 팩 seed 로케일이 병합되어야 한다. + * + * @scenario domain=notification, entry=reset_default + * + * @effects reset_default_data_includes_lang_pack_locales + */ + public function test_reset_default_data_includes_lang_pack_locales(): void + { + File::put($this->packRoot.'/seed/notifications.json', json_encode([ + 'welcome' => [ + 'definition' => ['name' => 'ようこそ通知', 'description' => '新規会員へのウェルカム'], + 'templates' => ['mail' => ['subject' => 'ようこそ', 'body' => '

{user_name}

']], + ], + ])); + + LanguagePack::query()->create([ + 'identifier' => 'test-feature-notif-ja', + 'vendor' => 'test', + 'scope' => LanguagePackScope::Core->value, + 'target_identifier' => null, + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'status' => LanguagePackStatus::Active->value, + 'is_protected' => false, + 'manifest' => [], + 'source_type' => 'zip', + ]); + $this->app->make(LanguagePackRegistry::class)->invalidate(); + + $defaultData = $this->app->make(NotificationTemplateService::class) + ->getDefaultTemplateData('welcome', 'mail'); + + $this->assertNotEmpty($defaultData, 'config/core.php 의 welcome/mail 기본 템플릿을 찾아야 한다'); + $this->assertSame('ようこそ', $defaultData['subject']['ja'] ?? null, + '[기본값 복원] 기본값에 활성 팩 seed 의 ja subject 가 병합되어야 한다 — 없으면 복원이 팩 번역을 지운다'); + $this->assertSame('

{user_name}

', $defaultData['body']['ja'] ?? null, + '[기본값 복원] 기본값에 활성 팩 seed 의 ja body 가 병합되어야 한다'); + $this->assertArrayHasKey('ko', $defaultData['subject'], 'ko 는 config 원본에서 보존'); + } + public function test_apply_filters_passthrough_when_no_active_pack(): void { $definitions = [ diff --git a/tests/Feature/Upgrades/Upgrade709LangPackResyncTest.php b/tests/Feature/Upgrades/Upgrade709LangPackResyncTest.php new file mode 100644 index 00000000..e5007b90 --- /dev/null +++ b/tests/Feature/Upgrades/Upgrade709LangPackResyncTest.php @@ -0,0 +1,207 @@ +packRoot = base_path('lang-packs/'.$this->packIdentifier); + File::ensureDirectoryExists($this->packRoot.'/seed'); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->packRoot); + parent::tearDown(); + } + + /** + * 활성 팩 행을 생성합니다 (resolveDirectory → lang-packs/{identifier}). + * + * @return LanguagePack 생성된 팩 + */ + private function createActivePack(): LanguagePack + { + return LanguagePack::query()->create([ + 'identifier' => $this->packIdentifier, + 'vendor' => 'test', + 'scope' => LanguagePackScope::Core->value, + 'target_identifier' => null, + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'status' => LanguagePackStatus::Active->value, + 'is_protected' => false, + 'manifest' => [], + 'source_type' => 'zip', + ]); + } + + /** + * 마이그레이션을 업그레이드 컨텍스트로 실행합니다. + * + * @return void + */ + private function runMigration(): void + { + (new ResyncActiveLanguagePackTranslations)->run( + new UpgradeContext('7.0.8', '7.0.9', '7.0.9') + ); + } + + /** + * [기본값 복원]이 지운 알림 템플릿 ja 가 업그레이드 재동기화로 복구되고, + * 운영자가 수정한(user_overrides 등록) 템플릿의 ja 는 덮이지 않는다. + * + * @scenario domain=notification, entry=upgrade_resync + * + * @effects upgrade_resync_restores_wiped_pack_locales + */ + public function test_resync_restores_wiped_notification_locale_and_preserves_overrides(): void + { + File::put($this->packRoot.'/seed/notifications.json', json_encode([ + 'welcome' => [ + 'definition' => ['name' => 'ようこそ通知'], + 'templates' => ['mail' => ['subject' => 'ようこそ', 'body' => '

{user_name}

']], + ], + 'password_changed' => [ + 'templates' => ['mail' => ['subject' => 'パスワード変更', 'body' => '

{user_name}

']], + ], + ])); + $this->createActivePack(); + + // 복원 결함이 ja 를 지운 상태 재현: ko/en 만 남고 user_overrides 는 null. + $wipedDef = NotificationDefinition::create([ + 'type' => 'welcome', + 'hook_prefix' => 'core.auth', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'name' => ['ko' => '환영', 'en' => 'Welcome'], + 'channels' => ['mail'], + 'hooks' => ['core.auth.after_register'], + ]); + $wiped = NotificationTemplate::create([ + 'definition_id' => $wipedDef->id, + 'channel' => 'mail', + 'subject' => ['ko' => '환영합니다', 'en' => 'Welcome'], + 'body' => ['ko' => '

환영

', 'en' => '

welcome

'], + 'is_active' => true, + 'is_default' => true, + ]); + + // 운영자 수정 상태 재현: ja 커스텀 + user_overrides 보존 선언. + $customDef = NotificationDefinition::create([ + 'type' => 'password_changed', + 'hook_prefix' => 'core.auth', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'name' => ['ko' => '비밀번호 변경', 'en' => 'Password changed'], + 'channels' => ['mail'], + 'hooks' => ['core.auth.after_password_change'], + ]); + $custom = NotificationTemplate::create([ + 'definition_id' => $customDef->id, + 'channel' => 'mail', + 'subject' => ['ko' => '커스텀 제목', 'ja' => 'カスタム件名'], + 'body' => ['ko' => '

커스텀

', 'ja' => '

カスタム

'], + 'is_active' => true, + 'is_default' => false, + ]); + // 운영자 수정 시 updating 이벤트가 기록하는 실제 형식 = dot-path 리스트 "{column}.{locale}" + $custom->user_overrides = ['subject.ja', 'body.ja']; + $custom->saveQuietly(); + + $this->assertArrayNotHasKey('ja', $wiped->fresh()->subject, '사전 조건: 소실 상태(ja 부재)'); + + $this->runMigration(); + + $this->assertSame('ようこそ', $wiped->fresh()->subject['ja'] ?? null, + '소실된 템플릿 subject 에 활성 팩 seed 의 ja 가 병합 복구되어야 한다'); + $this->assertSame('ようこそ通知', $wipedDef->fresh()->name['ja'] ?? null, + '정의 name 에도 ja 가 병합되어야 한다'); + $this->assertSame('カスタム件名', $custom->fresh()->subject['ja'] ?? null, + '운영자 수정(user_overrides 등록) 템플릿의 ja 는 seed 로 덮이지 않아야 한다'); + } + + /** + * 본인인증 메시지 템플릿의 소실된 ja 도 같은 재동기화로 복구된다. + * + * @scenario domain=identity, entry=upgrade_resync + * + * @effects upgrade_resync_restores_wiped_identity_locales + */ + public function test_resync_restores_wiped_identity_message_locale(): void + { + File::put($this->packRoot.'/seed/identity_messages.json', json_encode([ + 'mail.purpose.signup' => [ + 'definition' => ['name' => '会員登録認証'], + 'templates' => ['mail' => ['subject' => '[アプリ] 認証コード', 'body' => '

{code}

']], + ], + ])); + $this->createActivePack(); + + $cfg = config('core.identity_messages')['mail.purpose.signup']; + $definition = IdentityMessageDefinition::query()->create([ + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'provider_id' => $cfg['provider_id'], + 'scope_type' => $cfg['scope_type'], + 'scope_value' => $cfg['scope_value'], + 'name' => ['ko' => '회원가입 인증'], + 'channels' => ['mail'], + 'variables' => [], + 'is_active' => true, + 'is_default' => true, + ]); + $template = IdentityMessageTemplate::query()->create([ + 'definition_id' => $definition->id, + 'channel' => 'mail', + 'subject' => ['ko' => '[앱] 인증 코드'], + 'body' => ['ko' => '

{code}

'], + 'is_active' => true, + 'is_default' => true, + ]); + + $this->assertArrayNotHasKey('ja', $template->fresh()->subject, '사전 조건: 소실 상태(ja 부재)'); + + $this->runMigration(); + + $this->assertSame('[アプリ] 認証コード', $template->fresh()->subject['ja'] ?? null, + '소실된 본인인증 템플릿 subject 에 활성 팩 seed 의 ja 가 병합 복구되어야 한다'); + } +} diff --git a/tests/Unit/Listeners/LanguagePack/SyncDatabaseTranslationsTest.php b/tests/Unit/Listeners/LanguagePack/SyncDatabaseTranslationsTest.php index 388046b3..086c2a75 100644 --- a/tests/Unit/Listeners/LanguagePack/SyncDatabaseTranslationsTest.php +++ b/tests/Unit/Listeners/LanguagePack/SyncDatabaseTranslationsTest.php @@ -98,12 +98,15 @@ class SyncDatabaseTranslationsTest extends TestCase public function test_case_b_skips_locale_when_user_overrides_registered(): void { // Permission 은 HasUserOverrides 미사용이므로 Role 로 검증 (Role/Menu/Notification 모두 동일 정책) + // user_overrides 는 실제 생산 형식(운영자 수정 시 updating 이벤트가 기록하는 + // dot-path 리스트 "{column}.{locale}")으로 둔다 — 과거 픽스처는 어떤 생산자도 + // 쓰지 않는 연관 형식이라 보존 판정 결함(isset 사문)을 green 으로 가렸다. $role = Role::query()->create([ 'identifier' => 'test_role_b', 'name' => ['ko' => '바', 'en' => 'Bar', 'ja' => '사용자수정'], 'description' => ['ko' => 'D', 'en' => 'D'], 'is_active' => true, - 'user_overrides' => ['name' => ['updated_at' => now()->toIso8601String()]], + 'user_overrides' => ['name.ja'], ]); $pack = $this->setupPackWithPermissionsSeed([ @@ -167,12 +170,13 @@ class SyncDatabaseTranslationsTest extends TestCase public function test_deactivate_preserves_locale_when_user_overrides_registered(): void { + // dot-path 리스트 = 실제 생산 형식 (test_case_b 주석 참조) $role = Role::query()->create([ 'identifier' => 'test_role_zed', 'name' => ['ko' => '제드', 'en' => 'Zed', 'ja' => '사용자수정ja'], 'description' => ['ko' => '', 'en' => ''], 'is_active' => true, - 'user_overrides' => ['name' => ['updated_at' => now()->toIso8601String()]], + 'user_overrides' => ['name.ja'], ]); $pack = $this->setupPackWithPermissionsSeed([ diff --git a/tests/Unit/Notifications/GenericNotificationViaTest.php b/tests/Unit/Notifications/GenericNotificationViaTest.php index bee0bff6..76663499 100644 --- a/tests/Unit/Notifications/GenericNotificationViaTest.php +++ b/tests/Unit/Notifications/GenericNotificationViaTest.php @@ -313,7 +313,7 @@ class GenericNotificationViaTest extends TestCase /** * 사용 가능한 채널 목록에 없는 채널(비활성 확장이 제공하던 채널)은 via() 가 제외한다. * - * 회귀: 메시징 확장이 비활성이면 그 확장의 채널(sms/alimtalk)이 getAvailableChannels() + * 회귀: 비즈뿌리오 등 확장이 비활성이면 그 확장의 채널(sms/alimtalk)이 getAvailableChannels() * 에서 빠진다. 하지만 저장된 채널 설정·템플릿은 남아 있어, via() 가 available 목록을 확인하지 * 않으면 죽은 채널을 발송 후보로 넣고 "건너뜀" 로그까지 남긴다. available 목록에 없는 채널은 * 로그 없이 제외되어야 한다 (채널 지정 경로). diff --git a/tests/Unit/Notifications/NotificationDefinitionTypeLabelParityTest.php b/tests/Unit/Notifications/NotificationDefinitionTypeLabelParityTest.php new file mode 100644 index 00000000..29417b61 --- /dev/null +++ b/tests/Unit/Notifications/NotificationDefinitionTypeLabelParityTest.php @@ -0,0 +1,111 @@ + 'templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json', + 'en' => 'templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json', + ]; + + /** + * 코어 정의 타입 전수가 ko 라벨을 가진다 + * + * @scenario locale=ko + * + * @effects core_definition_types_have_admin_template_labels + */ + public function test_every_core_definition_type_has_ko_label(): void + { + $this->assertLabelsCoverCoreTypes('ko'); + } + + /** + * 코어 정의 타입 전수가 en 라벨을 가진다 + * + * @scenario locale=en + * + * @effects core_definition_types_have_admin_template_labels + */ + public function test_every_core_definition_type_has_en_label(): void + { + $this->assertLabelsCoverCoreTypes('en'); + } + + /** + * 코어 정의 전수 ↔ 해당 로케일 라벨 대조 + * + * @param string $locale 검사할 로케일 (ko|en) + */ + private function assertLabelsCoverCoreTypes(string $locale): void + { + $types = array_keys((array) config('core.notification_definitions')); + + // 모집단 검증 — config 구조가 바뀌어 빈 배열이 되면 초록이 무의미하다. + $this->assertGreaterThanOrEqual( + 5, + count($types), + 'config/core.php notification_definitions 에서 코어 정의를 읽지 못했다 — 테스트 경로/구조 확인 필요' + ); + + $relPath = self::LABEL_FILES[$locale]; + $labels = $this->loadTypeLabels($relPath); + + $missing = array_values(array_filter( + $types, + fn (string $type) => ! array_key_exists($type, $labels) + || ! is_string($labels[$type]) + || trim($labels[$type]) === '' + )); + + $this->assertSame( + [], + $missing, + "admin 템플릿 {$locale} 라벨 누락 — 알림 설정 화면 제목에 원시 키가 그대로 노출된다.\n" + ."{$relPath} 의 settings.notification_definitions.types 에 다음 타입을 추가할 것:\n" + .implode(', ', $missing) + ); + } + + /** + * partial admin.json 에서 타입 라벨 맵을 읽는다 + * + * @param string $relPath 저장소 루트 기준 경로 + * @return array 타입 → 라벨 + */ + private function loadTypeLabels(string $relPath): array + { + $path = base_path($relPath); + $this->assertFileExists($path, "라벨 파일이 없다: {$relPath}"); + + $data = json_decode((string) file_get_contents($path), true); + $this->assertIsArray($data, "라벨 파일 JSON 파싱 실패: {$relPath}"); + + $labels = $data['settings']['notification_definitions']['types'] ?? null; + $this->assertIsArray( + $labels, + "settings.notification_definitions.types 경로가 없다: {$relPath} — 파일 구조가 바뀌었으면 본 테스트의 경로를 갱신할 것" + ); + + return $labels; + } +} diff --git a/tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php b/tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php index 84a5270a..cc9288e7 100644 --- a/tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php +++ b/tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php @@ -34,13 +34,40 @@ class LanguagePackServiceTest extends TestCase * * @return void */ + /** 실번들 케이스가 삭제하는 실 설치본의 백업 경로 (테스트 종료 시 복원). */ + private ?string $protectedRealInstallDir = null; + protected function setUp(): void { parent::setUp(); $this->service = $this->app->make(LanguagePackService::class); + + // 실번들(g7-core-ja) 케이스들은 활성 설치본 디렉토리를 만들고 cleanup 에서 삭제한다. + // DB 는 RefreshDatabase 로 격리되지만 파일 시스템은 실 저장소를 공유하므로, + // 개발 환경에 이미 설치된 실 설치본이 있으면 백업해 두고 tearDown 에서 복원한다 + // (미보호 시 실환경 팩이 files_missing 드리프트에 빠진다 — 2026-08-24 실측). + $realDir = base_path('lang-packs/g7-core-ja'); + if (File::isDirectory($realDir)) { + $backup = base_path('lang-packs/.test-backup-g7-core-ja'); + File::deleteDirectory($backup); + File::moveDirectory($realDir, $backup); + $this->protectedRealInstallDir = $backup; + } $this->registry = $this->app->make(LanguagePackRegistry::class); } + protected function tearDown(): void + { + if ($this->protectedRealInstallDir !== null) { + $realDir = base_path('lang-packs/g7-core-ja'); + File::deleteDirectory($realDir); + File::moveDirectory($this->protectedRealInstallDir, $realDir); + $this->protectedRealInstallDir = null; + } + + parent::tearDown(); + } + /** * 테스트 언어팩 1건을 DB 에 직접 생성합니다. * @@ -1085,4 +1112,73 @@ class LanguagePackServiceTest extends TestCase File::deleteDirectory($path); } } + + /** + * 회귀 가드 — performUpdate 중 설치 트랜잭션이 status 를 installed 로 기록한 뒤 + * (활성화 단계 이전/도중) 실패하면, catch 의 상태 복원이 Updating 만 보던 탓에 + * active 였던 팩이 installed 로 방치됐다. 파일은 백업으로 원상 복원되므로 + * 상태도 이전 상태(active)로 복원되어야 한다 — 방치되면 해당 로케일의 백엔드 + * 번역이 통째로 폴백되는데 오류도 로그도 없다 (#597 보완 실측에서 유사 상태 실측). + * + * @scenario vector=update_failure_after_install_transaction, actor_permission=cli_system + * + * @effects failed_update_restores_previous_active_status + */ + public function test_failed_update_restores_previous_active_status(): void + { + $identifier = 'test-update-restore-ja'; + $bundledDir = base_path('lang-packs/_bundled/'.$identifier); + File::ensureDirectoryExists($bundledDir); + File::put($bundledDir.'/language-pack.json', json_encode(['identifier' => $identifier])); + + try { + $pack = LanguagePack::query()->create([ + 'identifier' => $identifier, + 'vendor' => 'test', + 'scope' => LanguagePackScope::Core->value, + 'target_identifier' => null, + 'locale' => 'ja', + 'locale_name' => 'Japanese', + 'locale_native_name' => '日本語', + 'text_direction' => 'ltr', + 'version' => '1.0.0', + 'status' => LanguagePackStatus::Active->value, + 'is_protected' => false, + 'manifest' => [], + 'source_type' => 'bundled', + ]); + + // Laravel partialMock 은 생성자를 건너뛰어 readonly 의존성이 미초기화되므로, + // 생성자 인자를 컨테이너에서 구성해 넘기는 Mockery partial 을 사용한다. + $ctorArgs = array_map( + static fn (\ReflectionParameter $param) => app($param->getType()->getName()), + (new \ReflectionClass(LanguagePackService::class))->getConstructor()->getParameters() + ); + $service = \Mockery::mock(LanguagePackService::class, $ctorArgs)->makePartial(); + $service->shouldReceive('installFromBundled') + ->once() + ->andReturnUsing(function () use ($pack) { + // 설치 트랜잭션이 성공적으로 installed 를 기록한 직후 실패하는 상황 재현 + $pack->newQuery()->whereKey($pack->id) + ->update(['status' => LanguagePackStatus::Installed->value]); + + throw new \RuntimeException('activate step failed (재현)'); + }); + + try { + $service->performUpdate($pack->fresh(), true); + $this->fail('installFromBundled 실패가 전파되어야 한다'); + } catch (\RuntimeException $e) { + // 예상된 실패 — 상태 복원만 검증 + } + + $this->assertSame( + LanguagePackStatus::Active->value, + $pack->fresh()->status, + '실패한 update 는 이전 상태(active)를 복원해야 한다 — installed 로 방치되면 해당 로케일 번역이 조용히 폴백된다' + ); + } finally { + File::deleteDirectory($bundledDir); + } + } } diff --git a/tests/scenarios/language-pack-locale-injection-parity.yaml b/tests/scenarios/language-pack-locale-injection-parity.yaml new file mode 100644 index 00000000..f4300f63 --- /dev/null +++ b/tests/scenarios/language-pack-locale-injection-parity.yaml @@ -0,0 +1,43 @@ +feature: 언어팩 로케일 주입·복원 일관성 (#597 보완 실측 후속) + +description: | + 활성 언어팩 seed 의 로케일(ja 등)이 시더 필터와 [기본값 복원] 양쪽에서 같은 SSoT 로 + 병합되는지 고정한다. + + 배경 (2026-08-24 #597 보완 실측에서 실측·확정): + - [기본값 복원] 경로(core.notification.filter_default_definitions / + core.identity.filter_default_message_definitions)가 언어팩 주입에 참여하지 않아, + 복원을 누르면 팩이 주입해 둔 로케일이 config 의 ko/en 만으로 대체되어 영구 소실됐다 + (실사례: 2026-08-23 복원 실측이 welcome ja 를 지움 — 오류·로그 없음). + - 시더 필터(seed.notifications.translations)의 주입기가 리스트+type 형태만 읽어, + 코어 시더의 실제 페이로드(config 원형 — 연관 배열, 항목에 type 없음)에서는 + 전 항목이 스킵되어 주입이 오류 없이 전면 불능이었다. identity 축은 시더가 + config 복합 키를 버리고 리스트로 넘겨 같은 방식으로 전면 불능이었다. + - 커밋 전 점검(같은 날)에서 추가 확정: 7.0.9 업그레이드 스텝이 활성 팩마다 + 활성화 훅을 재발화해 기존 설치본의 소실분을 병합 복구한다(upgrade_resync 축). + 이 백필의 안전 전제인 사용자 수정 보존은 dot-path(`"컬럼.로케일"`) 판정으로 + 정합화했고(종전 isset 판정은 사문), 주입 저장의 user_overrides 오염(시더 동결 + 유발)과 같은 프로세스 활성화의 시더 필터 stale(캡처 인스턴스 캐시)도 함께 교정. + +axes: + domain: [notification, identity] + entry: [seeder_filter, reset_default, upgrade_resync] + +effects: + - seeder_filter_injects_active_pack_locales + - seeder_filter_injects_ja_for_real_seeder_payload + - reset_default_data_includes_lang_pack_locales + - identity_reset_default_preserves_lang_pack_locales + - upgrade_resync_restores_wiped_pack_locales + - upgrade_resync_restores_wiped_identity_locales + +test_files: + - tests/Feature/LanguagePack/NotificationSeedInjectionTest.php + - tests/Feature/LanguagePack/IdentitySeedInjectionTest.php + - tests/Feature/Upgrades/Upgrade709LangPackResyncTest.php + +validation: + automated: "php artisan test --filter='NotificationSeedInjectionTest|IdentitySeedInjectionTest|Upgrade709LangPackResyncTest|SyncDatabaseTranslationsTest'" + manual: | + 관리자 알림 설정에서 언어팩이 번역을 제공하는 템플릿(예: welcome)에 [기본값 복원] 실행 후, + 일본어 화면에서 제목·본문 번역이 유지되는지 확인한다. diff --git a/tests/scenarios/notification-definition-type-labels.yaml b/tests/scenarios/notification-definition-type-labels.yaml new file mode 100644 index 00000000..e79933e9 --- /dev/null +++ b/tests/scenarios/notification-definition-type-labels.yaml @@ -0,0 +1,27 @@ +feature: 코어 알림 정의 타입 ↔ 관리자 화면 라벨 패리티 + +description: | + 관리자 알림 설정 화면의 정의 목록 제목은 DB name 이 아니라 프론트 다국어 키 + admin.settings.notification_definitions.types.{type} 로 렌더된다. 이 네임스페이스의 + 코어 소유분은 admin 템플릿(sirsoft-admin_basic)의 lang/partial/{ko,en}/admin.json 이 + 공급한다(모듈 소유 타입은 각 모듈 화면·lang 담당, ja 는 템플릿 언어팩 담당). + + 키가 없으면 예외·경고 없이 원시 키 문자열이 제목으로 그대로 노출된다 + (실사례: #481 이 sitemap_regenerated / sitemap_regenerate_failed 정의를 config 에만 + 추가하고 템플릿 라벨을 누락 — 2026-08-24 제보). config/core.php 의 코어 정의 + 전수를 양 로케일 라벨과 자동 대조해 재발을 차단한다. + +axes: + locale: [ko, en] + +effects: + - core_definition_types_have_admin_template_labels + +test_files: + - tests/Unit/Notifications/NotificationDefinitionTypeLabelParityTest.php + +validation: + manual: | + /admin/settings?tab=notification_definitions 의 메일·사이트내 알림 탭에서 각 행 제목이 + 번역 문구로 표시되는지(원시 admin.settings.* 키 노출 0) 브라우저 실측. ja locale 은 + g7-template-sirsoft-admin_basic-ja 팩 활성 상태에서 동일 확인. diff --git a/upgrades/Upgrade_7_0_9.php b/upgrades/Upgrade_7_0_9.php new file mode 100644 index 00000000..db5459ad --- /dev/null +++ b/upgrades/Upgrade_7_0_9.php @@ -0,0 +1,34 @@ +logger->info('[core:7.0.9] language_packs 테이블 부재 — 언어팩 seed 재동기화 스킵'); + + return; + } + + $packs = LanguagePack::query()->where('status', 'active')->get(); + + if ($packs->isEmpty()) { + $context->logger->info('[core:7.0.9] 활성 언어팩 없음 — 언어팩 seed 재동기화 스킵'); + + return; + } + + foreach ($packs as $pack) { + try { + HookManager::doAction('core.language_packs.activated', $pack); + $context->logger->info('[core:7.0.9] 언어팩 seed 번역 재동기화 완료: '.$pack->identifier); + } catch (Throwable $e) { + // 개별 팩 실패(디렉토리 손상 등)가 업그레이드 전체를 막지 않도록 격리 — + // 남은 팩은 계속 처리하고, 실패 팩은 로그로 남겨 운영자가 후속 조치한다. + $context->logger->warning( + '[core:7.0.9] 언어팩 seed 재동기화 실패(건너뜀): '.$pack->identifier.' — '.$e->getMessage() + ); + } + } + } +}