feat(message_bizppurio): 알림 템플릿 편집 통합·검수 의견·관리탭 개선 및 언어팩 번역 보존 결함군 수정
비즈뿌리오 알림톡 UX 개편 후속: - 코어/게시판/이커머스 알림 템플릿 [편집] 모달에 확장 채널 편집 영역 (extension_point 2종 + hidden_template_editor 채널 메타)을 신설하고, 알림톡 템플릿·문자(SMS)·수신자 규칙을 한 창에서 통합 저장하도록 개정 (행 하단은 상태 요약 전용, 승인 여부 2단 배지) - 검수 신청에 검수자 전달 의견(comment) 동봉 — kapi request 로만 전달, 행에는 저장하지 않음 (FormRequest 신설 + API 문서 백필) - 관리 탭 모바일 카드뷰·도구줄 코어 관례 통일, 사이트맵 알림 정의 다국어 라벨 결측 수정 + 코어 정의 라벨 패리티 테스트 신설 언어팩 번역 소실·보존 결함군 (보완 실측 → 커밋 전 점검에서 연쇄 확정, 전부 실패 테스트 선행 후 수정): - [기본값 복원]이 활성 언어팩 번역(ja)을 영구 소실 → 복원 기본값에 시딩과 같은 주입기(SSoT)로 팩 로케일 병합 (notification·identity) - 코어 알림/본인인증 시더의 언어팩 주입 전면 불능 → 주입기가 연관/리스트 양형태 수용 + identity 시더의 config 복합 키 보존 - 실패한 언어팩 업데이트가 active 팩을 installed 로 방치 → 상태 복원 확장 - 병합·제거의 사용자 수정 보존 판정 사문(연관 전제 isset) → dot-path 판정 교정, 팩 주입 저장의 user_overrides 오염 차단(시딩 바인딩), 템플릿 2모델 translatableTrackableFields 선언(beta.4 설계 누락), 같은 프로세스 활성화 시 시더 번역 필터 stale(캡처 인스턴스 캐시) 교정 - 7.0.9 업그레이드 스텝 신설 — 활성 팩 seed 재동기화로 기설치본의 소실 번역 자동 복구 (멱등·운영자 수정 보존·팩별 실패 격리)
This commit is contained in:
@@ -11,6 +11,14 @@
|
||||
- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 알림 템플릿·본인인증 메시지에서 [기본값 복원]을 실행하면 언어팩이 제공한 번역(일본어 등)이 함께 지워지던 문제를 수정했습니다. 이제 복원 기본값에도 활성 언어팩의 번역이 포함되어, 복원 후에도 해당 언어 화면에 번역이 유지됩니다. 또한 이번 버전으로 업그레이드하면 이전 버전에서 이미 지워졌던 언어팩 번역이 활성 언어팩 기준으로 자동 복구됩니다.
|
||||
- 설치·복구 과정의 데이터 동기화에서 코어 알림 정의와 본인인증 메시지에 언어팩 번역이 주입되지 않을 수 있던 문제를 수정했습니다.
|
||||
- 언어팩 업데이트가 실패했을 때 사용 중이던 언어팩이 비활성 상태로 남아, 해당 언어의 화면·메일 문구가 안내 없이 기본 언어로 표시되던 문제를 수정했습니다. 이제 실패 시 이전 상태로 복원됩니다.
|
||||
- 언어팩을 다시 활성화하거나 업데이트할 때 관리자가 직접 수정해 둔 알림·본인인증 템플릿의 해당 언어 문구가 언어팩 기본값으로 덮어써지던 문제를 수정했습니다. 이제 관리자가 수정한 언어만 보존되고, 수정하지 않은 언어는 언어팩 갱신을 그대로 따라갑니다.
|
||||
- 언어팩 활성화 직후 같은 화면 흐름에서 일부 기본 데이터에 새 언어 번역이 반영되지 않던 문제를 수정했습니다.
|
||||
|
||||
## [7.0.8] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,17 @@ class IdentityMessageTemplate extends Model
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
|
||||
* (NotificationTemplate 과 동형 — 미선언의 결과는 그쪽 주석 참조, #597 에서 교정)
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $translatableTrackableFields = [
|
||||
'subject',
|
||||
'body',
|
||||
];
|
||||
|
||||
/**
|
||||
* 활동 로그 추적 필드.
|
||||
*
|
||||
|
||||
@@ -27,6 +27,20 @@ class NotificationTemplate extends Model
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
|
||||
*
|
||||
* 미선언 시 수정이 컬럼 전체 항목(`"subject"`)으로 기록되어 시더 재실행이 컬럼을
|
||||
* 통째로 얼리고(다른 로케일 갱신·신규 팩 로케일 추가 불가), 언어팩 병합의
|
||||
* 사용자 수정 보존(Case B)도 로케일 단위로 판정할 수 없다 (#597 에서 교정).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -41,6 +41,11 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
*/
|
||||
public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array
|
||||
{
|
||||
// 팩 주입은 시딩 컨텍스트다 — 바인딩 없이 저장하면 UserOverridesAwareBuilder 가
|
||||
// 주입 자체를 "사용자 수정"으로 오인해 user_overrides 를 오염시키고, 그 선언이
|
||||
// 이후 시더 재실행의 컬럼 갱신을 영구히 얼린다 (#597 에서 실측·교정).
|
||||
app()->instance('user_overrides.seeding', true);
|
||||
try {
|
||||
$audit = [];
|
||||
$locale = $pack->locale;
|
||||
|
||||
@@ -64,6 +69,9 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
}
|
||||
|
||||
return $audit;
|
||||
} finally {
|
||||
app()->forgetInstance('user_overrides.seeding');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +81,23 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
* @return array<int, array<string, mixed>> 감사 로그 항목
|
||||
*/
|
||||
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<int, array<string, mixed>> 감사 로그 항목
|
||||
*/
|
||||
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<int, string> $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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<int, array<string, mixed>> $definitions type 필드를 포함한 기본 정의 배열
|
||||
* @return array<int, array<string, mixed>> 활성 팩 로케일이 병합된 정의 배열
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리보기용 렌더링.
|
||||
*
|
||||
|
||||
@@ -79,13 +79,18 @@ class IdentityMessageDefinitionSeeder extends Seeder
|
||||
$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;
|
||||
|
||||
@@ -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` 로 선언합니다.
|
||||
|
||||
@@ -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개:
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 작성/검수 신청/승인 관리·템플릿 통합 관리·발송 이력 화면과 발송 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 템플릿 작성 시 입력값 오류 안내도 항목 이름까지 일본어로 표시됩니다.
|
||||
- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 작성/검수 신청/승인 관리·템플릿 통합 관리·발송 이력 화면과 발송 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 템플릿 작성 시 입력값 오류 안내도 항목 이름까지 일본어로 표시됩니다. 알림 편집 창에 통합된 알림톡 템플릿·문자(SMS) 섹션의 제목·안내·승인 여부 배지(승인됨/미승인)와 검수자 전달 의견 입력란의 라벨·안내도 일본어로 표시됩니다.
|
||||
|
||||
@@ -107,6 +107,7 @@ return [
|
||||
'message_type' => 'メッセージタイプ',
|
||||
'emphasize_type' => '強調タイプ',
|
||||
'template_content' => '本文',
|
||||
'comment' => '審査担当者への伝達事項',
|
||||
'preview_message' => 'プレビュー文言',
|
||||
'category_code' => 'カテゴリ',
|
||||
'security_flag' => 'セキュリティテンプレート',
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"tab_guide": "通知ごとにアラームトークテンプレートを作成して審査を申請してください。カカオ承認後にアラームトークが自動送信され、代替SMS・SMS単独送信もこの画面で設定します。",
|
||||
"tab_guide": "各通知の[編集]でアラームトークのテンプレートとSMS本文をまとめて作成し、審査を申請してください。カカオの承認後にアラームトークが自動送信され、代替SMS・SMS単独送信も同じ画面で設定します。",
|
||||
"status": {
|
||||
"unwritten": "未作成",
|
||||
"draft": "作成中",
|
||||
@@ -137,7 +137,11 @@
|
||||
"sms_only": "SMS単独",
|
||||
"sms_body_prefix": "本文:",
|
||||
"sms_body_missing": "SMS本文未設定",
|
||||
"btn_edit_sms": "SMS本文を編集"
|
||||
"btn_edit_sms": "SMS本文を編集",
|
||||
"sms_label": "SMS",
|
||||
"on": "使用",
|
||||
"off": "未使用",
|
||||
"alimtalk_disabled": "送信停止中"
|
||||
},
|
||||
"form": {
|
||||
"modal_title": "アラームトークテンプレート — {name}",
|
||||
@@ -199,6 +203,9 @@
|
||||
"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": "審査を申請しました。承認されるとアラームトーク送信が開始されます。"
|
||||
},
|
||||
@@ -217,6 +224,18 @@
|
||||
"rejection": {
|
||||
"modal_title": "却下理由 — {name}",
|
||||
"btn_close": "閉じる"
|
||||
},
|
||||
"approval": {
|
||||
"approved": "承認済み",
|
||||
"not_approved": "未承認"
|
||||
},
|
||||
"section": {
|
||||
"alimtalk_title": "アラームトークテンプレート",
|
||||
"sms_title": "SMS",
|
||||
"alimtalk_enabled": "アラームトーク送信を使用",
|
||||
"locked_hint": "審査中または承認済みのテンプレートは内容を変更できません。変更するには先に[申請取消]または[編集(承認取消)]を押してください。",
|
||||
"saved_toast": "通知テンプレートを保存しました。",
|
||||
"loading": "テンプレートを読み込み中…"
|
||||
}
|
||||
},
|
||||
"manage": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
### Added
|
||||
|
||||
- 환경설정 SEO 탭의 기본 공유 이미지(og:image) 라벨·설명 번역을 추가했습니다.
|
||||
- 알림 설정 화면의 「사이트맵 재생성 완료」・「사이트맵 재생성 실패」 알림 이름 번역을 추가했습니다.
|
||||
|
||||
## [1.0.6] - 2026-08-22
|
||||
|
||||
|
||||
@@ -1862,7 +1862,9 @@
|
||||
"types": {
|
||||
"welcome": "会員登録ウェルカム",
|
||||
"reset_password": "パスワード再設定",
|
||||
"password_changed": "パスワード変更通知"
|
||||
"password_changed": "パスワード変更通知",
|
||||
"sitemap_regenerated": "サイトマップ再生成完了",
|
||||
"sitemap_regenerate_failed": "サイトマップ再生成失敗"
|
||||
},
|
||||
"channels_label": "チャネル:",
|
||||
"edit_modal": {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
### Changed
|
||||
|
||||
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
|
||||
- 게시판 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
|
||||
|
||||
## [1.0.5] - 2026-08-22
|
||||
|
||||
|
||||
+138
@@ -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<string, any>;
|
||||
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<string, unknown>): 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<string, unknown> = {}) => ({
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
+43
-9
@@ -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"
|
||||
},
|
||||
@@ -851,7 +868,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"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)}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
### Changed
|
||||
|
||||
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
|
||||
- 이커머스 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+138
@@ -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<string, any>;
|
||||
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<string, unknown>): 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<string, unknown> = {}) => ({
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
+43
-9
@@ -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"
|
||||
},
|
||||
@@ -851,7 +868,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"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)}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
### Added
|
||||
|
||||
- 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다.
|
||||
- 알림 설정 화면의 '비즈뿌리오' 통합 탭에서 알림별 카카오 알림톡 템플릿을 직접 작성해 검수를 신청하고, 승인되면 그 내용으로 자동 발송됩니다. 기본형·부가정보형·채널추가형·복합형과 강조표기·이미지·아이템리스트 등 카카오 전 유형과 버튼·바로연결을 지원하며, 검수 결과는 30분 주기로 자동 확인되고 [새로고침]으로 즉시 확인할 수 있습니다. 반려되면 사유를 화면에서 확인하고 수정 후 다시 신청합니다. 게시판·이커머스 알림 설정 화면에서도 동일하게 동작합니다.
|
||||
- 알림별 대체 SMS(알림톡 실패 시)와 SMS 단독 발송 본문을 같은 화면에서 설정할 수 있습니다. 문자 본문은 언어별로 입력하며 회원이 사용하는 언어로 발송됩니다(해당 언어가 비어 있으면 기본 언어로 발송). [SMS 단독]을 선택한 알림은 템플릿이 승인되어 있어도 알림톡을 보내지 않고 문자로만 발송합니다. 승인 취소 시에는 알림톡 발송이 즉시 중단됨을 경고합니다.
|
||||
- 알림 설정 화면의 '비즈뿌리오' 통합 탭에서 각 알림의 [편집]을 누르면 알림 템플릿 편집 창 안에서 카카오 알림톡 템플릿을 직접 작성해 검수를 신청하고, 승인되면 그 내용으로 자동 발송됩니다. 기본형·부가정보형·채널추가형·복합형과 강조표기·이미지·아이템리스트 등 카카오 전 유형과 버튼·바로연결을 지원하며, 검수 결과는 30분 주기로 자동 확인되고 편집 창의 [새로고침]으로 즉시 확인할 수 있습니다. 검수 신청 시 변수 예시값 등 검수자에게 전달할 의견을 함께 적어 보낼 수 있으며, 반려되면 사유를 같은 창에서 확인하고 수정 후 다시 신청합니다. 알림 목록의 각 행에는 승인 여부(승인됨 / 미승인·세부 상태)와 문자 설정 요약이 표시됩니다. 게시판·이커머스 알림 설정 화면에서도 동일하게 동작합니다.
|
||||
- 같은 편집 창에서 알림별 대체 SMS(알림톡 실패 시)·SMS 단독 발송 여부와 문자 본문을 함께 설정하고, 하단 [저장] 한 번으로 알림톡 템플릿·문자 설정·수신자 규칙을 모두 저장합니다. 문자 본문은 언어별로 입력하며 회원이 사용하는 언어로 발송됩니다(해당 언어가 비어 있으면 기본 언어로 발송). [SMS 단독]을 선택한 알림은 템플릿이 승인되어 있어도 알림톡을 보내지 않고 문자로만 발송합니다. 검수중·승인된 템플릿은 내용이 잠기며, 승인 취소는 즉시 알림톡 발송이 중단됨을 같은 창에서 확인한 뒤 진행합니다.
|
||||
- 플러그인 설정의 '알림 템플릿 관리' 화면에서 모든 알림의 템플릿 상태를 한눈에 보고 검색·필터·수정·삭제할 수 있습니다.
|
||||
- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. 요청 과다로 인한 일시적 발송 실패도 사유가 표시됩니다.
|
||||
- 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다.
|
||||
|
||||
@@ -114,10 +114,10 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook
|
||||
|
||||
카카오 알림톡 템플릿의 **작성·검수 신청·취소·삭제를 관리자 화면에서 직접** 수행합니다. 비즈뿌리오 콘솔을 따로 열 필요가 없습니다.
|
||||
|
||||
작성 위치는 두 곳이고 화면 구성은 같습니다.
|
||||
작성 위치는 두 곳입니다.
|
||||
|
||||
- **알림 설정 > 비즈뿌리오 탭** — 알림 항목마다 알림톡 템플릿과 대체 SMS 를 함께 설정합니다. 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다.
|
||||
- **플러그인 설정 > 알림 템플릿 관리** — 전체 알림의 템플릿 상태를 한 화면에서 보고 검색·필터링합니다.
|
||||
- **알림 설정 > 비즈뿌리오 탭** — 알림 항목의 **[편집]** 을 누르면 알림 템플릿 편집 창 안에 **알림톡 템플릿** 섹션과 **문자(SMS)** 섹션이 함께 열립니다. 알림톡 본문·유형·버튼, 대체 SMS/SMS 단독 여부와 문자 본문, 수신자 규칙을 한 창에서 고치고 하단 **[저장]** 한 번으로 모두 저장합니다(알림톡 검수는 **[저장 후 검수 신청]** — 작성 폼 하단의 **검수자 전달 의견**에 변수 예시값 등을 적으면 신청과 함께 카카오 검수자에게 전달됩니다). 이 채널에서는 코어의 제목/본문 입력이 숨겨지고 알림톡 본문이 그 자리를 대신합니다. 알림 목록의 각 행 하단에는 승인 여부(**승인됨** / **미승인 (세부 상태)**)와 문자 설정 요약이 표시됩니다. 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다.
|
||||
- **플러그인 설정 > 알림 템플릿 관리** — 전체 알림의 템플릿 상태를 한 화면에서 보고 검색·필터링합니다(자체 작성/SMS 본문 모달).
|
||||
|
||||
기본 흐름은 **작성 → 검수 신청 → (카카오 승인) → 자동 발송** 입니다. 승인된 시점의 내용이 발송 기준으로 저장되며, 발송할 때마다 카카오를 조회하지 않습니다.
|
||||
|
||||
@@ -136,13 +136,13 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook
|
||||
| 차단 | ❌ | 카카오에서 차단된 템플릿 |
|
||||
| 휴면 | ❌ | 장기 미사용으로 휴면 전환. 화면의 [휴면 해제]로 복구 |
|
||||
|
||||
검수 결과는 30분 주기로 자동 확인하며, 각 행의 [새로고침]으로 즉시 확인할 수도 있습니다.
|
||||
검수 결과는 30분 주기로 자동 확인하며, 편집 창의 [새로고침]으로 즉시 확인할 수도 있습니다.
|
||||
|
||||
승인된 템플릿의 내용을 고치려면 [수정]을 눌러 승인을 먼저 취소해야 합니다. **승인을 취소하면 그 즉시 해당 알림의 알림톡 발송이 중단**되므로(대체 SMS 는 설정에 따라 계속 발송), 화면에서 확인 후 진행합니다.
|
||||
검수중·승인된 템플릿은 편집 창에서 내용이 잠기고 요약만 표시됩니다. 검수중이면 [신청 취소] 후, 승인된 템플릿은 [수정 (승인 취소)] 를 눌러 승인을 먼저 취소해야 고칠 수 있습니다. **승인을 취소하면 그 즉시 해당 알림의 알림톡 발송이 중단**되므로(대체 SMS 는 설정에 따라 계속 발송), 같은 창의 확인 박스에서 확인 후 진행합니다.
|
||||
|
||||
### 문자(SMS) 본문
|
||||
|
||||
알림마다 문자 본문을 따로 입력합니다. 쓰임은 두 가지이고 본문은 하나를 공유합니다.
|
||||
알림마다 문자 본문을 따로 입력합니다(편집 창의 문자(SMS) 섹션). 쓰임은 두 가지이고 본문은 하나를 공유하며, 카카오 검수 대상이 아니므로 [저장] 즉시 반영됩니다.
|
||||
|
||||
- **대체 SMS** — 알림톡 발송이 실패했을 때(수신 거부·미가입 등) 같은 번호로 문자를 대신 보냅니다.
|
||||
- **SMS 단독** — 알림톡을 쓰지 않고 문자로만 보냅니다. 이 항목을 켜면 템플릿이 승인 상태여도 알림톡을 보내지 않습니다.
|
||||
|
||||
@@ -672,6 +672,7 @@ Content-Type: application/json
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| id | path | integer | 예 | 숫자만 | 템플릿 PK |
|
||||
| comment | body | string | 아니오 | 500자 이내 (앞뒤 공백 제거, 비어 있으면 미전달) | 검수자 전달 의견 — kapi `template/request` 의 comment 로 그대로 실린다. 행에는 저장하지 않는다 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -679,7 +680,12 @@ Content-Type: application/json
|
||||
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}` 와 동일)
|
||||
@@ -710,7 +716,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
|
||||
| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | content 미작성 / 신청 불가 상태(draft·rejected 외) / kapi 실패(`errors.bizppurio_message`·`errors.result_code`) / 발신프로필 키(sender_key) 미설정 / 템플릿 코드 채번 실패 |
|
||||
| 422 | Unprocessable Entity | comment 500자 초과 / content 미작성 / 신청 불가 상태(draft·rejected 외) / kapi 실패(`errors.bizppurio_message`·`errors.result_code`) / 발신프로필 키(sender_key) 미설정 / 템플릿 코드 채번 실패 |
|
||||
|
||||
**설명**
|
||||
|
||||
@@ -720,6 +726,9 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
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
|
||||
|
||||
@@ -132,6 +132,7 @@ return [
|
||||
'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',
|
||||
|
||||
@@ -132,6 +132,7 @@ return [
|
||||
'message_type' => '메시지 유형',
|
||||
'emphasize_type' => '강조 유형',
|
||||
'template_content' => '본문',
|
||||
'comment' => '검수자 전달 의견',
|
||||
'preview_message' => '미리보기 문구',
|
||||
'category_code' => '카테고리',
|
||||
'security_flag' => '보안 템플릿 여부',
|
||||
|
||||
+75
-680
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extension_point": "notification_definition_row_footer",
|
||||
"comment": "알림 설정 '비즈뿌리오' 통합 탭의 각 알림 행 하단(코어 notification_definition_row_footer 슬롯)에 알림톡 라이프사이클 줄 + SMS 설정 줄을 채운다(#597 §4.1). activeChannel==='alimtalk'(통합 탭 정체성)일 때만 노출. 요약 데이터는 bizppurioTemplates(각 탭 오버레이가 등록·조회, GET templates/map)에서 def.type 으로 읽고, 작성/수정은 modal_bizppurio_template(오버레이 등록), SMS 본문은 modal_bizppurio_sms, 반려 사유는 modal_bizppurio_rejection, 승인 취소 경고는 modal_bizppurio_cancel_approval 을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.",
|
||||
"comment": "알림 설정 '비즈뿌리오' 통합 탭의 각 알림 행 하단 상태 요약(PO 결정 2026-08-23 — 버튼 없음, 조작은 코어 [편집] 모달로 일원화). activeChannel==='alimtalk'(통합 탭 정체성)일 때만 노출. 요약 데이터는 bizppurioTemplates(각 탭 오버레이가 등록·조회, GET templates/map)에서 def.type 으로 읽는다. 1줄: 알림톡 승인 2단 배지 + 신청/동기화일 + 발송 사용 여부 / 2줄: 문자(SMS) 대체·단독 사용 여부 + 본문 미리보기.",
|
||||
"priority": 320,
|
||||
"components": [
|
||||
{
|
||||
"id": "bizppurio_row_lifecycle",
|
||||
@@ -14,787 +15,181 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-center flex-wrap gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fas fa-comment-dots",
|
||||
"size": "sm",
|
||||
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
|
||||
"className": "text-sm text-teal-600 dark:text-teal-400 flex-shrink-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_label",
|
||||
"props": {
|
||||
"className": "text-sm font-medium text-gray-700 dark:text-gray-200 flex-shrink-0"
|
||||
}
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_label"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{$t('sirsoft-message_bizppurio.template.status.' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten'))}}",
|
||||
"comment": "승인 여부 2단 배지(PO 결정 2026-08-23): 1차 = 승인됨/미승인, 2차(괄호) = 세부 상태(미작성·작성중·검수중·반려·휴면·중지·차단). approved 는 세부 없이 단독 표기.",
|
||||
"props": {
|
||||
"className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'stopped' || (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}"
|
||||
}
|
||||
"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')}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}",
|
||||
"props": {
|
||||
"className": "text-xs text-gray-400 dark:text-gray-500 flex-shrink-0"
|
||||
},
|
||||
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.synced_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at ?? '').slice(5, 10)}}",
|
||||
"props": {
|
||||
"className": "text-xs text-gray-400 dark:text-gray-500 flex-shrink-0"
|
||||
},
|
||||
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}"
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "flex-center gap-2 min-w-0 flex-wrap"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
"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": "composite",
|
||||
"name": "Toggle",
|
||||
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}",
|
||||
"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": {
|
||||
"checked": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled ?? false}}",
|
||||
"size": "sm"
|
||||
"className": "font-normal opacity-80"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}",
|
||||
"params": {
|
||||
"method": "PUT",
|
||||
"body": {
|
||||
"alimtalk_enabled": "{{!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled ?? false)}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioTemplates"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
|
||||
"type": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
"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",
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_enabled",
|
||||
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}",
|
||||
"props": {
|
||||
"className": "text-xs text-gray-500 dark:text-gray-400"
|
||||
},
|
||||
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}"
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "flex-center gap-1.5"
|
||||
}
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"name": "Span",
|
||||
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}",
|
||||
"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"
|
||||
"className": "text-xs text-gray-500 dark:text-gray-400"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"bz_reject_view": {
|
||||
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
|
||||
"comments": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "openModal",
|
||||
"target": "modal_bizppurio_rejection"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.btn_view_reason",
|
||||
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'rejected' && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []).length > 0}}"
|
||||
"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": "Button",
|
||||
"name": "Span",
|
||||
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled === false)}}",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"bz_tpl_upload": { "uploading": false, "error": null },
|
||||
"bz_tpl_modal": {
|
||||
"id": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id ?? null}}",
|
||||
"notification_type": "{{extensionPointProps.definition?.type}}",
|
||||
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
|
||||
"variables": "{{extensionPointProps.definition?.variables ?? []}}",
|
||||
"status": "draft",
|
||||
"inspection_detail": [],
|
||||
"isSaving": false,
|
||||
"error": null,
|
||||
"sender_profile": "",
|
||||
"content": {
|
||||
"templateName": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
|
||||
"templateMessageType": "BA",
|
||||
"templateEmphasizeType": "NONE",
|
||||
"templateContent": "{{(((extensionPointProps.definition?.templates ?? []).find(tpl => tpl.channel === 'alimtalk')?.body?.[$locale] ?? '').split('{').join('#{'))}}",
|
||||
"templateExtra": "",
|
||||
"templateHeader": "",
|
||||
"templateTitle": "",
|
||||
"templateSubtitle": "",
|
||||
"templateImageName": "",
|
||||
"templateImageUrl": "",
|
||||
"categoryCode": "",
|
||||
"buttons": [],
|
||||
"quickReplies": [],
|
||||
"templateItem": {
|
||||
"list": [],
|
||||
"summary": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"templateItemHighlight": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"imageUrl": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "openModal",
|
||||
"target": "modal_bizppurio_template"
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioCategories"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioProfiles"
|
||||
}
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_disabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.btn_compose",
|
||||
"if": "{{!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) || !(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}",
|
||||
"params": {
|
||||
"method": "GET"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"bz_tpl_upload": { "uploading": false, "error": null },
|
||||
"bz_tpl_modal": {
|
||||
"id": "{{response.data?.template?.id}}",
|
||||
"notification_type": "{{response.data?.template?.notification_type}}",
|
||||
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? response.data?.template?.notification_type}}",
|
||||
"variables": "{{extensionPointProps.definition?.variables ?? []}}",
|
||||
"status": "{{response.data?.template?.status}}",
|
||||
"inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}",
|
||||
"isSaving": false,
|
||||
"error": null,
|
||||
"sender_profile": "",
|
||||
"content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "openModal",
|
||||
"target": "modal_bizppurio_template"
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioCategories"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioProfiles"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
|
||||
"type": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.btn_edit",
|
||||
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'draft' || (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'rejected')}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"bz_cancel_approval": {
|
||||
"id": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}",
|
||||
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
|
||||
"isSaving": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "openModal",
|
||||
"target": "modal_bizppurio_cancel_approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_approved",
|
||||
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'approved'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30",
|
||||
"disabled": "{{_global.bz_row_busy ?? false}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"bz_row_busy": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/request",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"bz_row_busy": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioTemplates"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "$t:sirsoft-message_bizppurio.template.row.requested_toast",
|
||||
"type": "success"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"bz_row_busy": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
|
||||
"type": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"if": "{{!(_global.bz_row_busy ?? false)}}"
|
||||
}
|
||||
],
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.btn_request",
|
||||
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'draft'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/cancel-request",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioTemplates"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "$t:sirsoft-message_bizppurio.template.row.cancel_request_toast",
|
||||
"type": "success"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
|
||||
"type": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.btn_cancel_request",
|
||||
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'requested'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/release",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioTemplates"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "$t:sirsoft-message_bizppurio.template.row.released_toast",
|
||||
"type": "success"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
|
||||
"type": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.btn_release",
|
||||
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'dormant'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/sync",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioTemplates"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "$t:sirsoft-message_bizppurio.template.row.synced_toast",
|
||||
"type": "success"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
|
||||
"type": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.btn_refresh",
|
||||
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.template_code}}"
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "flex-center gap-1.5 flex-shrink-0 flex-wrap"
|
||||
}
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "flex-between gap-2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-center flex-wrap gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fas fa-comment-sms",
|
||||
"size": "sm",
|
||||
"className": "text-gray-400 dark:text-gray-500 flex-shrink-0"
|
||||
"className": "text-sm text-gray-500 dark:text-gray-400 flex-shrink-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"comment": "알림톡 실패 시 SMS 대체발송 — 변경 즉시 저장(행이 없으면 draft 로 생성)",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "flex items-center gap-1.5 cursor-pointer select-none"
|
||||
"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 상태 배지 — 사용(초록)/미사용(회색). 문구 대신 배지(PO 지시 2026-08-23, 행의 활성·기본·승인 배지와 일관).",
|
||||
"props": {
|
||||
"className": "flex-center gap-1 flex-shrink-0"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"autoBinding": false,
|
||||
"checked": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}",
|
||||
"className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600"
|
||||
"className": "text-xs text-gray-500 dark:text-gray-400"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}",
|
||||
"params": {
|
||||
"method": "PUT",
|
||||
"body": {
|
||||
"fallback_sms_enabled": "{{$event.target.checked}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioTemplates"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
|
||||
"type": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms"
|
||||
},
|
||||
{
|
||||
"id": "bizppurio_row_sms_fallback_badge",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms",
|
||||
"props": {
|
||||
"className": "text-xs text-gray-600 dark:text-gray-300"
|
||||
}
|
||||
"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": "Label",
|
||||
"comment": "알림톡 없이 SMS 만 발송 — 변경 즉시 저장",
|
||||
"name": "Div",
|
||||
"comment": "sms_only 상태 배지 — 사용(초록)/미사용(회색). 문구 대신 배지(PO 지시 2026-08-23, 행의 활성·기본·승인 배지와 일관).",
|
||||
"props": {
|
||||
"className": "flex items-center gap-1.5 cursor-pointer select-none"
|
||||
"className": "flex-center gap-1 flex-shrink-0"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"autoBinding": false,
|
||||
"checked": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only)}}",
|
||||
"className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600"
|
||||
"className": "text-xs text-gray-500 dark:text-gray-400"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}",
|
||||
"params": {
|
||||
"method": "PUT",
|
||||
"body": {
|
||||
"sms_only": "{{$event.target.checked}}"
|
||||
}
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.sms_only"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "bizppurioTemplates"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
|
||||
"type": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
"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",
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.sms_only",
|
||||
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body === true}}",
|
||||
"props": {
|
||||
"className": "text-xs text-gray-600 dark:text-gray-300"
|
||||
}
|
||||
}
|
||||
]
|
||||
"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": "SMS 본문 미리보기. 로케일 선택·폴백·유무 판정은 서버(BizppurioTemplate::getLocalizedSmsBody/hasSmsBody)가 하고 화면은 결과만 그린다 — 여기서 맵을 직접 훑으면 발송 게이트와 규칙이 갈린다.",
|
||||
"text": "{{$t('sirsoft-message_bizppurio.template.row.sms_body_prefix')}} {{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '').slice(0, 40) + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '').length > 40 ? '…' : '')}}",
|
||||
"comment": "본문 미설정 = 값 없음 — '발송 사용 안 함' 칩과 같은 외곽선 스타일.",
|
||||
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body !== true}}",
|
||||
"props": {
|
||||
"className": "text-xs text-gray-500 dark:text-gray-400 truncate min-w-0"
|
||||
"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"
|
||||
},
|
||||
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body === true}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.sms_body_missing",
|
||||
"props": {
|
||||
"className": "text-xs text-gray-400 dark:text-gray-500"
|
||||
},
|
||||
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body !== true}}"
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "flex-center gap-3 min-w-0 flex-wrap"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"bz_sms_modal": {
|
||||
"notification_type": "{{extensionPointProps.definition?.type}}",
|
||||
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
|
||||
"variables": "{{extensionPointProps.definition?.variables ?? []}}",
|
||||
"body": "{{Object.assign({}, bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body)}}",
|
||||
"editLang": "{{$locale}}",
|
||||
"isSaving": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "openModal",
|
||||
"target": "modal_bizppurio_sms"
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.sms_body_missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_sms"
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "flex-between gap-2"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"priority": 320
|
||||
]
|
||||
}
|
||||
|
||||
+1
-2384
File diff suppressed because it is too large
Load Diff
+1
-2384
File diff suppressed because it is too large
Load Diff
+1
-2384
File diff suppressed because it is too large
Load Diff
+797
@@ -0,0 +1,797 @@
|
||||
{
|
||||
"extension_point": "notification_template_form_footer_actions",
|
||||
"comment": "코어 알림 템플릿 [편집] 모달 푸터에 주입되는 통합 저장 버튼(PO 결정 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}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+2714
File diff suppressed because it is too large
Load Diff
+13
-25
@@ -8,15 +8,16 @@
|
||||
*
|
||||
* 검증 방식: comment 필드 제거 후 JSON 문자열에서 면 고유 토큰(target_layout·target_id·
|
||||
* 컴포넌트 id 접두)만 코어 형으로 치환하면 코어 오버레이와 완전 동일해야 한다.
|
||||
* 반대로 데이터소스 id·모달 id·전역 상태 경로(bz_tpl_modal 등)는 치환 없이도 3면 동일해야
|
||||
* 한다 — notification_row_footer.json(전역 매칭)이 이 이름들을 공유 참조하기 때문이다.
|
||||
* 반대로 데이터소스 id 는 치환 없이도 3면 동일해야 한다 — notification_row_footer.json 과
|
||||
* notification_template_form_sections.json(전역 매칭, 3면 공유 1본)이 이 이름들을 공유 참조하기 때문이다.
|
||||
* 편집 UI(작성 폼·SMS·저장)는 코어 [편집] 모달의 extension_point 에 주입되는 공유 1본이라
|
||||
* 면 패리티 대상이 아니다(PO 결정 2026-08-23).
|
||||
*
|
||||
* 세 오버레이가 전문 동일하다는 것이 board/ecommerce 면의 화면 효과를 떠받치는 근거다 —
|
||||
* core 면에서 단언한 것들(탭 통합·행 하단 UI·업로드 잠금·SMS 언어 탭)이 두 면에도 있다는
|
||||
* 보장은 이 패리티뿐이고, 그래서 이 파일이 사라지면 두 면은 미측정으로 남는다.
|
||||
*
|
||||
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_badge_and_lifecycle_actions,
|
||||
* upload_in_progress_locks_save_buttons_not_cancel, sms_modal_edits_body_per_locale_tab
|
||||
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_summary_only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
@@ -68,13 +69,8 @@ const normalizedCore = JSON.stringify(stripComments(coreOverlay));
|
||||
|
||||
/** 3면 공유 이름(치환 없이 동일해야 하는 축) */
|
||||
const SHARED_DATA_SOURCE_IDS = ['bizppurioTemplates', 'bizppurioCategories', 'bizppurioProfiles'];
|
||||
const SHARED_MODAL_IDS = [
|
||||
'modal_bizppurio_template',
|
||||
'modal_bizppurio_sms',
|
||||
'modal_bizppurio_cancel_approval',
|
||||
'modal_bizppurio_rejection',
|
||||
];
|
||||
const SHARED_GLOBAL_STATE_PATHS = ['bz_tpl_modal', 'bz_sms_modal', 'bz_cancel_approval', 'bz_reject_view', 'bz_tpl_upload'];
|
||||
// 모달·전역 상태(bz_*)는 오버레이가 아니라 3면 공유 1본(notification_template_form_sections.json /
|
||||
// _footer_actions.json)이 소유한다(PO 결정 2026-08-23 — 편집 모달 통합). 오버레이는 모달을 등록하지 않는다.
|
||||
|
||||
describe.each(FIXTURES)('$label 오버레이 — 3면 패리티', ({ label, overlay, targetLayout, targetId, idPrefix }) => {
|
||||
it(`target_layout=${targetLayout} 이고 extension_point 키가 없다(overlay 전용)`, () => {
|
||||
@@ -102,27 +98,19 @@ describe.each(FIXTURES)('$label 오버레이 — 3면 패리티', ({ label, over
|
||||
expect(ids).toEqual(SHARED_DATA_SOURCE_IDS);
|
||||
});
|
||||
|
||||
it('모달 id 4종이 코어와 완전 동일하다(행 footer 의 openModal target 공유)', () => {
|
||||
const ids = ((overlay as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id);
|
||||
expect(ids).toEqual(SHARED_MODAL_IDS);
|
||||
});
|
||||
|
||||
it('전역 상태 경로(bz_*)가 접두 치환 없이 그대로 사용된다(면 전용 상태 분기 금지)', () => {
|
||||
it('오버레이는 모달을 등록하지 않고, 면 전용 전역 상태(bz_board_* 등)도 만들지 않는다', () => {
|
||||
expect((overlay as { modals?: AnyNode[] }).modals, `${label} 오버레이 modals`).toBeUndefined();
|
||||
const raw = JSON.stringify(overlay);
|
||||
for (const path of SHARED_GLOBAL_STATE_PATHS) {
|
||||
expect(raw, `${label} 에 ${path} 없음`).toContain(`_global.${path}`);
|
||||
// 면 접두가 붙은 변형(bz_board_* 등)이 존재하면 footer 공유가 깨진다
|
||||
expect(raw).not.toContain(path.replace('bz_', `bz_${idPrefix.replace('bizppurio_', '')}`));
|
||||
}
|
||||
expect(raw).not.toContain(`bz_${idPrefix.replace('bizppurio_', '')}`);
|
||||
expect(raw).not.toContain('openModal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3면 패리티 — 공유 축 교차 검증', () => {
|
||||
it('코어 오버레이도 동일한 공유 데이터소스·모달 id 집합을 갖는다(패리티 기준점 고정)', () => {
|
||||
it('코어 오버레이도 동일한 공유 데이터소스 id 집합을 갖고 모달은 없다(패리티 기준점 고정)', () => {
|
||||
const dsIds = ((coreOverlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
|
||||
const modalIds = ((coreOverlay as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id);
|
||||
expect(dsIds).toEqual(SHARED_DATA_SOURCE_IDS);
|
||||
expect(modalIds).toEqual(SHARED_MODAL_IDS);
|
||||
expect((coreOverlay as { modals?: AnyNode[] }).modals).toBeUndefined();
|
||||
});
|
||||
|
||||
it('세 오버레이의 init_actions(탭 진입 요약 맵 조회)가 comment 제외 완전 동일하다', () => {
|
||||
|
||||
+579
-429
File diff suppressed because it is too large
Load Diff
+232
-39
@@ -3,7 +3,7 @@
|
||||
/**
|
||||
* 플러그인 설정 — 알림 템플릿 관리 탭(DB 목록) 구조 검증 (#597 §4.3)
|
||||
*
|
||||
* @effects manage_screen_lists_db_rows_with_merge_query_round_trip
|
||||
* @effects manage_screen_lists_db_rows_with_merge_query_round_trip, inspection_request_carries_reviewer_comment
|
||||
*
|
||||
* templates 탭은 DB 목록 관리 화면이다:
|
||||
* - 데이터소스 bizppurio_templates_list(GET /admin/templates) — 필터·검색·페이지는
|
||||
@@ -15,7 +15,8 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import layout from '../../../layouts/admin/plugin_settings.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, evalBinding, type AnyNode } from './helpers';
|
||||
@@ -105,7 +106,7 @@ describe('manage — 필터·검색·페이지 (mergeQuery 왕복 규약)', () =
|
||||
|
||||
it('새로고침 버튼은 URL 을 건드리지 않고 목록만 refetch 한다(보던 목록 유지)', () => {
|
||||
const refreshBtn = findAllByName(manageView, 'Button')
|
||||
.find((b) => b.text === '$t:sirsoft-message_bizppurio.templates.list.refresh');
|
||||
.find((b) => (b.props as Record<string, unknown>)?.['aria-label'] === '$t:sirsoft-message_bizppurio.templates.list.refresh');
|
||||
expect(refreshBtn).toBeTruthy();
|
||||
const raw = JSON.stringify(refreshBtn);
|
||||
expect(raw).toContain('bizppurio_templates_list');
|
||||
@@ -157,15 +158,14 @@ describe('manage — 행 액션(알림 설정 탭 행 하단과 동일 엔드포
|
||||
});
|
||||
|
||||
/**
|
||||
* 관리 화면(bzRow)과 행 하단(요약 맵)의 노출 조건을 같은 상태 집합에 **실제로 태워** 대조한다.
|
||||
* 관리 화면(bzRow)과 코어 [편집] 모달 섹션(_global.bz_tpl_modal)의 노출 조건을 같은 상태
|
||||
* 집합에 **실제로 태워** 대조한다(PO 결정 2026-08-23 — 행 하단 버튼 폐지, 섹션이 SSoT).
|
||||
*
|
||||
* 문자열 동일성 단언은 두 면의 식이 실제로 같은 결과를 내는지 증명하지 못한다 — 실제로
|
||||
* `btn_refresh` 는 footer 가 `(row) && row?.template_code`, manage 는 `bzRow.template_code`
|
||||
* 로 서로 다른 모양이고, `=== true` 와 truthy 도 갈린다. 두 식을 각자의 컨텍스트로 평가해
|
||||
* 상태별 결과가 일치하는지를 본다.
|
||||
* 문자열 동일성 단언은 두 면의 식이 실제로 같은 결과를 내는지 증명하지 못한다 — 두 식을
|
||||
* 각자의 컨텍스트로 평가해 상태별 결과가 일치하는지를 본다.
|
||||
*/
|
||||
const MANAGE_ROWS: Record<string, Record<string, unknown>> = {
|
||||
'행만 있고 내용 없음': { has_content: false, status: 'draft' },
|
||||
'행만 있고 내용 없음': { 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' },
|
||||
@@ -178,60 +178,64 @@ describe('manage — 행 액션(알림 설정 탭 행 하단과 동일 엔드포
|
||||
|
||||
/** manage 화면 식을 bzRow 컨텍스트로 평가 */
|
||||
const evalManage = (expr: string, bzRow: Record<string, unknown>): boolean => {
|
||||
const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
|
||||
const body = expr.trim().replace(/^{{/, '').replace(/}}$/, '');
|
||||
// eslint-disable-next-line no-new-func
|
||||
return Boolean(new Function('bzRow', `return (${body});`)(bzRow));
|
||||
};
|
||||
|
||||
/** footer 식을 요약 맵 컨텍스트로 평가 */
|
||||
const evalFooter = (expr: string, bzRow: Record<string, unknown>): boolean => {
|
||||
const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
|
||||
/** 편집 모달 섹션 식을 _global.bz_tpl_modal 컨텍스트로 평가(섹션은 상세 GET 으로 status/template_code 를 시딩한다) */
|
||||
const evalSection = (expr: string, bzRow: Record<string, unknown>): boolean => {
|
||||
const body = expr.trim().replace(/^{{/, '').replace(/}}$/, '');
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('bizppurioTemplates', 'extensionPointProps', `return (${body});`);
|
||||
return Boolean(fn({ data: { templates: { welcome: bzRow } } }, { definition: { type: 'welcome' } }));
|
||||
// 섹션은 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 footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode;
|
||||
const footerRow = findById(footerRoot, 'bizppurio_row_lifecycle') as AnyNode;
|
||||
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_request',
|
||||
'template.row.btn_cancel_request',
|
||||
'template.row.btn_release',
|
||||
'template.row.btn_refresh',
|
||||
'template.row.btn_edit_approved',
|
||||
];
|
||||
|
||||
it.each(PAIRS)('%s 의 노출 조건이 관리 화면과 행 하단에서 상태별로 같은 결과를 낸다', (key) => {
|
||||
it.each(PAIRS)('%s 의 노출 조건이 관리 화면과 편집 모달 섹션에서 상태별로 같은 결과를 낸다', (key) => {
|
||||
const m = byTextKey(manageView, key);
|
||||
const f = byTextKey(footerRow, key);
|
||||
const f = byTextKey(sectionsRoot, key);
|
||||
expect(m?.if, `관리 화면에 ${key} if 가 없다`).toBeTruthy();
|
||||
expect(f?.if, `행 하단에 ${key} if 가 없다`).toBeTruthy();
|
||||
expect(f?.if, `편집 모달 섹션에 ${key} if 가 없다`).toBeTruthy();
|
||||
|
||||
for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) {
|
||||
expect(evalManage(m!.if as string, bzRow), `${key} @ ${state}`)
|
||||
.toBe(evalFooter(f!.if as string, bzRow));
|
||||
.toBe(evalSection(f!.if as string, bzRow));
|
||||
}
|
||||
});
|
||||
|
||||
it('작성/수정 버튼은 행 하단의 작성+수정 두 버튼을 합친 것과 같은 상태에서 노출된다', () => {
|
||||
// 관리 화면은 DB 목록이라 "행 없음" 이 없다 — footer 의 btn_compose(!row || !has_content)와
|
||||
// btn_edit(has_content && draft|rejected)를 합집합으로 놓고 대조한다.
|
||||
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 compose = byTextKey(footerRow, 'template.row.btn_compose');
|
||||
const edit = byTextKey(footerRow, 'template.row.btn_edit');
|
||||
expect(compose?.if).toBeTruthy();
|
||||
expect(edit?.if).toBeTruthy();
|
||||
|
||||
const editor = findById(sectionsRoot, 'bizppurio_tpl_editor');
|
||||
expect(editor?.if).toBeTruthy();
|
||||
for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) {
|
||||
const footerVisible = evalFooter(compose!.if as string, bzRow) || evalFooter(edit!.if as string, bzRow);
|
||||
expect(evalManage(m!.if as string, bzRow), `작성/수정 @ ${state}`).toBe(footerVisible);
|
||||
expect(evalManage(m!.if as string, bzRow), `작성/수정 @ ${state}`).toBe(evalSection(editor!.if as string, bzRow));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -246,15 +250,14 @@ describe('manage — 행 액션(알림 설정 탭 행 하단과 동일 엔드포
|
||||
expect(en.manage.btn_compose).toBeTruthy();
|
||||
});
|
||||
|
||||
it('승인 취소·SMS 본문·delivery upsert 는 행 하단(footer)과 같은 엔드포인트 계열을 쓴다', () => {
|
||||
it('상태 전이·delivery upsert 엔드포인트 계열이 편집 모달 섹션·푸터 액션과 같다', () => {
|
||||
const layoutEndpoints = collectEndpoints(layout);
|
||||
const footerEndpoints = collectEndpoints(footer);
|
||||
// footer 가 쓰는 계열(map 조회 제외 — 관리 화면은 DB 목록을 쓴다)이 관리 화면에도 존재한다
|
||||
for (const ep of footerEndpoints) {
|
||||
if (ep.endsWith('/map')) continue;
|
||||
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',
|
||||
);
|
||||
@@ -371,3 +374,193 @@ describe('manage 모달 — 이미지 업로드 배선·SMS 언어 탭 (#597 §1
|
||||
expect(modalRaw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)");
|
||||
});
|
||||
});
|
||||
|
||||
describe('manage 모달 — 검수자 전달 의견 (#597 §18.7, PO 결정 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 행 안에 끼지 않는다 (회귀: PO 화면 지적 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<string, unknown>)?.bz_tpl_modal);
|
||||
expect(seeds.length).toBeGreaterThan(0);
|
||||
for (const s of seeds) {
|
||||
expect(((s.params as Record<string, unknown>).bz_tpl_modal as Record<string, unknown>).request_comment).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('라벨·placeholder·안내 키가 ko/en 에 존재한다', () => {
|
||||
const pick = (json: unknown, key: string): unknown => key.split('.')
|
||||
.reduce<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[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 열은 상태 배지 (행 하단과 같은 규칙, PO 지시 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<string, unknown>): 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 — 목록 모바일 카드 전환 + 도구줄 버튼 일관성 (PO 지시 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<string, { props?: { className?: string } }> | 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<string, unknown>)?.['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');
|
||||
});
|
||||
});
|
||||
|
||||
+15
-13
@@ -12,7 +12,7 @@
|
||||
* 디스패치 시점의 동기 검사라 리렌더를 기다리지 않는다.
|
||||
*
|
||||
* 이 테스트는 신청 체인을 포함하는 모든 click 액션이 재진입 가드(if)를
|
||||
* 보유하는지 4개 산출 파일 전수로 고정한다. 가드가 한 파일에서라도 빠지면
|
||||
* 보유하는지 산출 파일 전수(편집 모달 푸터 1본 + 관리 화면)로 고정한다. 가드가 한 파일에서라도 빠지면
|
||||
* 그 면에서만 중복 신청이 재발한다.
|
||||
*
|
||||
* 실행 검증은 §6.3 10c 브라우저 실측이 담당한다. 라운드 5 실측 결과를 여기 남긴다:
|
||||
@@ -36,10 +36,9 @@ import { describe, it, expect } from 'vitest';
|
||||
const BASE = path.resolve(__dirname, '../../../..');
|
||||
|
||||
const FILES = [
|
||||
'resources/extensions/notification_tab_core.json',
|
||||
'resources/extensions/notification_tab_board.json',
|
||||
'resources/extensions/notification_tab_ecommerce.json',
|
||||
'resources/extensions/notification_row_footer.json',
|
||||
// 3면 공유 1본 — 코어 [편집] 모달 푸터에 주입되는 통합 저장/검수 신청 버튼(PO 결정 2026-08-23)
|
||||
'resources/extensions/notification_template_form_footer_actions.json',
|
||||
// 플러그인 관리 화면의 자기 모달
|
||||
'resources/layouts/admin/plugin_settings.json',
|
||||
];
|
||||
|
||||
@@ -113,8 +112,8 @@ describe('검수 신청 체인 재진입 가드 (#597 10c)', () => {
|
||||
const all = FILES.flatMap(collectRequestClickActions);
|
||||
|
||||
it('신청 체인을 포함한 click 액션이 산출 파일 전체에서 수집된다 (0건이면 아래 단언은 공회전한다)', () => {
|
||||
// 모달 저장 후 신청(신규/수정) ×4파일 + row footer 신청 + 관리 화면 행 신청
|
||||
expect(all.length).toBeGreaterThanOrEqual(6);
|
||||
// 편집 모달 푸터 [저장 후 검수 신청] 1 + 관리 화면(모달 저장 후 신청 신규/수정 + 행 신청)
|
||||
expect(all.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it.each(FILES)('%s 의 신청 체인 click 액션은 모두 재진입 if 가드를 갖는다', (file) => {
|
||||
@@ -172,9 +171,11 @@ describe('저장 체인 재진입 가드 (#597 라운드 5 §6.3 10c 실측 파
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node)) { node.forEach(walk); return; }
|
||||
const n = node as Record<string, any>;
|
||||
// 관리 화면 모달: 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');
|
||||
&& ((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 });
|
||||
@@ -188,14 +189,15 @@ describe('저장 체인 재진입 가드 (#597 라운드 5 §6.3 10c 실측 파
|
||||
return found;
|
||||
};
|
||||
|
||||
it('저장 버튼의 click 액션이 4개 산출 파일 전수에서 재진입 가드를 보유한다', () => {
|
||||
it('저장 버튼의 click 액션이 산출 파일 전수에서 재진입 가드를 보유한다', () => {
|
||||
const all = FILES.flatMap(collectSaveClickActions);
|
||||
// 3면 오버레이 + 관리 화면 = 4면 × (신규/수정) 2버튼
|
||||
expect(all.length, '저장 버튼 click 액션 수').toBe(8);
|
||||
// 편집 모달 푸터 통합 [저장] 1 + 관리 화면 모달 (신규/수정) 2버튼
|
||||
expect(all.length, '저장 버튼 click 액션 수').toBe(3);
|
||||
|
||||
for (const a of all) {
|
||||
// 편집 모달 푸터의 통합 [저장]은 isSaving 가드에 상세 GET 완료 전 가드(loading)를 AND 로 더 단다 — 포함 판정
|
||||
expect(a.ifExpr, `${a.file}: 저장 체인에 재진입 가드(if)가 없다 — 더블 클릭 시 중복 저장`)
|
||||
.toBe(GUARD);
|
||||
.toContain(GUARD.slice(2, -2));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"tab_guide": "Compose an alimtalk template per notification and request inspection. Once Kakao approves it, alimtalk is sent automatically. SMS fallback and SMS-only delivery are also configured here.",
|
||||
"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",
|
||||
@@ -137,7 +137,11 @@
|
||||
"sms_only": "SMS only",
|
||||
"sms_body_prefix": "Body:",
|
||||
"sms_body_missing": "No SMS body",
|
||||
"btn_edit_sms": "Edit 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}",
|
||||
@@ -199,6 +203,9 @@
|
||||
"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."
|
||||
},
|
||||
@@ -217,6 +224,18 @@
|
||||
"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": {
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"tab_guide": "알림별로 알림톡 템플릿을 작성해 검수를 신청하세요. 카카오 승인 후 알림톡이 자동 발송되며, 대체 SMS·SMS 단독 발송도 이 화면에서 설정합니다.",
|
||||
"tab_guide": "각 알림의 [편집]에서 알림톡 템플릿과 문자(SMS) 본문을 함께 작성하고 검수를 신청하세요. 카카오 승인 후 알림톡이 자동 발송되며, 대체 SMS·SMS 단독 발송도 같은 창에서 설정합니다.",
|
||||
"status": {
|
||||
"unwritten": "미작성",
|
||||
"draft": "작성중",
|
||||
@@ -137,7 +137,11 @@
|
||||
"sms_only": "SMS 단독",
|
||||
"sms_body_prefix": "본문:",
|
||||
"sms_body_missing": "SMS 본문 미설정",
|
||||
"btn_edit_sms": "SMS 본문 편집"
|
||||
"btn_edit_sms": "SMS 본문 편집",
|
||||
"sms_label": "문자(SMS)",
|
||||
"on": "사용",
|
||||
"off": "미사용",
|
||||
"alimtalk_disabled": "발송 사용 안 함"
|
||||
},
|
||||
"form": {
|
||||
"modal_title": "알림톡 템플릿 — {name}",
|
||||
@@ -199,6 +203,9 @@
|
||||
"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": "검수를 신청했습니다. 승인되면 알림톡 발송이 시작됩니다."
|
||||
},
|
||||
@@ -217,6 +224,18 @@
|
||||
"rejection": {
|
||||
"modal_title": "반려 사유 — {name}",
|
||||
"btn_close": "닫기"
|
||||
},
|
||||
"approval": {
|
||||
"approved": "승인됨",
|
||||
"not_approved": "미승인"
|
||||
},
|
||||
"section": {
|
||||
"alimtalk_title": "알림톡 템플릿",
|
||||
"sms_title": "문자(SMS)",
|
||||
"alimtalk_enabled": "알림톡 발송 사용",
|
||||
"locked_hint": "검수중이거나 승인된 템플릿은 내용을 수정할 수 없습니다. 수정하려면 [신청 취소] 또는 [수정 (승인 취소)]를 먼저 누르세요.",
|
||||
"saved_toast": "알림 템플릿을 저장했습니다.",
|
||||
"loading": "템플릿 내용을 불러오는 중…"
|
||||
}
|
||||
},
|
||||
"manage": {
|
||||
|
||||
+233
-64
@@ -1648,7 +1648,7 @@
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"className": "w-40 text-sm",
|
||||
"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)}))]}}"
|
||||
},
|
||||
@@ -1677,13 +1677,20 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "flex-1 min-w-0 text-sm"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 w-56",
|
||||
"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 ?? '')}}"
|
||||
},
|
||||
@@ -1696,14 +1703,21 @@
|
||||
"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": "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"
|
||||
"className": "btn btn-primary whitespace-nowrap flex-shrink-0"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -1737,7 +1751,7 @@
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "flex-center gap-2 flex-wrap"
|
||||
"className": "flex-center gap-2 flex-wrap flex-1 min-w-0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1745,7 +1759,8 @@
|
||||
"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"
|
||||
"aria-label": "$t:sirsoft-message_bizppurio.templates.list.refresh",
|
||||
"className": "btn-icon flex-shrink-0"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -1756,7 +1771,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "$t:sirsoft-message_bizppurio.templates.list.refresh"
|
||||
"comment": "새로고침 — 코어 관리자 목록 관례(btn-icon + 회전 아이콘)와 통일 (PO 지시 2026-08-24).",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fas fa-arrows-rotate",
|
||||
"className": "text-base"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
@@ -1776,105 +1801,125 @@
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"id": "bz_manage_list_header",
|
||||
"type": "basic",
|
||||
"name": "Table",
|
||||
"name": "Div",
|
||||
"comment": "데스크톱 grid 헤더 — portable 에서는 카드가 자기 서술이므로 숨긴다 (PO 지시 2026-08-24 모바일 카드뷰).",
|
||||
"props": {
|
||||
"className": "w-full text-left"
|
||||
"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": "Thead",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "bg-gray-50 dark:bg-gray-900"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Tr",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Th",
|
||||
"props": {
|
||||
"className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
"className": "col-span-3"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.manage.columns.notification"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Th",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
"className": "col-span-2"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.manage.columns.owner"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Th",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
"className": "col-span-1"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.manage.columns.status"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Th",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
"className": "col-span-1"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.manage.columns.sms"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Th",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
"className": "col-span-1"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.manage.columns.requested_at"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Th",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-2.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
"className": "col-span-1"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.manage.columns.synced_at"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Th",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-2.5 text-right text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
"className": "col-span-3 text-right"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.manage.columns.actions"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "bz_manage_list_rows",
|
||||
"type": "basic",
|
||||
"name": "Tbody",
|
||||
"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": "Tr",
|
||||
"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": "border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
"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": "Td",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200"
|
||||
"className": "col-span-3"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "w-full"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -1892,22 +1937,45 @@
|
||||
"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": "Td",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200"
|
||||
"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": "Td",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200"
|
||||
"className": "col-span-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -1922,33 +1990,64 @@
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Td",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200"
|
||||
"className": "col-span-1"
|
||||
},
|
||||
"text": "{{bzRow.sms_only ? $t('sirsoft-message_bizppurio.manage.sms_only') : (bzRow.fallback_sms_enabled ? $t('sirsoft-message_bizppurio.manage.sms_fallback') : '-')}}"
|
||||
"comment": "SMS 발송 모드 배지 — 단독/대체(초록), 미사용(회색). 행 하단 상태 배지와 같은 규칙(PO 지시 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": "Td",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200 tabular-nums"
|
||||
"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": "Td",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-3 text-sm text-gray-700 dark:text-gray-200 tabular-nums"
|
||||
"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": "Td",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-4 py-3"
|
||||
"className": "col-span-3"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "w-full pt-1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -2072,7 +2171,8 @@
|
||||
"description": "",
|
||||
"imageUrl": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"request_comment": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2540,7 +2640,7 @@
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "admin-card overflow-x-auto p-0"
|
||||
"className": "admin-card p-0 overflow-hidden"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3421,7 +3521,7 @@
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "space-y-1.5"
|
||||
"className": "space-y-2"
|
||||
},
|
||||
"if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}"
|
||||
},
|
||||
@@ -3654,7 +3754,7 @@
|
||||
"index_var": "buttonsIdx"
|
||||
},
|
||||
"props": {
|
||||
"className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5"
|
||||
"className": "p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -3866,7 +3966,7 @@
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "space-y-1.5"
|
||||
"className": "space-y-2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3919,7 +4019,7 @@
|
||||
"index_var": "quickRepliesIdx"
|
||||
},
|
||||
"props": {
|
||||
"className": "p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1.5"
|
||||
"className": "p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -4131,8 +4231,71 @@
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
"className": "space-y-1.5"
|
||||
"className": "space-y-2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bz_manage_request_comment",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"comment": "검수자 전달 의견 — kapi template/request 의 comment(≤500). 카카오 심사 가이드가 요구하는 변수 예시값 등을 검수자에게 전할 유일한 통로(PO 결정 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4354,7 +4517,10 @@
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
@@ -4452,7 +4618,10 @@
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
|
||||
+4
-2
@@ -12,6 +12,7 @@ use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioTemplateStateException;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\BizppurioTemplateImageRequest;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\BizppurioTemplateListRequest;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\RequestBizppurioInspectionRequest;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreBizppurioTemplateRequest;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateBizppurioDeliveryRequest;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateBizppurioTemplateRequest;
|
||||
@@ -164,10 +165,11 @@ class BizppurioTemplateController extends AdminBaseController
|
||||
/**
|
||||
* 검수를 신청합니다 (채번 → kapi add/update → request → status=requested).
|
||||
*
|
||||
* @param RequestBizppurioInspectionRequest $request 검수자 전달 의견(comment, 선택 ≤500)
|
||||
* @param int $id 템플릿 PK
|
||||
* @return JsonResponse data.template 에 갱신된 행
|
||||
*/
|
||||
public function requestInspection(int $id): JsonResponse
|
||||
public function requestInspection(RequestBizppurioInspectionRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$template = $this->service->find($id);
|
||||
if ($template === null) {
|
||||
@@ -176,7 +178,7 @@ class BizppurioTemplateController extends AdminBaseController
|
||||
|
||||
return $this->guardLifecycle(fn () => ResponseHelper::success(
|
||||
'sirsoft-message_bizppurio::messages.template.requested',
|
||||
['template' => $this->detailRow($this->service->requestInspection($template))],
|
||||
['template' => $this->detailRow($this->service->requestInspection($template, $request->comment()))],
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 알림톡 템플릿 검수 신청 요청 검증 (#597 §18.7 — PO 결정 2026-08-23).
|
||||
*
|
||||
* 카카오 심사 가이드는 템플릿 변수마다 '예시 텍스트' 를 요구하지만 kapi `template/add` 에는
|
||||
* 예시 필드가 없다. 검수자에게 무언가를 전달할 유일한 통로가 `template/request` 의
|
||||
* comment(≤500)이므로 그 값을 선택 입력으로 받는다. 행에는 저장하지 않고 신청 호출에만 싣는다.
|
||||
*/
|
||||
class RequestBizppurioInspectionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한은 라우트 미들웨어(messaging.manage)에서 처리한다.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙을 반환합니다.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
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<string, string> 필드 경로 → 라벨
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'comment' => __('sirsoft-message_bizppurio::messages.validation.attributes.comment'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+6
@@ -270,6 +270,11 @@ class RegisterNotificationChannelsListener implements HookListenerInterface
|
||||
* - sms.hidden_tab: 탭만 숨긴다(채널 토글 카드·발송 축은 그대로).
|
||||
* - alimtalk.tab_channels: 이 목록 중 하나라도 활성 저장이면 탭을 노출한다.
|
||||
* - alimtalk.tab_label_key: 탭 라벨용 프론트 lang 키($t 해석 — 카드 라벨(name)과 분리).
|
||||
* - alimtalk.hidden_template_editor: 코어 알림 템플릿 [편집] 모달의 제목/본문 입력·미리보기·
|
||||
* 코어 [저장]을 숨긴다(PO 결정 2026-08-23). 알림톡 본문은 카카오 규격(#{var}, 유형별 필드)이라
|
||||
* 코어 본문과 다른 편집기가 필요하고, 그 편집기·통합 저장은 플러그인이 같은 모달의
|
||||
* extension_point(notification_template_form_sections / _footer_actions)에 주입한다.
|
||||
* 코어 채널 템플릿 행(제목/본문)은 발송에 쓰이지 않으므로 숨겨도 동작 손실이 없다.
|
||||
* 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다
|
||||
* (is_test_mode 와 동일 경로).
|
||||
*
|
||||
@@ -298,6 +303,7 @@ class RegisterNotificationChannelsListener implements HookListenerInterface
|
||||
'allow_guest' => true,
|
||||
'tab_channels' => ['sms', 'alimtalk'],
|
||||
'tab_label_key' => self::PLUGIN_IDENTIFIER.'.channels.bizppurio_tab',
|
||||
'hidden_template_editor' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
+9
-2
@@ -16,14 +16,20 @@ use App\Contracts\Extension\HookListenerInterface;
|
||||
* (NotificationSyncHelper::syncTemplate), cleanupStaleTemplates 의 definedChannels 에 우리
|
||||
* 채널이 포함되므로 재시딩·업데이트에도 삭제되지 않는다. 코어·모듈 파일은 수정하지 않는다.
|
||||
*
|
||||
* 구독 훅(3영역, 정의 배열을 넘기는 필터):
|
||||
* 구독 훅(정의 배열을 넘기는 필터 4종):
|
||||
* - seed.notifications.translations (코어 — config/core.php)
|
||||
* - seed.sirsoft-board.notifications.translations (게시판)
|
||||
* - seed.sirsoft-ecommerce.notifications.translations (이커머스)
|
||||
* - core.notification.filter_default_definitions (코어 [기본값 복원] 경로 —
|
||||
* NotificationTemplateService::getDefaultTemplateData). 시딩 3훅과 같은 증강을 적용해야
|
||||
* 우리가 시드한 sms·alimtalk 행의 [기본값 복원]이 "기본 템플릿 데이터를 찾을 수 없습니다" 로
|
||||
* 끝나지 않는다 — 시드 출처와 복원 출처가 같은 함수여야 한다(#597 §18.7 C2 실측 결함).
|
||||
* 게시판·이커머스가 자기 정의를 이 필터에 보태는 priority 20 뒤(50)에 돌아야 모듈 정의도
|
||||
* 증강된다.
|
||||
*
|
||||
* 배열 형태 차이:
|
||||
* - 코어: 연관 배열 `['welcome' => [...], ...]` (키가 type)
|
||||
* - 모듈: 순차 배열 `[['type' => 'order_confirmed', ...], ...]`
|
||||
* - 모듈·기본값 복원 경로: 순차 배열 `[['type' => 'order_confirmed', ...], ...]`
|
||||
* 둘 다 각 정의 값에 대해 동일하게 채널을 증강하고 키/순서는 보존한다.
|
||||
*
|
||||
* 증강 대상 = 회원(사용자) 대상 알림(결정 E). 관리자 전용(수신자가 role:admin 뿐인) 알림은
|
||||
@@ -54,6 +60,7 @@ class SeedChannelTemplatesListener implements HookListenerInterface
|
||||
'seed.notifications.translations',
|
||||
'seed.sirsoft-board.notifications.translations',
|
||||
'seed.sirsoft-ecommerce.notifications.translations',
|
||||
'core.notification.filter_default_definitions',
|
||||
] as $hook) {
|
||||
$hooks[$hook] = [
|
||||
'method' => 'augment',
|
||||
|
||||
+9
-2
@@ -193,14 +193,17 @@ class BizppurioTemplateService
|
||||
* - template_code 가 없으면(최초) 자체 채번 후 kapi add, 있으면(재신청) kapi update.
|
||||
* add 성공 시점에만 template_code 를 행에 확정하므로, add 자체가 실패하면 다음
|
||||
* 시도에서 다시 채번부터 진행된다.
|
||||
* - 검수자 전달 의견(comment)은 kapi request 에만 싣고 행에 저장하지 않는다 — 카카오 심사
|
||||
* 가이드가 요구하는 변수 '예시 텍스트' 를 전할 유일한 통로다(PO 결정 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): BizppurioTemplate
|
||||
public function requestInspection(BizppurioTemplate $template, ?string $comment = null): BizppurioTemplate
|
||||
{
|
||||
if (! is_array($template->content) || $template->content === []) {
|
||||
throw new BizppurioTemplateStateException(
|
||||
@@ -248,7 +251,11 @@ class BizppurioTemplateService
|
||||
$this->assertSuccess($this->kakao->updateTemplate($payload));
|
||||
}
|
||||
|
||||
$this->assertSuccess($this->kakao->requestInspection($senderKey, (string) $template->template_code));
|
||||
$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]);
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Notification;
|
||||
|
||||
use App\Extension\HookListenerRegistrar;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationTemplateService;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\SeedChannelTemplatesListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 코어 [기본값 복원]이 플러그인이 시드한 alimtalk 채널 template 에서도 동작한다 (#597 §18.7 C2).
|
||||
*
|
||||
* 실측 결함: welcome 알림톡 행의 [기본값 복원] → 404 "기본 템플릿 데이터를 찾을 수 없습니다".
|
||||
* 플러그인이 시딩 훅(seed.*.notifications.translations)으로 행을 만들어 놓고, 복원 경로가
|
||||
* 통과하는 필터(core.notification.filter_default_definitions)에는 참여하지 않아서였다.
|
||||
*
|
||||
* 리스너 메서드를 직접 부르지 않는다 — 실제 등록 경로(HookListenerRegistrar)로 올린 뒤
|
||||
* 코어 서비스·엔드포인트의 관찰 가능한 결과(복원값·행 상태)로 판정한다.
|
||||
*/
|
||||
class CoreTemplateResetDefaultsTest extends PluginTestCase
|
||||
{
|
||||
/** 리스너를 실제 훅 파이프라인에 등록합니다 (getSubscribedHooks 기반). */
|
||||
private function registerListener(): void
|
||||
{
|
||||
HookListenerRegistrar::register(SeedChannelTemplatesListener::class, 'plugin:sirsoft-message_bizppurio');
|
||||
}
|
||||
|
||||
/** config/core.php welcome 정의의 database 채널 본문(ko) — 시딩·복원이 공유하는 출처. */
|
||||
private function welcomeDatabaseBodyKo(): string
|
||||
{
|
||||
foreach (config('core.notification_definitions.welcome.templates', []) as $template) {
|
||||
if (($template['channel'] ?? null) === 'database') {
|
||||
return (string) $template['body']['ko'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
+40
@@ -609,6 +609,46 @@ class BizppurioTemplateControllerTest extends PluginTestCase
|
||||
Http::assertSentCount(3); // codeCheck → add → request
|
||||
}
|
||||
|
||||
/**
|
||||
* 검수자 전달 의견(comment)은 선택 입력이며 kapi request 로만 전달된다 (#597 §18.7 — PO 결정 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
|
||||
*
|
||||
|
||||
+137
-31
@@ -1,14 +1,18 @@
|
||||
/**
|
||||
* E2E 정밀 점검 매트릭스: 비즈뿌리오 알림 템플릿 라이프사이클 (#597 §6.3)
|
||||
*
|
||||
* @scenario bizppurio_tab_integration_e2e, bizppurio_compose_modal_e2e, bizppurio_manage_round_trip_e2e
|
||||
* @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_badge_and_lifecycle_actions, compose_modal_switches_conditional_fields_by_type,
|
||||
* 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
|
||||
* sms_body_presence_and_preview_resolved_by_server, inspection_request_carries_reviewer_comment
|
||||
*
|
||||
* 계획서 §6.3 이 요구한 7대 축(T1~T7) + 선택 축(T8~T10) + 회귀 축(R1~R6)을 브라우저에서 실측한다.
|
||||
* PO 결정(2026-08-23, 계획서 §18)으로 알림톡 작성·SMS 본문 편집은 행 하단 버튼/별도 모달이 아니라
|
||||
* 코어 [편집] 모달 안의 섹션으로 통합됐다 — 모달 진입은 언제나 행의 [편집] 이고, 저장은 하단 [저장] 하나다.
|
||||
* 이 매트릭스는 라운드 1~4 동안 "수행 증거 없음" 으로 남아 있었고, 그 근본 원인은 플러그인
|
||||
* package.json 에 실행 진입점(test:e2e)이 없었다는 것이다 — 라운드 5 에서 진입점을 만들고
|
||||
* 매트릭스를 spec 으로 고정해 다음 라운드부터는 명령 하나로 재실측된다.
|
||||
@@ -71,7 +75,7 @@ function dialog(page: Page) {
|
||||
return page.getByRole('dialog');
|
||||
}
|
||||
|
||||
/** 모달 안의 저장 버튼(신규/수정 공통 — if 로 하나만 렌더된다) */
|
||||
/** 편집 모달 푸터의 통합 [저장](코어 [저장]은 hidden_template_editor 게이트로 숨는다 — 하나만 렌더된다) */
|
||||
function modalSave(page: Page) {
|
||||
return dialog(page).getByRole('button', { name: '저장', exact: true });
|
||||
}
|
||||
@@ -94,12 +98,18 @@ async function seedTemplate(page: Page, wanted = 'welcome'): Promise<string> {
|
||||
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: '001001',
|
||||
categoryCode: typeof firstCategory === 'string' && firstCategory ? firstCategory : '001001',
|
||||
};
|
||||
|
||||
const listRes = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates?per_page=50', { headers });
|
||||
@@ -110,6 +120,19 @@ async function seedTemplate(page: Page, wanted = 'welcome'): Promise<string> {
|
||||
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 }),
|
||||
@@ -128,22 +151,35 @@ async function seedTemplate(page: Page, wanted = 'welcome'): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 시드된 행의 수정 모달을 연다.
|
||||
* 알림 행의 코어 [편집] 버튼(그 행 카드 안의 것)을 찾는다.
|
||||
*
|
||||
* 행 카드는 유형 코드(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) {
|
||||
async function openEditModal(page: Page, type?: string) {
|
||||
await page.goto(SETTINGS_URL);
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
// 행 컨테이너 id 는 모든 행이 공유하므로 유형으로 특정할 수 없다. 목록 순서는 재조회
|
||||
// 후에도 동일하므로, 같은 테스트 안에서 first() 는 항상 같은 행을 연다 — 저장→새로고침→
|
||||
// 재오픈 왕복 비교에는 그것으로 충분하다. (테스트 간 간섭은 알림 유형 분리로 막는다.)
|
||||
const edit = page.getByRole('button', { name: '수정', exact: true }).first();
|
||||
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('[id^="bizppurio_tpl_modal_body"]').first();
|
||||
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;
|
||||
}
|
||||
@@ -196,6 +232,13 @@ test.describe('T1 진입/표시', () => {
|
||||
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);
|
||||
@@ -255,9 +298,23 @@ test.describe('T2·T3 입력', () => {
|
||||
await authenticatePage(page, messagingManageToken);
|
||||
await openBizppurioTab(page);
|
||||
|
||||
await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click();
|
||||
const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first();
|
||||
await expect(modal).toBeVisible({ timeout: 15_000 });
|
||||
await 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);
|
||||
@@ -303,9 +360,8 @@ test.describe('T2·T3 입력', () => {
|
||||
test('3a 버튼 3회 추가 + 1회 삭제 → 카운트 정합, 5개 도달 시 추가 잠김', async ({ page, messagingManageToken }) => {
|
||||
await authenticatePage(page, messagingManageToken);
|
||||
await openBizppurioTab(page);
|
||||
await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click();
|
||||
const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first();
|
||||
await expect(modal).toBeVisible({ timeout: 15_000 });
|
||||
await seedTemplate(page, 'welcome');
|
||||
const modal = await openEditModal(page, 'welcome');
|
||||
|
||||
const addBtn = modal.getByRole('button', { name: '버튼 추가' });
|
||||
const rowCount = () => modal.getByPlaceholder('버튼명', { exact: false }).count();
|
||||
@@ -360,7 +416,7 @@ test.describe('T4 제출/응답 + T10 경계', () => {
|
||||
const results: Record<string, number> = {};
|
||||
const typed: Record<string, number> = {};
|
||||
for (const len of [999, 1000, 1001]) {
|
||||
const modal = await openEditModal(page);
|
||||
const modal = await openEditModal(page, 'reset_password');
|
||||
|
||||
const ta = modal.locator('textarea[name="bz_template_content"]');
|
||||
await ta.fill('가'.repeat(len));
|
||||
@@ -387,10 +443,13 @@ test.describe('T4 제출/응답 + T10 경계', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
const modal = await openEditModal(page, 'password_changed');
|
||||
await modal.locator('textarea[name="bz_template_content"]').fill('중복 제출 테스트 #{site_name}');
|
||||
|
||||
let saves = 0;
|
||||
@@ -418,13 +477,40 @@ test.describe('T4 제출/응답 + T10 경계', () => {
|
||||
// 판정 축은 "요청 수" 가 아니라 **중복 신청 0** 이다. 클라이언트 if 가드는
|
||||
// setState 반영 전에 두 번째 click 이 디스패치되면 뚫린다(실측). 실제 방어는
|
||||
// 서버의 원자 선점(claimForInspection)이며, 두 번째 신청은 422 로 거부된다.
|
||||
const modal2 = await openEditModal(page);
|
||||
const modal2 = await openEditModal(page, 'password_changed');
|
||||
await modal2.locator('textarea[name="bz_template_content"]').fill('중복 신청 테스트 #{site_name}');
|
||||
// 검수자 전달 의견(PO 결정 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -441,7 +527,7 @@ test.describe('T6·T7 영속성·컨텍스트 격리', () => {
|
||||
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);
|
||||
let modal = await openEditModal(page, 'welcome');
|
||||
const targetId = await openedId();
|
||||
evidence('6a 측정 대상 행 id', targetId);
|
||||
expect(targetId, '수정 모달이 연 행의 id 를 읽지 못했다').not.toBeNull();
|
||||
@@ -450,7 +536,7 @@ test.describe('T6·T7 영속성·컨텍스트 격리', () => {
|
||||
const beforeSave = await page.evaluate(() => {
|
||||
const g = (window as any).G7Core?.state?.get?.() ?? {};
|
||||
const m = g.bz_tpl_modal ?? {};
|
||||
return { id: m.id ?? null, len: (m.content?.templateContent ?? '').length, modals: document.querySelectorAll('[id^="bizppurio_tpl_modal_body"]').length };
|
||||
return { id: m.id ?? null, len: (m.content?.templateContent ?? '').length, modals: document.querySelectorAll('#bizppurio_tpl_sections').length };
|
||||
});
|
||||
evidence('6a 저장 직전 전역 상태', beforeSave);
|
||||
|
||||
@@ -474,7 +560,7 @@ test.describe('T6·T7 영속성·컨텍스트 격리', () => {
|
||||
expect(stored, '이모지·특수문자·#{var} 가 섞인 본문이 그대로 저장되어야 한다').toBe(body);
|
||||
|
||||
// ② 새로고침 후 화면이 그 값을 복원하는가 (영속성 UI 축)
|
||||
modal = await openEditModal(page);
|
||||
modal = await openEditModal(page, 'welcome');
|
||||
const reopenedId = await openedId();
|
||||
const restored = await modal.locator('textarea[name="bz_template_content"]').inputValue();
|
||||
const expected = reopenedId === targetId
|
||||
@@ -509,15 +595,16 @@ test.describe('T6·T7 영속성·컨텍스트 격리', () => {
|
||||
evidence('7a 탭 왕복 전/후 쿼리', { before, after });
|
||||
expect(after).toContain('channel=alimtalk');
|
||||
|
||||
// 7b: 모달에 값을 넣고 저장 없이 닫은 뒤 다시 열면 이전 입력이 남지 않는다
|
||||
const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first();
|
||||
await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click();
|
||||
// 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 page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click();
|
||||
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('이월되면 안 되는 값'));
|
||||
@@ -559,6 +646,26 @@ test.describe('T8 3면 패리티·다크·로케일', () => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -616,9 +723,8 @@ test.describe('라운드5 신규 축 — 업로드 가드·관리 라벨·SMS
|
||||
test('U1 업로드 진행 중 저장 계열 버튼이 잠기고 취소는 열려 있다', async ({ page, messagingManageToken }) => {
|
||||
await authenticatePage(page, messagingManageToken);
|
||||
await openBizppurioTab(page);
|
||||
await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click();
|
||||
const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first();
|
||||
await expect(modal).toBeVisible({ timeout: 15_000 });
|
||||
await seedTemplate(page, 'welcome');
|
||||
const modal = await openEditModal(page, 'welcome');
|
||||
await modal.getByRole('button', { name: '이미지', exact: true }).click();
|
||||
|
||||
// 업로드 응답을 붙잡아 in-flight 상태를 만든다(네트워크 지연 재현).
|
||||
|
||||
+76
-12
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* @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
|
||||
* 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. 코어 알림 설정 서브탭 통합 — '비즈뿌리오' 단일 탭 노출, 문자·알림톡 개별 탭 부재
|
||||
@@ -66,8 +67,8 @@ test.describe('비즈뿌리오 통합 탭 (코어 알림 설정)', () => {
|
||||
});
|
||||
|
||||
// @scenario bizppurio_tab_integration_e2e
|
||||
// @effects row_footer_shows_status_badge_and_lifecycle_actions
|
||||
test('알림 행 하단에 알림톡 라이프사이클 줄과 SMS 설정 줄이 렌더된다', async ({ page, messagingManageToken }) => {
|
||||
// @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 });
|
||||
@@ -75,11 +76,16 @@ test.describe('비즈뿌리오 통합 탭 (코어 알림 설정)', () => {
|
||||
const rows = page.locator('[id^="bizppurio_row_lifecycle"]');
|
||||
await expect(rows.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// 알림톡 라벨 + 상태 배지(미작성/작성중/…) + SMS 체크박스 라벨
|
||||
// 알림톡 라벨 + 승인 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: 미사용")가 아니라 배지(사용/미사용)로 렌더된다 (PO 지시 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();
|
||||
@@ -87,24 +93,41 @@ test.describe('비즈뿌리오 통합 탭 (코어 알림 설정)', () => {
|
||||
expect(content).not.toContain('sirsoft-message_bizppurio.');
|
||||
});
|
||||
|
||||
// @scenario bizppurio_compose_modal_e2e
|
||||
// @effects compose_modal_switches_conditional_fields_by_type
|
||||
test('작성 모달의 강조 유형 전환 시 조건부 필드가 등장·소멸한다', async ({ page, messagingManageToken }) => {
|
||||
// @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 });
|
||||
|
||||
// 미작성 행의 [알림톡 템플릿 작성] → 모달 오픈
|
||||
const composeButton = page.getByRole('button', { name: '알림톡 템플릿 작성' }).first();
|
||||
await expect(composeButton).toBeVisible({ timeout: 30_000 });
|
||||
await composeButton.click();
|
||||
// 행의 코어 [편집] → 같은 모달 안에 알림톡 템플릿·문자(SMS) 섹션이 주입된다(작성 폼은 draft/rejected/미작성 행에서 열린다)
|
||||
const editButton = page.getByRole('button', { name: '편집', exact: true }).first();
|
||||
await expect(editButton).toBeVisible({ timeout: 30_000 });
|
||||
await editButton.click();
|
||||
|
||||
const modal = page.locator('[id^="bizppurio_tpl_modal_body"]').first();
|
||||
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 행 안에 끼어 라벨이 세로로 찌그러짐 — PO 화면 지적 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);
|
||||
|
||||
@@ -147,6 +170,9 @@ test.describe('비즈뿌리오 알림 템플릿 관리 (플러그인 설정)', (
|
||||
|
||||
await expect(manageView).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// 목록 SMS 열은 평문(단독/대체/-)이 아니라 상태 배지(단독·대체=초록, 미사용=회색) — 행 하단과 같은 규칙 (PO 지시 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();
|
||||
@@ -160,4 +186,42 @@ test.describe('비즈뿌리오 알림 템플릿 관리 (플러그인 설정)', (
|
||||
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 });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+40
@@ -146,4 +146,44 @@ class SeedChannelTemplatesListenerTest extends PluginTestCase
|
||||
$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'], '기존 필드는 보존된다.');
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -733,4 +733,56 @@ class BizppurioTemplateServiceTest extends PluginTestCase
|
||||
|
||||
app()->setLocale('ko');
|
||||
}
|
||||
|
||||
/**
|
||||
* 검수자 전달 의견은 kapi request 의 comment 로만 실린다 (#597 §18.7 — PO 결정 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+31
-6
@@ -15,7 +15,8 @@ description: |
|
||||
- 동기화: 스케줄러(30분, requested 있을 때만 list 일괄 대조) + 수동 새로고침(detail 1건).
|
||||
승인 전이 시 content 를 approved_content 로 동결(발송 SSoT), 반려 시 comments 저장
|
||||
- 화면: 3면(코어/게시판/이커머스) '비즈뿌리오' 통합 탭(sms 탭 숨김 + tab_channels 규칙)
|
||||
행 하단 라이프사이클 UI + 작성 모달(4×4 유형) + 플러그인 관리 화면(DB 목록)
|
||||
행 하단 상태 요약(승인 2단 배지) + 코어 [편집] 모달에 녹인 알림톡·SMS 섹션(통합 저장,
|
||||
4×4 유형) + 플러그인 관리 화면(DB 목록). PO 결정 2026-08-23: 별도 작성/SMS 모달 폐지
|
||||
|
||||
axis 설계 노트:
|
||||
- content_variant 는 카카오 메시지유형×강조유형의 대표 변형 4종이다 — 유형별 조건부
|
||||
@@ -45,6 +46,7 @@ effects:
|
||||
- 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
|
||||
@@ -73,14 +75,22 @@ effects:
|
||||
- approval_transition_freezes_content_into_approved_content
|
||||
- rejection_transition_stores_comments_detail
|
||||
- manual_sync_updates_single_row
|
||||
# 화면 (S5)
|
||||
# 화면 (S5 — PO 결정 2026-08-23: 편집 모달 통합)
|
||||
- bizppurio_tab_replaces_sms_and_alimtalk_tabs
|
||||
- tab_visible_when_any_of_tab_channels_active
|
||||
- row_footer_shows_status_badge_and_lifecycle_actions
|
||||
- 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
|
||||
# 관리 목록 반응형 (PO 지시 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
|
||||
@@ -92,12 +102,20 @@ effects:
|
||||
|
||||
scenarios:
|
||||
- id: bizppurio_tab_integration_e2e
|
||||
description: 코어 알림 설정에서 문자·알림톡 개별 탭이 사라지고 '비즈뿌리오' 통합 탭이 노출되며, 행 하단에 알림톡·SMS 라이프사이클 UI 가 렌더된다 (i18n 원문 노출 0)
|
||||
description: 코어 알림 설정에서 문자·알림톡 개별 탭이 사라지고 '비즈뿌리오' 통합 탭이 노출되며, 행 하단에 승인 2단 배지 + SMS 설정 요약만 렌더된다 (버튼 0, i18n 원문 노출 0)
|
||||
effects:
|
||||
- bizppurio_tab_replaces_sms_and_alimtalk_tabs
|
||||
- row_footer_shows_status_badge_and_lifecycle_actions
|
||||
- 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)을 전환하면 조건부 필드 블록이 등장/소멸한다
|
||||
description: 편집 모달의 알림톡 섹션에서 강조 유형(NONE→TEXT→IMAGE→ITEM_LIST)을 전환하면 조건부 필드 블록이 등장/소멸한다
|
||||
effects:
|
||||
- compose_modal_switches_conditional_fields_by_type
|
||||
- id: bizppurio_manage_round_trip_e2e
|
||||
@@ -105,6 +123,7 @@ scenarios:
|
||||
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
|
||||
@@ -112,6 +131,8 @@ test_files:
|
||||
- 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
|
||||
@@ -119,6 +140,10 @@ test_files:
|
||||
# 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
|
||||
|
||||
+2
-1
@@ -86,7 +86,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: greedy
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량 1`] = `513`;
|
||||
exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량 1`] = `516`;
|
||||
|
||||
exports[`BindingShape 라우팅 diff 하네스 > 판정이 갈리는 식의 분류별 건수 요약 1`] = `
|
||||
{
|
||||
@@ -282,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",
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
### Changed
|
||||
|
||||
- 관리자 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 확장이 여러 발송 채널을 하나의 탭으로 묶어 표시할 수 있으며(예: 비즈뿌리오 문자·알림톡 → '비즈뿌리오' 단일 탭), 묶인 채널 중 하나라도 켜져 있으면 탭이 노출됩니다.
|
||||
- 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 알림 설정 화면에서 「사이트맵 재생성 완료」·「사이트맵 재생성 실패」 알림의 이름이 다국어 키 원문으로 표시되던 문제를 수정했습니다.
|
||||
|
||||
## [1.0.6] - 2026-08-22
|
||||
|
||||
|
||||
+138
@@ -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<string, any>;
|
||||
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<string, unknown>): 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<string, unknown> = {}) => ({
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -1862,7 +1862,9 @@
|
||||
"types": {
|
||||
"welcome": "회원가입 환영",
|
||||
"reset_password": "비밀번호 재설정",
|
||||
"password_changed": "비밀번호 변경 알림"
|
||||
"password_changed": "비밀번호 변경 알림",
|
||||
"sitemap_regenerated": "사이트맵 재생성 완료",
|
||||
"sitemap_regenerate_failed": "사이트맵 재생성 실패"
|
||||
},
|
||||
"channels_label": "채널:",
|
||||
"edit_modal": {
|
||||
|
||||
+42
-7
@@ -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"
|
||||
},
|
||||
@@ -865,7 +883,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"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)}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 로 전환되어야 합니다.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => '<p>{code}</p>']],
|
||||
],
|
||||
]));
|
||||
|
||||
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('<p>{code}</p>', $updated->body['ja'] ?? null,
|
||||
'복원 기본값에 활성 팩 seed 의 ja body 가 병합되어야 한다');
|
||||
$this->assertArrayHasKey('ko', $updated->subject, 'ko 는 config 원본에서 보존');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => '<p>{user_name}</p>']],
|
||||
],
|
||||
]));
|
||||
|
||||
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' => '<p>{user_name}</p>', 'en' => '<p>{user_name}</p>'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$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' => '<p>{user_name}</p>']],
|
||||
],
|
||||
]));
|
||||
|
||||
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('<p>{user_name}</p>', $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 = [
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Upgrades;
|
||||
|
||||
use App\Enums\LanguagePackScope;
|
||||
use App\Enums\LanguagePackStatus;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Models\IdentityMessageDefinition;
|
||||
use App\Models\IdentityMessageTemplate;
|
||||
use App\Models\LanguagePack;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Upgrades\Data\V7_0_9\Migrations\ResyncActiveLanguagePackTranslations;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 7.0.9 업그레이드 스텝 — 활성 언어팩 seed 번역 재동기화 백필 검증.
|
||||
*
|
||||
* 7.0.9 이전의 [기본값 복원] 결함·코어 시더 주입 불능으로 소실된 팩 로케일이
|
||||
* 업그레이드 시 병합 복구되는지, 그리고 운영자 수정(user_overrides)은 덮지
|
||||
* 않는지를 실제 활성화 훅 경로(core.language_packs.activated)로 검증한다.
|
||||
*/
|
||||
class Upgrade709LangPackResyncTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $packIdentifier = 'test-upgrade-resync-ja';
|
||||
|
||||
private string $packRoot = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// upgrade data 파일은 composer autoload 대상이 아니며 AbstractUpgradeStep 이
|
||||
// 실행 시점에 require_once 로 수동 로드한다. 테스트에서도 동일하게 수동 로드.
|
||||
require_once base_path('upgrades/data/7.0.9/migrations/01_ResyncActiveLanguagePackTranslations.php');
|
||||
|
||||
$this->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' => '<p>{user_name}</p>']],
|
||||
],
|
||||
'password_changed' => [
|
||||
'templates' => ['mail' => ['subject' => 'パスワード変更', 'body' => '<p>{user_name}</p>']],
|
||||
],
|
||||
]));
|
||||
$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' => '<p>환영</p>', 'en' => '<p>welcome</p>'],
|
||||
'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' => '<p>커스텀</p>', 'ja' => '<p>カスタム</p>'],
|
||||
'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' => '<p>{code}</p>']],
|
||||
],
|
||||
]));
|
||||
$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' => '<p>{code}</p>'],
|
||||
'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 가 병합 복구되어야 한다');
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Notifications;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 코어 소유 알림 정의 타입 ↔ 관리자 화면 라벨 패리티 (#597 후속, 2026-08-24)
|
||||
*
|
||||
* 관리자 알림 설정 화면의 정의 목록은 제목을 DB `name` 이 아니라 프론트 다국어 키
|
||||
* `admin.settings.notification_definitions.types.{type}` 로 렌더한다. 이 네임스페이스의
|
||||
* 코어 소유분은 admin 템플릿(sirsoft-admin_basic)의 `lang/partial/{ko,en}/admin.json` 이
|
||||
* 공급한다(모듈 소유 타입은 각 모듈의 화면·lang 이 담당).
|
||||
*
|
||||
* 키가 없으면 예외도 경고도 없이 원시 키 문자열이 화면 제목으로 그대로 노출된다
|
||||
* (실사례: sitemap_regenerated / sitemap_regenerate_failed — #481 이 config 정의만 추가하고
|
||||
* 템플릿 라벨을 누락). config/core.php 에 정의를 추가하는 커밋이 라벨을 빠뜨리는 일이
|
||||
* 재발하지 않도록, 코어 정의 전수를 양 로케일 라벨과 대조한다.
|
||||
*
|
||||
* 마커는 로케일별 테스트 메서드 docblock 에 있다 (@scenario 는 docblock 당 1조합).
|
||||
*/
|
||||
class NotificationDefinitionTypeLabelParityTest extends TestCase
|
||||
{
|
||||
/** 코어 소유 타입 라벨을 공급하는 admin 템플릿 lang partial */
|
||||
private const LABEL_FILES = [
|
||||
'ko' => '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<string, mixed> 타입 → 라벨
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)에 [기본값 복원] 실행 후,
|
||||
일본어 화면에서 제목·본문 번역이 유지되는지 확인한다.
|
||||
@@ -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 PO 제보). 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 팩 활성 상태에서 동일 확인.
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* 코어 7.0.9 업그레이드 스텝
|
||||
*
|
||||
* 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.0.9/` 안에 격리된다:
|
||||
*
|
||||
* - migrations/
|
||||
* 01_ResyncActiveLanguagePackTranslations.php
|
||||
* 활성 언어팩의 seed 번역을 DB JSON 컬럼에 재동기화한다. 7.0.9 이전의
|
||||
* [기본값 복원] 결함(복원 기본값에 언어팩 로케일 미포함)과 코어 시더 필터
|
||||
* 주입 불능으로 이미 소실된 팩 번역(ja 등)을 병합 복구한다. 병합은 운영자
|
||||
* 수정(user_overrides)을 보존하며 멱등이다.
|
||||
*
|
||||
* 실행 순서는 파일명 정렬(`sort()`)을 따른다.
|
||||
*
|
||||
* 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음.
|
||||
*
|
||||
* @upgrade-path 7.0.x → 7.0.9
|
||||
*
|
||||
* 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.0.9/migrations/` 의 버전 namespace
|
||||
* 클래스에 위임한다. 미래 버전에서 *그 디렉토리는 동결* (수정 금지) 되어 "각 스텝별 동작
|
||||
* 100% 동일 보장" invariant 가 성립.
|
||||
*
|
||||
* 상세: docs/extension/upgrade-step-guide.md §13 "버전별 데이터 스냅샷"
|
||||
*/
|
||||
class Upgrade_7_0_9 extends AbstractUpgradeStep
|
||||
{
|
||||
// 모든 로직 위임 — data/7.0.9/ 가 SSoT.
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\V7_0_9\Migrations;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Models\LanguagePack;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 활성 언어팩의 seed 번역을 DB JSON 컬럼에 재동기화합니다.
|
||||
*
|
||||
* 7.0.9 이전 버전에는 언어팩 번역이 DB 에서 소실되는 두 결함이 있었다:
|
||||
*
|
||||
* 1) 알림/본인인증 템플릿의 [기본값 복원]이 복원 기본값에 활성 언어팩 로케일을
|
||||
* 포함하지 않아, 복원 시 팩이 주입해 둔 로케일(ja 등)과 보존 선언(user_overrides)이
|
||||
* config 의 ko/en 대체로 영구 소실됐다.
|
||||
* 2) 코어 시더의 언어팩 필터 주입이 페이로드 형태 불일치로 전면 불능이라, 팩 활성화
|
||||
* 이후 코어 업그레이드가 추가한 알림 정의 행에는 팩 로케일이 주입되지 않았다.
|
||||
*
|
||||
* 소스 교정(7.0.9)만으로는 이미 소실된 설치본 데이터가 낫지 않으므로, 활성 언어팩마다
|
||||
* 활성화 훅(core.language_packs.activated)을 재발화해 seed 번역을 병합 복구한다 —
|
||||
* 정상 활성화와 완전히 같은 경로이므로 활성화 시점과 동일한 결과가 재현된다.
|
||||
*
|
||||
* idempotent + 운영자 수정 보존: 병합 리스너(SyncDatabaseTranslations → applySeedFromPack)는
|
||||
* - locale 키 부재 → 추가 (소실분 복구)
|
||||
* - locale 키 존재 + user_overrides 에 컬럼 등록 → 건너뜀 (운영자 수정 보존)
|
||||
* - locale 키 존재 + user_overrides 미등록 → seed 값으로 갱신
|
||||
* 이라 여러 번 실행해도 결과가 같고 운영자가 직접 수정한 번역을 덮지 않는다.
|
||||
*
|
||||
* V-1 안전: 상태값은 enum 참조 대신 'active' 리터럴을 쓴다 (본 디렉토리는 릴리즈 후
|
||||
* 동결되므로 스냅샷 시점의 DB 저장값 자체를 고정). LanguagePack 모델과 HookManager 는
|
||||
* 언어팩 도입 시점부터 존재하는 기존 클래스의 기존 표면만 사용한다.
|
||||
*/
|
||||
class ResyncActiveLanguagePackTranslations implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 마이그레이션 식별자를 반환합니다.
|
||||
*
|
||||
* @return string 마이그레이션 이름
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'ResyncActiveLanguagePackTranslations';
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 언어팩 전수에 활성화 훅을 재발화해 seed 번역을 병합 복구합니다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
* @return void
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
if (! Schema::hasTable('language_packs')) {
|
||||
$context->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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user