Webhookのメッセージをカスタマイズできるように
This commit is contained in:
@@ -5,6 +5,10 @@ import { query } from '../../db/pool.js';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { badRequest, notFound } from '../../utils/httpErrors.js';
|
||||
import { normalizeHttpsUrl } from '../../utils/urlPolicy.js';
|
||||
import {
|
||||
serializeWebhookMessageSettings,
|
||||
validateWebhookMessageTemplate,
|
||||
} from './webhookMessageSettings.js';
|
||||
|
||||
const router = new Hono();
|
||||
|
||||
@@ -25,6 +29,10 @@ const pushSubscriptionStatusSchema = z.object({
|
||||
endpoint: z.string().trim().url().max(2048),
|
||||
});
|
||||
|
||||
const webhookMessageSettingsSchema = z.object({
|
||||
webhookMessageTemplate: z.string().max(2000),
|
||||
});
|
||||
|
||||
function serializeWebhook(row) {
|
||||
return {
|
||||
notificationMethodId: row.notification_method_id,
|
||||
@@ -39,22 +47,31 @@ router.use('*', requireAuth);
|
||||
|
||||
router.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const result = await query(
|
||||
`SELECT notification_method_id,
|
||||
alias,
|
||||
url,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM notification_methods
|
||||
WHERE user_id = $1
|
||||
AND notification_type = 'webhook'
|
||||
ORDER BY created_at DESC`,
|
||||
[user.user_id],
|
||||
);
|
||||
const [webhookResult, settingsResult] = await Promise.all([
|
||||
query(
|
||||
`SELECT notification_method_id,
|
||||
alias,
|
||||
url,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM notification_methods
|
||||
WHERE user_id = $1
|
||||
AND notification_type = 'webhook'
|
||||
ORDER BY created_at DESC`,
|
||||
[user.user_id],
|
||||
),
|
||||
query(
|
||||
`SELECT webhook_message_template, timezone
|
||||
FROM user_notification_settings
|
||||
WHERE user_id = $1`,
|
||||
[user.user_id],
|
||||
),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
webhooks: result.rows.map(serializeWebhook),
|
||||
webhooks: webhookResult.rows.map(serializeWebhook),
|
||||
vapidPublicKey: env.vapidPublicKey,
|
||||
webhookMessageSettings: serializeWebhookMessageSettings(settingsResult.rows[0]),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +156,42 @@ router.delete('/webhooks/:methodId', async (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
router.put('/webhook-message-settings', async (c) => {
|
||||
const body = webhookMessageSettingsSchema.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
throw badRequest('入力内容を確認してください', body.error.flatten());
|
||||
}
|
||||
|
||||
let webhookMessageTemplate;
|
||||
try {
|
||||
webhookMessageTemplate = validateWebhookMessageTemplate(body.data.webhookMessageTemplate);
|
||||
} catch (error) {
|
||||
throw badRequest(error.message);
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO user_notification_settings (user_id, webhook_message_template)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET webhook_message_template = EXCLUDED.webhook_message_template
|
||||
RETURNING webhook_message_template, timezone`,
|
||||
[c.get('user').user_id, webhookMessageTemplate],
|
||||
);
|
||||
|
||||
return c.json({ webhookMessageSettings: serializeWebhookMessageSettings(result.rows[0]) });
|
||||
});
|
||||
|
||||
router.delete('/webhook-message-settings', async (c) => {
|
||||
const result = await query(
|
||||
`UPDATE user_notification_settings
|
||||
SET webhook_message_template = NULL
|
||||
WHERE user_id = $1
|
||||
RETURNING webhook_message_template, timezone`,
|
||||
[c.get('user').user_id],
|
||||
);
|
||||
return c.json({ webhookMessageSettings: serializeWebhookMessageSettings(result.rows[0]) });
|
||||
});
|
||||
|
||||
router.post('/push-subscription-status', async (c) => {
|
||||
const body = pushSubscriptionStatusSchema.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
|
||||
117
src/server/modules/notificationMethods/webhookMessageSettings.js
Normal file
117
src/server/modules/notificationMethods/webhookMessageSettings.js
Normal file
@@ -0,0 +1,117 @@
|
||||
export const DEFAULT_TIMEZONE = 'Asia/Tokyo';
|
||||
|
||||
export const DEFAULT_WEBHOOK_MESSAGE_TEMPLATE = `{{siteDomain}} の有効期限が {{condTiming}} になりました。
|
||||
{{expiryDateTime}} に期限切れになります。`;
|
||||
|
||||
export const WEBHOOK_MESSAGE_VARIABLES = [
|
||||
'expiryDate',
|
||||
'expiryDateTime',
|
||||
'expiryTime',
|
||||
'siteName',
|
||||
'siteDomain',
|
||||
'condTiming',
|
||||
];
|
||||
|
||||
const VARIABLE_PATTERN = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
|
||||
|
||||
export function normalizeWebhookMessageTemplate(template) {
|
||||
return String(template ?? '').trim();
|
||||
}
|
||||
|
||||
export function validateWebhookMessageTemplate(template) {
|
||||
const normalized = normalizeWebhookMessageTemplate(template);
|
||||
if (!normalized) {
|
||||
throw new Error('メッセージを入力してください');
|
||||
}
|
||||
if (normalized.length > 2000) {
|
||||
throw new Error('メッセージは2000文字以内で入力してください');
|
||||
}
|
||||
|
||||
const unsupportedVariables = new Set();
|
||||
for (const match of normalized.matchAll(VARIABLE_PATTERN)) {
|
||||
if (!WEBHOOK_MESSAGE_VARIABLES.includes(match[1])) {
|
||||
unsupportedVariables.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (unsupportedVariables.size > 0) {
|
||||
throw new Error(`未対応のテンプレート変数があります: ${[...unsupportedVariables].join(', ')}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validateTimezone(timezone) {
|
||||
const normalized = String(timezone ?? '').trim();
|
||||
if (!normalized) {
|
||||
throw new Error('タイムゾーンを入力してください');
|
||||
}
|
||||
if (normalized.length > 80) {
|
||||
throw new Error('タイムゾーンは80文字以内で入力してください');
|
||||
}
|
||||
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: normalized }).format(new Date());
|
||||
} catch {
|
||||
throw new Error('タイムゾーンが不正です');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function serializeWebhookMessageSettings(row) {
|
||||
const hasCustomTemplate = Boolean(row?.webhook_message_template?.trim());
|
||||
return {
|
||||
webhookMessageTemplate: row?.webhook_message_template ?? DEFAULT_WEBHOOK_MESSAGE_TEMPLATE,
|
||||
timezone: row?.timezone ?? DEFAULT_TIMEZONE,
|
||||
usesDefault: !hasCustomTemplate,
|
||||
defaultWebhookMessageTemplate: DEFAULT_WEBHOOK_MESSAGE_TEMPLATE,
|
||||
availableVariables: WEBHOOK_MESSAGE_VARIABLES,
|
||||
};
|
||||
}
|
||||
|
||||
function dateParts(date, timezone) {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
|
||||
return Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]));
|
||||
}
|
||||
|
||||
export function formatConditionTiming(thresholdHours) {
|
||||
if (thresholdHours % 168 === 0) return `${thresholdHours / 168}週間前`;
|
||||
if (thresholdHours % 24 === 0) return `${thresholdHours / 24}日前`;
|
||||
return `${thresholdHours}時間前`;
|
||||
}
|
||||
|
||||
export function renderWebhookExpiryMessage({ site, condition, certificate, settings }) {
|
||||
const template =
|
||||
settings?.webhook_message_template?.trim() || DEFAULT_WEBHOOK_MESSAGE_TEMPLATE;
|
||||
const timezone = validateTimezone(settings?.timezone || DEFAULT_TIMEZONE);
|
||||
const parts = dateParts(certificate.expiresAt, timezone);
|
||||
const siteDomain = (() => {
|
||||
try {
|
||||
return new URL(site.url).hostname;
|
||||
} catch {
|
||||
return site.url;
|
||||
}
|
||||
})();
|
||||
|
||||
const values = {
|
||||
expiryDate: `${parts.year}/${parts.month}/${parts.day}`,
|
||||
expiryDateTime: `${parts.year}/${parts.month}/${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`,
|
||||
expiryTime: `${parts.hour}:${parts.minute}:${parts.second}`,
|
||||
siteName: site.alias,
|
||||
siteDomain,
|
||||
condTiming: formatConditionTiming(condition.threshold_hours),
|
||||
};
|
||||
|
||||
return template.replace(VARIABLE_PATTERN, (match, name) => values[name] ?? match);
|
||||
}
|
||||
Reference in New Issue
Block a user