feat: get-voice-mailに取得範囲フラグ追加、n8nワークフローを1件ずつループ処理化

大量ファイル処理時のn8nメモリ枯渇対策として、SplitInBatches(1件ずつ)で
SSHダウンロード→base64化→プリザンター登録を直列化(n8n側、API経由で反映済み)。
Webhookに?range=today|week|allを追加、app-portal側にも選択UIを実装。
This commit is contained in:
Kenichiro NOGI 2026-09-19 11:43:21 +09:00
parent 6c9893c24b
commit 4b14d992af
7 changed files with 54 additions and 6 deletions

View File

@ -34,7 +34,15 @@ document.getElementById('cards').addEventListener('click', async (event) => {
return; return;
} }
const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' }); let url = `/api/compose/${composeId}/${action}`;
if (action === 'trigger') {
const rangeSelect = document.querySelector(`select[data-range-select][data-compose-id="${composeId}"]`);
if (rangeSelect) {
url += `?range=${encodeURIComponent(rangeSelect.value)}`;
}
}
const res = await fetch(url, { method: 'POST' });
if (res.ok) { if (res.ok) {
showToast(`${action} を実行しました`, false); showToast(`${action} を実行しました`, false);
} else { } else {

View File

@ -60,12 +60,19 @@ function renderCard(card) {
meta.appType === 'web' && meta.url meta.appType === 'web' && meta.url
? `<a class="btn btn-outline-primary btn-sm" href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>` ? `<a class="btn btn-outline-primary btn-sm" href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>`
: ''; : '';
const rangeSelect =
meta.appType === 'batch' && meta.rangeOptions && meta.rangeOptions.length > 0
? `<select class="form-select form-select-sm w-auto" data-range-select data-compose-id="${escapeHtml(composeId)}">
${meta.rangeOptions.map((opt) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`).join('')}
</select>`
: '';
const actionButtons = const actionButtons =
meta.appType === 'web' meta.appType === 'web'
? `<button class="btn btn-success btn-sm" data-action="start" data-compose-id="${escapeHtml(composeId)}">Start</button> ? `<button class="btn btn-success btn-sm" data-action="start" data-compose-id="${escapeHtml(composeId)}">Start</button>
<button class="btn btn-danger btn-sm" data-action="stop" data-compose-id="${escapeHtml(composeId)}">Stop</button> <button class="btn btn-danger btn-sm" data-action="stop" data-compose-id="${escapeHtml(composeId)}">Stop</button>
<button class="btn btn-warning btn-sm" data-action="restart" data-compose-id="${escapeHtml(composeId)}">Restart</button>` <button class="btn btn-warning btn-sm" data-action="restart" data-compose-id="${escapeHtml(composeId)}">Restart</button>`
: `<button class="btn btn-primary btn-sm" data-action="trigger" data-compose-id="${escapeHtml(composeId)}">今すぐ実行</button>`; : `${rangeSelect}
<button class="btn btn-primary btn-sm" data-action="trigger" data-compose-id="${escapeHtml(composeId)}">今すぐ実行</button>`;
return `<div class="col-12 col-md-6 col-lg-4"> return `<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100" data-compose-id="${escapeHtml(composeId)}"> <div class="card h-100" data-compose-id="${escapeHtml(composeId)}">
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">

View File

@ -152,7 +152,9 @@ app.post('/api/compose/:composeId/trigger', async (req, res) => {
res.sendStatus(400); res.sendStatus(400);
return; return;
} }
const triggerRes = await fetch(`${meta.url}${meta.triggerPath}`, { const range = req.query.range;
const query = meta.rangeOptions && meta.rangeOptions.includes(range) ? `?range=${encodeURIComponent(range)}` : '';
const triggerRes = await fetch(`${meta.url}${meta.triggerPath}${query}`, {
method: 'POST', method: 'POST',
headers: { 'X-Portal-Secret': PORTAL_SECRET }, headers: { 'X-Portal-Secret': PORTAL_SECRET },
}); });

View File

@ -22,11 +22,17 @@ function extractPortalMeta(envString, fallbackLabel) {
if (appType !== 'web' && appType !== 'batch') { if (appType !== 'web' && appType !== 'batch') {
return null; return null;
} }
const rangeOptions = vars.PORTAL_TRIGGER_RANGE_OPTIONS
? vars.PORTAL_TRIGGER_RANGE_OPTIONS.split(',')
.map((s) => s.trim())
.filter(Boolean)
: null;
return { return {
appType, appType,
label: vars.PORTAL_APP_LABEL || fallbackLabel, label: vars.PORTAL_APP_LABEL || fallbackLabel,
url: vars.PORTAL_APP_URL || null, url: vars.PORTAL_APP_URL || null,
triggerPath: vars.PORTAL_TRIGGER_PATH || null, triggerPath: vars.PORTAL_TRIGGER_PATH || null,
rangeOptions,
}; };
} }

View File

@ -20,7 +20,13 @@ test('extractPortalMeta returns null when PORTAL_APP_TYPE is missing or invalid'
test('extractPortalMeta parses a web-type app, falling back to label when PORTAL_APP_LABEL is absent', () => { test('extractPortalMeta parses a web-type app, falling back to label when PORTAL_APP_LABEL is absent', () => {
const meta = extractPortalMeta('PORTAL_APP_TYPE=web\nPORTAL_APP_URL=https://x.apps.next-hd.net', 'x'); const meta = extractPortalMeta('PORTAL_APP_TYPE=web\nPORTAL_APP_URL=https://x.apps.next-hd.net', 'x');
assert.deepStrictEqual(meta, { appType: 'web', label: 'x', url: 'https://x.apps.next-hd.net', triggerPath: null }); assert.deepStrictEqual(meta, {
appType: 'web',
label: 'x',
url: 'https://x.apps.next-hd.net',
triggerPath: null,
rangeOptions: null,
});
}); });
test('extractPortalMeta parses a batch-type app with PORTAL_TRIGGER_PATH', () => { test('extractPortalMeta parses a batch-type app with PORTAL_TRIGGER_PATH', () => {
@ -28,5 +34,19 @@ test('extractPortalMeta parses a batch-type app with PORTAL_TRIGGER_PATH', () =>
'PORTAL_APP_TYPE=batch\nPORTAL_APP_LABEL=同期バッチ\nPORTAL_APP_URL=http://sample:3000\nPORTAL_TRIGGER_PATH=/trigger', 'PORTAL_APP_TYPE=batch\nPORTAL_APP_LABEL=同期バッチ\nPORTAL_APP_URL=http://sample:3000\nPORTAL_TRIGGER_PATH=/trigger',
'fallback' 'fallback'
); );
assert.deepStrictEqual(meta, { appType: 'batch', label: '同期バッチ', url: 'http://sample:3000', triggerPath: '/trigger' }); assert.deepStrictEqual(meta, {
appType: 'batch',
label: '同期バッチ',
url: 'http://sample:3000',
triggerPath: '/trigger',
rangeOptions: null,
});
});
test('extractPortalMeta parses PORTAL_TRIGGER_RANGE_OPTIONS into an array', () => {
const meta = extractPortalMeta(
'PORTAL_APP_TYPE=batch\nPORTAL_APP_URL=http://sample:3000\nPORTAL_TRIGGER_PATH=/trigger\nPORTAL_TRIGGER_RANGE_OPTIONS=today, week ,all',
'fallback'
);
assert.deepStrictEqual(meta.rangeOptions, ['today', 'week', 'all']);
}); });

View File

@ -7,6 +7,8 @@ PORTAL_APP_LABEL=ボイスメール取得
PORTAL_APP_URL=http://get-voice-mail:3000 PORTAL_APP_URL=http://get-voice-mail:3000
PORTAL_TRIGGER_PATH=/trigger PORTAL_TRIGGER_PATH=/trigger
PORTAL_SECRET= PORTAL_SECRET=
# app-portalダッシュボードに取得範囲の選択肢を表示する(未指定ならボタンのみ、選択なしはtriggerで自動的にtoday扱い)
PORTAL_TRIGGER_RANGE_OPTIONS=today,week,all
# n8nワークフロー入口(Webhookード)のURL。/trigger起動時にPOSTする先 # n8nワークフロー入口(Webhookード)のURL。/trigger起動時にPOSTする先
N8N_WEBHOOK_URL=https://n8n32.next-hd.net/webhook/get-voice-mail N8N_WEBHOOK_URL=https://n8n32.next-hd.net/webhook/get-voice-mail

View File

@ -15,6 +15,8 @@ app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy' }); res.status(200).json({ status: 'healthy' });
}); });
const VALID_RANGES = ['today', 'week', 'all'];
app.post('/trigger', (req, res) => { app.post('/trigger', (req, res) => {
if (!isTriggerAuthorized(req.headers['x-portal-secret'], PORTAL_SECRET)) { if (!isTriggerAuthorized(req.headers['x-portal-secret'], PORTAL_SECRET)) {
res.sendStatus(403); res.sendStatus(403);
@ -25,7 +27,8 @@ app.post('/trigger', (req, res) => {
res.sendStatus(500); res.sendStatus(500);
return; return;
} }
fetch(N8N_WEBHOOK_URL, { const range = VALID_RANGES.includes(req.query.range) ? req.query.range : 'today';
fetch(`${N8N_WEBHOOK_URL}?range=${range}`, {
method: 'POST', method: 'POST',
headers: N8N_WEBHOOK_SECRET ? { 'X-Api-Key': N8N_WEBHOOK_SECRET } : {}, headers: N8N_WEBHOOK_SECRET ? { 'X-Api-Key': N8N_WEBHOOK_SECRET } : {},
}).catch((err) => { }).catch((err) => {