import { z } from 'zod' import { isBotUserAgent, isTrackableAnalyticsPath, normalizePageSlugForAnalytics, normalizePostSlugForAnalytics } from '../../lib/analytics.js' import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js' import { createSessionHashFromEvent, recordAnalyticsHeartbeat } from '../repositories/analytics-repository.js' const heartbeatInputSchema = z.object({ path: z.string().trim().min(1).max(500), postSlug: z.string().trim().max(200).optional().default(''), pageSlug: z.string().trim().max(200).optional().default(''), clientSessionId: z.string().trim().min(8).max(120), durationSeconds: z.number().int().min(0).max(1800), maxScrollRatio: z.number().min(0).max(1) }) /** * heartbeat 추적 요청을 처리한다. * @param {import('h3').H3Event} event - H3 이벤트 * @returns {Promise<{ ok: true }>} */ export const handleAnalyticsHeartbeat = async (event) => { const parsedBody = heartbeatInputSchema.safeParse(await readBody(event)) if (!parsedBody.success) { throw createError({ statusCode: 400, message: '통계 heartbeat 형식이 올바르지 않습니다.' }) } const body = parsedBody.data const userAgent = String(getRequestHeader(event, 'user-agent') || '') if (isBotUserAgent(userAgent)) { return { ok: true } } if (!isTrackableAnalyticsPath(body.path)) { return { ok: true } } const postSlug = normalizePostSlugForAnalytics(body.postSlug) const pageSlug = normalizePageSlugForAnalytics(body.pageSlug) let postId = null let pageId = null if (postSlug) { const post = await getPostBySlug(postSlug) if (!post) { return { ok: true } } postId = post.id } if (!postId && pageSlug) { const page = await getPageBySlug(pageSlug) if (!page) { return { ok: true } } pageId = page.id } const sessionHash = createSessionHashFromEvent(event, body.clientSessionId) await recordAnalyticsHeartbeat({ event, sessionHash, path: body.path, postId, postSlug, pageId, pageSlug, durationSeconds: body.durationSeconds, maxScrollRatio: body.maxScrollRatio }) return { ok: true } }