feat(message_bizppurio): 알림톡 템플릿 라이프사이클 개편 — DB 기반 작성·검수·승인
비즈뿌리오 알림톡 템플릿을 콘솔 위임(발송 전 실시간 조회) 방식에서 시스템 내 작성(draft) → 검수 신청(requested) → 승인(approved) 라이프사이클로 개편한다. 발송 판정의 SSoT 를 DB(bizppurio_templates)로 옮기고 발송 전 실시간 조회를 폐지. - 알림↔템플릿 바인딩 모델(notification_bindings) 제거, 알림 1건당 템플릿 1행으로 대체 - 검수 신청: template_code 자체 채번(codeCheck 재시도) → kapi add/update → request - 상태 동기화: 30분 스케줄러 + 수동 새로고침, 승인 전이 시 content 를 approved_content 로 동결 - SMS 본문 언어별 입력 복원(수신자 로케일 발송), SMS 단독 선택 시 알림톡 미발송 - 3면(코어/게시판/이커머스) '비즈뿌리오' 통합 탭 + 플러그인 '알림 템플릿 관리' 화면 - 중복 검수 신청은 서버 원자 선점(claimForInspection 조건부 UPDATE)으로 차단 - admin_basic 1.0.6 / board 1.0.5 / ecommerce 1.1.2: 채널 서브탭 통합 탭 선언(tab_channels) 지원 - 검증: 계획서 대비 5라운드 전수검증, §6.3 실측 매트릭스 Playwright 19 고정, PHPUnit 421 / Vitest 228 green, audit 0 error
This commit is contained in:
1 parent
6a8a537f10
commit
e17731d8cc
111 files changed
+24733
-6890
No files matched your search
@@ -10,6 +10,11 @@
|
||||
|
||||
- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
|
||||
- 게시판 알림 설정 화면에서 비즈뿌리오 알림톡 템플릿을 작성해 검수를 신청하고, 카카오 승인 후 알림톡이 자동 발송됩니다. 대체 SMS·SMS 단독 발송도 같은 화면에서 설정합니다. (비즈뿌리오 메시지 발송 플러그인 설치 시)
|
||||
|
||||
### Changed
|
||||
|
||||
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
|
||||
|
||||
## [1.0.5] - 2026-08-22
|
||||
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
// e2e:allow 레이아웃 조건식 실평가 전용 — 브라우저 흐름은 비즈뿌리오 플러그인 spec 이 담당한다.
|
||||
/**
|
||||
* 게시판 알림 설정 — 채널 서브탭 필터 조합 실평가 (#597 §14.2 T8)
|
||||
*
|
||||
* 이 면의 서브탭 필터는 admin_basic·이커머스와 같은 식을 공유한다. 그 동일성은
|
||||
* admin_basic 쪽 패리티 테스트가 고정하지만, 그 테스트는 **admin_basic 템플릿의
|
||||
* 러너에서만** 수집된다 — 게시판 모듈만 단독으로 Vitest 를 돌리면 이 면의 필터는
|
||||
* 한 번도 평가되지 않는다. 그래서 각 면이 자기 파일을 자기 러너에서 평가한다.
|
||||
*
|
||||
* 검증 대상: 확장이 선언한 통합 탭 메타(hidden_tab / tab_channels)를 필터가 실제로
|
||||
* 해석하는가. 문자열 동일성 단언은 `!==` 를 `===` 로 바꾸는 오타를 잡지 못한다.
|
||||
*
|
||||
* @scenario resource=notification_definitions_tab,endpoint=admin_board_settings,observation=tab_filter
|
||||
*
|
||||
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const TAB_LAYOUT = path.resolve(
|
||||
__dirname,
|
||||
'../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json',
|
||||
);
|
||||
|
||||
/** availableChannels 응답 형태 (코어 2 + 비즈뿌리오 2) */
|
||||
const CHANNELS = [
|
||||
{ id: 'mail', source: 'core', name: '메일' },
|
||||
{ id: 'database', source: 'core', name: '사이트내 알림' },
|
||||
{ id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true },
|
||||
{
|
||||
id: 'alimtalk',
|
||||
source: 'sirsoft-message_bizppurio',
|
||||
name: '비즈뿌리오 알림톡',
|
||||
tab_channels: ['sms', 'alimtalk'],
|
||||
tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다.
|
||||
*
|
||||
* @param node 탐색할 JSON 노드
|
||||
* @returns 서브탭 컨테이너 노드 (없으면 null)
|
||||
*/
|
||||
function findSubTabsContainer(node: unknown): Record<string, any> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findSubTabsContainer(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
|
||||
const record = node as Record<string, any>;
|
||||
if (typeof record.id === 'string' && record.id.endsWith('channel_sub_tabs')) return record;
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const found = findSubTabsContainer(value);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서브탭 컨테이너 안에서 iteration 을 가진 탭 버튼 노드를 찾습니다.
|
||||
*
|
||||
* @param node 탐색할 JSON 노드
|
||||
* @returns iteration 보유 노드 (없으면 null)
|
||||
*/
|
||||
function findIterationNode(node: unknown): Record<string, any> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findIterationNode(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
|
||||
const record = node as Record<string, any>;
|
||||
if (record.iteration && typeof record.iteration.source === 'string') return record;
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const found = findIterationNode(value);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('게시판 알림 설정 — 채널 서브탭 필터 실평가 (#597)', () => {
|
||||
const layout = JSON.parse(fs.readFileSync(TAB_LAYOUT, 'utf-8'));
|
||||
const container = findSubTabsContainer(layout);
|
||||
const tabNode = container ? findIterationNode(container) : null;
|
||||
const expr = String(tabNode?.iteration?.source ?? '').replace(/^\{\{|\}\}$/g, '');
|
||||
|
||||
/**
|
||||
* 필터 표현식 원문을 실행해 노출 탭 id 목록을 반환합니다.
|
||||
*
|
||||
* @param saved _local.form.notifications.channels 저장값
|
||||
* @param channels availableChannels 채널 목록
|
||||
* @returns 필터를 통과한 채널 id 배열
|
||||
*/
|
||||
function visibleTabs(
|
||||
saved: Array<{ id: string; is_active: boolean }>,
|
||||
channels: Array<Record<string, any>> = CHANNELS,
|
||||
): string[] {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('availableChannels', '_local', `return ${expr};`);
|
||||
const result = fn(
|
||||
{ data: { channels } },
|
||||
{ form: { notifications: { channels: saved } } },
|
||||
) as Array<{ id: string }>;
|
||||
return result.map((c) => c.id);
|
||||
}
|
||||
|
||||
it('서브탭 필터 표현식을 레이아웃에서 추출할 수 있다', () => {
|
||||
expect(container, '*channel_sub_tabs 컨테이너를 찾지 못했다').toBeTruthy();
|
||||
expect(expr, '탭 iteration source 가 비어 있다').not.toBe('');
|
||||
});
|
||||
|
||||
it('sms·alimtalk 모두 활성 → 통합 탭 1개만 노출, sms 개별 탭은 숨김', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: true },
|
||||
{ id: 'alimtalk', is_active: true },
|
||||
])).toEqual(['mail', 'database', 'alimtalk']);
|
||||
});
|
||||
|
||||
it('sms 만 활성(alimtalk 비활성) → tab_channels 규칙으로 통합 탭 노출', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: true },
|
||||
{ id: 'alimtalk', is_active: false },
|
||||
])).toEqual(['mail', 'database', 'alimtalk']);
|
||||
});
|
||||
|
||||
it('sms·alimtalk 모두 비활성 → 통합 탭 미노출', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: false },
|
||||
{ id: 'alimtalk', is_active: false },
|
||||
])).toEqual(['mail', 'database']);
|
||||
});
|
||||
|
||||
it('확장 채널 저장값 자체가 없으면(미저장=opt-in 전) 통합 탭 미노출', () => {
|
||||
expect(visibleTabs([])).toEqual(['mail', 'database']);
|
||||
});
|
||||
|
||||
it('코어 채널은 저장값이 명시적 false 일 때만 숨는다 (기본 노출)', () => {
|
||||
expect(visibleTabs([{ id: 'mail', is_active: false }])).toEqual(['database']);
|
||||
});
|
||||
|
||||
it('tab_channels 미선언 확장 채널은 자기 자신의 활성 저장 기준으로 판정된다', () => {
|
||||
const withPlain = [...CHANNELS, { id: 'push', source: 'some-plugin', name: '푸시' }];
|
||||
expect(visibleTabs([{ id: 'push', is_active: true }], withPlain)).toEqual(['mail', 'database', 'push']);
|
||||
expect(visibleTabs([{ id: 'push', is_active: false }], withPlain)).toEqual(['mail', 'database']);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -140,7 +140,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"iteration": {
|
||||
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}",
|
||||
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.hidden_tab !== true && (c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0))}}",
|
||||
"item_var": "ch",
|
||||
"index_var": "chIdx"
|
||||
},
|
||||
@@ -160,7 +160,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{ch.name ?? ch.id}}"
|
||||
"text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
### Added
|
||||
|
||||
- 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
- 이커머스 알림 설정 화면에서 비즈뿌리오 알림톡 템플릿을 작성해 검수를 신청하고, 카카오 승인 후 알림톡이 자동 발송됩니다. 대체 SMS·SMS 단독 발송도 같은 화면에서 설정합니다. (비즈뿌리오 메시지 발송 플러그인 설치 시)
|
||||
|
||||
### Changed
|
||||
|
||||
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
// e2e:allow 레이아웃 조건식 실평가 전용 — 브라우저 흐름은 비즈뿌리오 플러그인 spec 이 담당한다.
|
||||
/**
|
||||
* 이커머스 알림 설정 — 채널 서브탭 필터 조합 실평가 (#597 §14.2 T8)
|
||||
*
|
||||
* 이 면의 서브탭 필터는 admin_basic·게시판와 같은 식을 공유한다. 그 동일성은
|
||||
* admin_basic 쪽 패리티 테스트가 고정하지만, 그 테스트는 **admin_basic 템플릿의
|
||||
* 러너에서만** 수집된다 — 이커머스 모듈만 단독으로 Vitest 를 돌리면 이 면의 필터는
|
||||
* 한 번도 평가되지 않는다. 그래서 각 면이 자기 파일을 자기 러너에서 평가한다.
|
||||
*
|
||||
* 검증 대상: 확장이 선언한 통합 탭 메타(hidden_tab / tab_channels)를 필터가 실제로
|
||||
* 해석하는가. 문자열 동일성 단언은 `!==` 를 `===` 로 바꾸는 오타를 잡지 못한다.
|
||||
*
|
||||
* @scenario resource=notification_definitions_tab,endpoint=admin_ecommerce_settings,observation=tab_filter
|
||||
*
|
||||
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const TAB_LAYOUT = path.resolve(
|
||||
__dirname,
|
||||
'../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json',
|
||||
);
|
||||
|
||||
/** availableChannels 응답 형태 (코어 2 + 비즈뿌리오 2) */
|
||||
const CHANNELS = [
|
||||
{ id: 'mail', source: 'core', name: '메일' },
|
||||
{ id: 'database', source: 'core', name: '사이트내 알림' },
|
||||
{ id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true },
|
||||
{
|
||||
id: 'alimtalk',
|
||||
source: 'sirsoft-message_bizppurio',
|
||||
name: '비즈뿌리오 알림톡',
|
||||
tab_channels: ['sms', 'alimtalk'],
|
||||
tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다.
|
||||
*
|
||||
* @param node 탐색할 JSON 노드
|
||||
* @returns 서브탭 컨테이너 노드 (없으면 null)
|
||||
*/
|
||||
function findSubTabsContainer(node: unknown): Record<string, any> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findSubTabsContainer(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
|
||||
const record = node as Record<string, any>;
|
||||
if (typeof record.id === 'string' && record.id.endsWith('channel_sub_tabs')) return record;
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const found = findSubTabsContainer(value);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서브탭 컨테이너 안에서 iteration 을 가진 탭 버튼 노드를 찾습니다.
|
||||
*
|
||||
* @param node 탐색할 JSON 노드
|
||||
* @returns iteration 보유 노드 (없으면 null)
|
||||
*/
|
||||
function findIterationNode(node: unknown): Record<string, any> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findIterationNode(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
|
||||
const record = node as Record<string, any>;
|
||||
if (record.iteration && typeof record.iteration.source === 'string') return record;
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const found = findIterationNode(value);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('이커머스 알림 설정 — 채널 서브탭 필터 실평가 (#597)', () => {
|
||||
const layout = JSON.parse(fs.readFileSync(TAB_LAYOUT, 'utf-8'));
|
||||
const container = findSubTabsContainer(layout);
|
||||
const tabNode = container ? findIterationNode(container) : null;
|
||||
const expr = String(tabNode?.iteration?.source ?? '').replace(/^\{\{|\}\}$/g, '');
|
||||
|
||||
/**
|
||||
* 필터 표현식 원문을 실행해 노출 탭 id 목록을 반환합니다.
|
||||
*
|
||||
* @param saved _local.form.notifications.channels 저장값
|
||||
* @param channels availableChannels 채널 목록
|
||||
* @returns 필터를 통과한 채널 id 배열
|
||||
*/
|
||||
function visibleTabs(
|
||||
saved: Array<{ id: string; is_active: boolean }>,
|
||||
channels: Array<Record<string, any>> = CHANNELS,
|
||||
): string[] {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('availableChannels', '_local', `return ${expr};`);
|
||||
const result = fn(
|
||||
{ data: { channels } },
|
||||
{ form: { notifications: { channels: saved } } },
|
||||
) as Array<{ id: string }>;
|
||||
return result.map((c) => c.id);
|
||||
}
|
||||
|
||||
it('서브탭 필터 표현식을 레이아웃에서 추출할 수 있다', () => {
|
||||
expect(container, '*channel_sub_tabs 컨테이너를 찾지 못했다').toBeTruthy();
|
||||
expect(expr, '탭 iteration source 가 비어 있다').not.toBe('');
|
||||
});
|
||||
|
||||
it('sms·alimtalk 모두 활성 → 통합 탭 1개만 노출, sms 개별 탭은 숨김', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: true },
|
||||
{ id: 'alimtalk', is_active: true },
|
||||
])).toEqual(['mail', 'database', 'alimtalk']);
|
||||
});
|
||||
|
||||
it('sms 만 활성(alimtalk 비활성) → tab_channels 규칙으로 통합 탭 노출', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: true },
|
||||
{ id: 'alimtalk', is_active: false },
|
||||
])).toEqual(['mail', 'database', 'alimtalk']);
|
||||
});
|
||||
|
||||
it('sms·alimtalk 모두 비활성 → 통합 탭 미노출', () => {
|
||||
expect(visibleTabs([
|
||||
{ id: 'sms', is_active: false },
|
||||
{ id: 'alimtalk', is_active: false },
|
||||
])).toEqual(['mail', 'database']);
|
||||
});
|
||||
|
||||
it('확장 채널 저장값 자체가 없으면(미저장=opt-in 전) 통합 탭 미노출', () => {
|
||||
expect(visibleTabs([])).toEqual(['mail', 'database']);
|
||||
});
|
||||
|
||||
it('코어 채널은 저장값이 명시적 false 일 때만 숨는다 (기본 노출)', () => {
|
||||
expect(visibleTabs([{ id: 'mail', is_active: false }])).toEqual(['database']);
|
||||
});
|
||||
|
||||
it('tab_channels 미선언 확장 채널은 자기 자신의 활성 저장 기준으로 판정된다', () => {
|
||||
const withPlain = [...CHANNELS, { id: 'push', source: 'some-plugin', name: '푸시' }];
|
||||
expect(visibleTabs([{ id: 'push', is_active: true }], withPlain)).toEqual(['mail', 'database', 'push']);
|
||||
expect(visibleTabs([{ id: 'push', is_active: false }], withPlain)).toEqual(['mail', 'database']);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -139,7 +139,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"iteration": {
|
||||
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}",
|
||||
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.hidden_tab !== true && (c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0))}}",
|
||||
"item_var": "ch",
|
||||
"index_var": "chIdx"
|
||||
},
|
||||
@@ -159,7 +159,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{ch.name ?? ch.id}}"
|
||||
"text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
|
||||
Reference in new issue
Block a user