import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; import { getSharedResourceTokenDetailByShareToken } from '../services/shared-resource-token-service.js'; import { createBaseballTicketBayAlert, createBaseballTicketBayLog, deleteBaseballTicketBayLog, deleteBaseballTicketBayAlert, listBaseballTicketBayAlerts, listBaseballTicketBayLogs, runBaseballTicketBayAlert, searchBaseballTicketBayListings, updateBaseballTicketBayAlert, } from '../services/baseball-ticket-bay-service.js'; const timeWindowSchema = z.object({ id: z.string().trim().min(1), start: z.string().trim().regex(/^\d{2}:\d{2}$/), end: z.string().trim().regex(/^\d{2}:\d{2}$/), }); const alertPayloadSchema = z.object({ title: z.string().trim().min(1).max(255), eventDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/), team: z.string().trim().min(1).max(50), zone: z.string().trim().min(1).max(100), aisleSide: z.string().trim().min(1).max(100), seatDirections: z.array(z.string().trim().min(1).max(50)).max(10), maxPrice: z.number().finite().positive().nullable(), seatCount: z.number().int().positive().max(10), batchIntervalMinutes: z.number().int().min(1).max(120), sameProductAlertEnabled: z.boolean(), sameProductNotifyOnce: z.boolean(), active: z.boolean().default(true), timeWindows: z.array(timeWindowSchema).min(1).max(24), }); function readHeader(request: { headers: Record }, key: string) { const raw = request.headers[key]; return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim(); } function hasBaseballTicketBayGlobalAccess(request: { headers: Record }) { return hasErrorLogViewAccessToken(request.headers['x-access-token']); } type BaseballTicketBayRouteAccessContext = | { scope: 'all' } | { scope: 'client'; clientId: string } | { scope: 'shared-token'; clientId: string; tokenId: string }; function toOwnerScope(accessContext: Exclude | { scope: 'all' }) { if (accessContext.scope === 'all') { return { kind: 'all' } as const; } if (accessContext.scope === 'shared-token') { return { kind: 'owner', ownerType: 'shared-token', ownerId: accessContext.tokenId } as const; } return { kind: 'owner', ownerType: 'client', ownerId: accessContext.clientId } as const; } async function resolveBaseballTicketBayAccessContext( request: { headers: Record }, ) : Promise { const clientId = readHeader(request, 'x-client-id'); if (hasBaseballTicketBayGlobalAccess(request)) { return { scope: 'all' }; } const accessToken = readHeader(request, 'x-access-token'); if (accessToken) { const sharedTokenDetail = await getSharedResourceTokenDetailByShareToken(accessToken); if ( sharedTokenDetail && sharedTokenDetail.token.enabled !== false && !sharedTokenDetail.token.revokedAt && sharedTokenDetail.token.allowedAppIds.some((item) => item.trim().toLowerCase() === 'baseball-ticket-bay') ) { if (!clientId) { return null; } return { scope: 'shared-token', clientId, tokenId: sharedTokenDetail.token.id, }; } } if (!clientId) { return null; } return { scope: 'client', clientId, }; } export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { app.post('/api/baseball-ticket-bay/search', async (request) => searchBaseballTicketBayListings(request.body ?? {})); app.get('/api/baseball-ticket-bay/alerts', async (request, reply) => { const accessContext = await resolveBaseballTicketBayAccessContext(request); if (!accessContext) { return reply.code(400).send({ message: '접근 식별값이 없어 알림 목록을 불러올 수 없습니다.' }); } return { ok: true, includeAllClients: accessContext.scope === 'all', accessScope: accessContext.scope, scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null, items: await listBaseballTicketBayAlerts(toOwnerScope(accessContext)), }; }); app.get('/api/baseball-ticket-bay/logs', async (request, reply) => { const accessContext = await resolveBaseballTicketBayAccessContext(request); if (!accessContext) { return reply.code(400).send({ message: '접근 식별값이 없어 로그를 불러올 수 없습니다.' }); } const query = z.object({ alertId: z.string().trim().min(1).optional() }).parse(request.query ?? {}); return { ok: true, includeAllClients: accessContext.scope === 'all', accessScope: accessContext.scope, scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null, items: await listBaseballTicketBayLogs(toOwnerScope(accessContext), query.alertId), }; }); app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => { const accessContext = await resolveBaseballTicketBayAccessContext(request); if (!accessContext || accessContext.scope === 'all') { return reply.code(400).send({ message: '현재 접근 범위에서는 로그를 삭제할 수 없습니다.' }); } const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {}); const item = await deleteBaseballTicketBayLog(params.id, toOwnerScope(accessContext)); if (!item) { return reply.code(404).send({ message: '삭제할 로그를 찾지 못했습니다.' }); } return { ok: true, item, }; }); app.post('/api/baseball-ticket-bay/alerts', async (request, reply) => { const accessContext = await resolveBaseballTicketBayAccessContext(request); if (!accessContext || accessContext.scope === 'all') { return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 저장할 수 없습니다.' }); } const payload = alertPayloadSchema.parse(request.body ?? {}); const item = await createBaseballTicketBayAlert(payload, { clientId: accessContext.clientId, ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, appOrigin: readHeader(request, 'x-app-origin'), appDomain: readHeader(request, 'x-app-domain'), }); await createBaseballTicketBayLog({ clientId: accessContext.clientId, ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, alertId: item.id, alertTitle: item.title, action: 'create', status: 'info', message: '알림 조건을 저장했습니다.', detail: `${item.team} · ${item.eventDate}`, }); return { ok: true, item, }; }); app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => { const accessContext = await resolveBaseballTicketBayAccessContext(request); if (!accessContext || accessContext.scope === 'all') { return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 수정할 수 없습니다.' }); } const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {}); const payload = alertPayloadSchema.partial().parse(request.body ?? {}); const item = await updateBaseballTicketBayAlert(params.id, payload, { clientId: accessContext.clientId, ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, appOrigin: readHeader(request, 'x-app-origin'), appDomain: readHeader(request, 'x-app-domain'), }); await createBaseballTicketBayLog({ clientId: accessContext.clientId, ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, alertId: item.id, alertTitle: item.title, action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run', status: 'info', message: payload.active === false ? '알림을 중지했습니다.' : payload.active === true ? '알림을 다시 실행 상태로 전환했습니다.' : '알림 조건을 수정 저장했습니다.', detail: `${item.team} · ${item.eventDate}`, }); return { ok: true, item, }; }); app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => { const accessContext = await resolveBaseballTicketBayAccessContext(request); if (!accessContext || accessContext.scope === 'all') { return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 삭제할 수 없습니다.' }); } const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {}); const item = await deleteBaseballTicketBayAlert(params.id, toOwnerScope(accessContext)); if (!item) { return reply.code(404).send({ message: '삭제할 알림을 찾지 못했습니다.' }); } await createBaseballTicketBayLog({ clientId: accessContext.clientId, ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, alertId: item.id, alertTitle: item.title, action: 'delete', status: 'info', message: '알림 항목을 삭제했습니다.', detail: `${item.team} · ${item.eventDate}`, }); return { ok: true, item, }; }); app.post('/api/baseball-ticket-bay/alerts/:id/run', async (request, reply) => { const accessContext = await resolveBaseballTicketBayAccessContext(request); if (!accessContext) { return reply.code(400).send({ message: '접근 식별값이 없어 즉시 실행할 수 없습니다.' }); } const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {}); if (accessContext.scope === 'all') { return reply.code(403).send({ message: '전체 보기 범위에서는 즉시 실행할 수 없습니다.' }); } const result = await runBaseballTicketBayAlert(params.id, { ignoreTimeWindow: true, scope: toOwnerScope(accessContext), }); return { ok: true, alert: result.alert, matches: result.matches, notifiedMatches: result.notifiedMatches, log: result.log, }; }); }