import { Hono } from 'hono'; import { z } from 'zod'; import { query } from '../../db/pool.js'; import { requireAuth } from '../../middleware/auth.js'; import { badRequest, notFound } from '../../utils/httpErrors.js'; const router = new Hono(); const filterSchema = z.object({ siteId: z.string().uuid().optional(), alertType: z.string().trim().min(1).max(80).optional(), from: z.string().datetime({ offset: true }).optional(), to: z.string().datetime({ offset: true }).optional(), }); function serializeAlert(row) { return { alertId: row.alert_id, userId: row.user_id, siteId: row.site_id, siteAlias: row.site_alias, siteUrl: row.site_url, alertType: row.alert_type, content: row.content, occurredAt: row.occurred_at, readAt: row.read_at, deliveryChannels: row.delivery_channels ?? [], deliveryResult: row.delivery_result ?? {}, }; } router.use('*', requireAuth); router.get('/', async (c) => { const rawFilters = { siteId: c.req.query('siteId') || undefined, alertType: c.req.query('alertType') || undefined, from: c.req.query('from') || undefined, to: c.req.query('to') || undefined, }; const filters = filterSchema.safeParse(rawFilters); if (!filters.success) { throw badRequest('絞り込み条件を確認してください', filters.error.flatten()); } const conditions = ['a.user_id = $1']; const params = [c.get('user').user_id]; if (filters.data.siteId) { params.push(filters.data.siteId); conditions.push(`a.site_id = $${params.length}`); } if (filters.data.alertType) { params.push(filters.data.alertType); conditions.push(`a.alert_type = $${params.length}`); } if (filters.data.from) { params.push(filters.data.from); conditions.push(`a.occurred_at >= $${params.length}`); } if (filters.data.to) { params.push(filters.data.to); conditions.push(`a.occurred_at <= $${params.length}`); } const result = await query( `SELECT a.alert_id, a.user_id, a.site_id, s.alias AS site_alias, s.url AS site_url, a.alert_type, a.content, a.occurred_at, a.read_at, a.delivery_channels, a.delivery_result FROM alert_history a LEFT JOIN sites s ON s.site_id = a.site_id WHERE ${conditions.join(' AND ')} ORDER BY a.occurred_at DESC, a.created_at DESC LIMIT 200`, params, ); return c.json({ alerts: result.rows.map(serializeAlert) }); }); router.patch('/:alertId/read', async (c) => { const alertId = z.string().uuid().safeParse(c.req.param('alertId')); if (!alertId.success) { throw badRequest('アラート ID が不正です'); } const result = await query( `WITH updated AS ( UPDATE alert_history SET read_at = COALESCE(read_at, now()) WHERE user_id = $1 AND alert_id = $2 RETURNING alert_id, user_id, site_id, alert_type, content, occurred_at, read_at, delivery_channels, delivery_result ) SELECT u.alert_id, u.user_id, u.site_id, s.alias AS site_alias, s.url AS site_url, u.alert_type, u.content, u.occurred_at, u.read_at, u.delivery_channels, u.delivery_result FROM updated u LEFT JOIN sites s ON s.site_id = u.site_id`, [c.get('user').user_id, alertId.data], ); if (!result.rows[0]) { throw notFound('アラートが見つかりません'); } return c.json({ alert: serializeAlert(result.rows[0]) }); }); router.delete('/:alertId', async (c) => { const alertId = z.string().uuid().safeParse(c.req.param('alertId')); if (!alertId.success) { throw badRequest('アラート ID が不正です'); } const result = await query( `DELETE FROM alert_history WHERE user_id = $1 AND alert_id = $2 RETURNING alert_id`, [c.get('user').user_id, alertId.data], ); if (!result.rows[0]) { throw notFound('アラートが見つかりません'); } return c.json({ ok: true }); }); export default router;