import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; import { db } from '../db/client.js'; type PlayAppEnvironment = 'preview' | 'test' | 'prod'; type PlayAppSeedEntry = { id: string; name: string; accentClassName: string; statusLabel: string; isReady: boolean; iconName: string; usagePriority?: number; supportedEnvironments: PlayAppEnvironment[]; searchKeywords?: string[]; searchDescription?: string; }; const PLAY_APP_TABLE = 'play_apps'; function getRequestAccessToken(request: FastifyRequest) { const tokenHeader = request.headers['x-access-token']; return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim(); } function ensurePlayAppWriteAuthorized(request: FastifyRequest, reply: FastifyReply) { if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) { return true; } reply.code(403).send({ message: '권한 토큰이 필요합니다.', }); return false; } const DEFAULT_ENTRIES: PlayAppSeedEntry[] = [ { id: 'baseball-ticket-bay', name: '야구-티켓베이', accentClassName: 'apps-library__card--baseball-ticket-bay', statusLabel: '알림', isReady: true, iconName: 'BellOutlined', usagePriority: 100, supportedEnvironments: ['preview', 'test'], searchKeywords: ['야구', '티켓베이', 'ticketbay', '야구 티켓', '웹푸시', '알림'], searchDescription: '팀, 구역, 통로, 가격 조건으로 야구 티켓 알림 조건을 저장하고 테스트 푸시를 보냅니다.', }, { id: 'e-reader', name: 'E-Reader', accentClassName: 'apps-library__card--reader', statusLabel: '읽기', isReady: true, iconName: 'BookOutlined', usagePriority: 80, supportedEnvironments: ['preview', 'test'], searchKeywords: ['e-reader', 'reader', 'ebook', 'article', 'web article', '기사', '전자책', '리더'], searchDescription: 'Apps 보관함에서 인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽습니다.', }, { id: 'photoprism', name: 'PhotoPrism', accentClassName: 'apps-library__card--photoprism', statusLabel: '연결', isReady: true, iconName: 'FileImageOutlined', usagePriority: 70, supportedEnvironments: ['preview', 'test'], searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'], searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.', }, { id: 'photo-puzzle', name: '사진 퍼즐', accentClassName: 'apps-library__card--puzzle', statusLabel: '실행', isReady: true, iconName: 'PictureOutlined', usagePriority: 60, supportedEnvironments: ['preview', 'test'], searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'], searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.', }, { id: 'the-quest', name: 'The Quest', accentClassName: 'apps-library__card--the-quest', statusLabel: '신규', isReady: true, iconName: 'ThunderboltOutlined', usagePriority: 50, supportedEnvironments: ['preview', 'test'], searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'], searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.', }, { id: 'template1', name: 'Template1', accentClassName: 'apps-library__card--template1', statusLabel: '템플릿', isReady: true, iconName: 'AppstoreAddOutlined', usagePriority: 45, supportedEnvironments: ['preview', 'test'], searchKeywords: ['template1', 'template', '앱 템플릿', '레이아웃', '기본 UI', 'layout'], searchDescription: '다른 앱 개발 시 공통 레이아웃을 빠르게 적용하기 위한 템플릿 화면입니다.', }, { id: 'tetris', name: 'Tetris', accentClassName: 'apps-library__card--tetris', statusLabel: '실행', isReady: true, iconName: 'FundProjectionScreenOutlined', usagePriority: 40, supportedEnvironments: ['preview', 'test'], searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'], searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.', }, { id: 'beat-lab', name: 'Beat Lab', accentClassName: 'apps-library__card--beat', statusLabel: '준비', isReady: false, iconName: 'SoundOutlined', usagePriority: 35, supportedEnvironments: ['preview'], searchDescription: '향후 추가 예정인 Beat Lab 앱', }, { id: 'sticker-booth', name: 'Sticker Booth', accentClassName: 'apps-library__card--sticker', statusLabel: '준비', isReady: false, iconName: 'StarOutlined', usagePriority: 30, supportedEnvironments: ['preview'], searchDescription: '향후 추가 예정인 Sticker Booth 앱', }, { id: 'launch-note', name: 'Launch Note', accentClassName: 'apps-library__card--launch', statusLabel: '예정', isReady: false, iconName: 'RocketOutlined', usagePriority: 20, supportedEnvironments: ['preview'], searchDescription: '향후 추가 예정인 Launch Note 앱', }, { id: 'arcade-pack', name: 'Arcade Pack', accentClassName: 'apps-library__card--arcade', statusLabel: '예정', isReady: false, iconName: 'FireOutlined', usagePriority: 10, supportedEnvironments: ['preview'], searchDescription: '향후 추가 예정인 Arcade Pack 앱', }, { id: 'app-vault', name: 'App Vault', accentClassName: 'apps-library__card--vault', statusLabel: '테마', isReady: false, iconName: 'AppstoreOutlined', usagePriority: 0, supportedEnvironments: ['preview'], searchDescription: '향후 추가 예정인 App Vault 앱', }, ] ; const listQuerySchema = z.object({ environment: z.enum(['preview', 'test', 'prod']).optional(), }); const playAppIdSchema = z.string().trim().min(1).max(100); const supportedEnvironmentSchema = z.enum(['preview', 'test', 'prod']); const iconNameSchema = z.enum([ 'AppstoreOutlined', 'AppstoreAddOutlined', 'BellOutlined', 'BookOutlined', 'FireOutlined', 'FundProjectionScreenOutlined', 'FileImageOutlined', 'PictureOutlined', 'RocketOutlined', 'SoundOutlined', 'StarOutlined', 'ThunderboltOutlined', ]); const playAppCreatePayloadSchema = z.object({ id: playAppIdSchema, name: z.string().trim().min(1).max(120), accentClassName: z.string().trim().min(1).max(80), statusLabel: z.string().trim().min(1).max(80), isReady: z.boolean().default(false), iconName: iconNameSchema, usagePriority: z.number().int().min(0).max(1_000_000).optional(), supportedEnvironments: z.array(supportedEnvironmentSchema).min(1).default(['preview']), searchKeywords: z .array(z.string().trim().min(1).max(80)) .default([]) .transform((keywords) => Array.from(new Set(keywords.map((keyword) => keyword.trim()).filter((keyword) => keyword.length > 0)))), searchDescription: z.string().trim().max(4000).default(''), }); const playAppUpdatePayloadSchema = z .object({ id: playAppIdSchema.optional(), name: z.string().trim().min(1).max(120).optional(), accentClassName: z.string().trim().min(1).max(80).optional(), statusLabel: z.string().trim().min(1).max(80).optional(), isReady: z.boolean().optional(), iconName: iconNameSchema.optional(), usagePriority: z.number().int().min(0).max(1_000_000).nullable().optional(), supportedEnvironments: z.array(supportedEnvironmentSchema).optional(), searchKeywords: z .array(z.string().trim().min(1).max(80)) .transform((keywords) => Array.from(new Set(keywords.map((keyword) => keyword.trim()).filter((keyword) => keyword.length > 0)))) .optional(), searchDescription: z.string().trim().max(4000).optional(), }) .refine((payload) => Object.keys(payload).length > 0, { message: '수정할 항목이 없습니다.', }); const playAppIdParamsSchema = z.object({ id: playAppIdSchema, }); type SupportedEnvironment = Array; type PlayAppRegistryRecord = { id: string; name: string; accent_class_name: string; status_label: string; is_ready: boolean; icon_name: string; usage_priority: number | null; supported_environments: string | null; search_keywords: string | null; search_description: string | null; }; type PlayAppRow = { id: string; name: string; accentClassName: string; statusLabel: string; isReady: boolean; iconName: string; usagePriority?: number; supportedEnvironments: SupportedEnvironment; searchKeywords: string[]; searchDescription: string; }; type PlayAppCreateInput = z.infer; type PlayAppUpdateInput = z.infer; function normalizeSupportedEnvironments(value: string | null): SupportedEnvironment { if (!value) { return ['preview']; } const trimmed = value.trim(); if (!trimmed) { return ['preview']; } if (trimmed.startsWith('[') && trimmed.endsWith(']')) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) { return parsed .map((item) => String(item).trim()) .filter((item): item is PlayAppEnvironment => item === 'preview' || item === 'test' || item === 'prod'); } } catch { // fall through to comma parser below. } } return value .trim() .split(',') .map((item) => item.trim().toLowerCase()) .filter((item): item is PlayAppEnvironment => item === 'preview' || item === 'test' || item === 'prod'); } function parseJsonArrayList(value: string | null): string[] { if (!value) { return []; } const trimmed = value.trim(); if (!trimmed) { return []; } if (trimmed.startsWith('[') && trimmed.endsWith(']')) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) { return parsed .map((item) => (typeof item === 'string' ? item.trim() : '')) .filter((item) => item.length > 0); } } catch { // fallthrough to legacy parser below. } } return trimmed .split(',') .map((item) => item.trim()) .filter((item) => item.length > 0); } function toResponseItem(row: PlayAppRegistryRecord): PlayAppRow { return { id: row.id, name: row.name, accentClassName: row.accent_class_name, statusLabel: row.status_label, isReady: !!row.is_ready, iconName: row.icon_name, usagePriority: row.usage_priority ?? undefined, supportedEnvironments: normalizeSupportedEnvironments(row.supported_environments), searchKeywords: parseJsonArrayList(row.search_keywords), searchDescription: row.search_description ?? '', }; } function encodeJsonList(values: readonly string[] | null | undefined) { if (!values || values.length === 0) { return '[]'; } return JSON.stringify(Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))); } function toDbRowPayload(input: PlayAppCreateInput | Omit) { const payload: Record = { id: 'id' in input ? input.id?.trim() : undefined, name: input.name?.trim(), accent_class_name: input.accentClassName?.trim(), status_label: input.statusLabel?.trim(), is_ready: input.isReady, icon_name: input.iconName, usage_priority: input.usagePriority ?? null, supported_environments: input.supportedEnvironments ? encodeJsonList(input.supportedEnvironments) : undefined, search_keywords: input.searchKeywords ? encodeJsonList(input.searchKeywords) : undefined, search_description: input.searchDescription ? input.searchDescription.trim() : undefined, }; Object.entries(payload).forEach(([key, value]) => { if (value === undefined) { delete payload[key]; } }); if ('is_ready' in payload && payload.is_ready === undefined) { payload.is_ready = false; } return payload; } function isDbUpdateResultEmpty(result: unknown) { if (Array.isArray(result)) { return result.length === 0; } return typeof result === 'number' ? result === 0 : false; } function parsePlayAppErrorWithCode(error: unknown, fallbackMessage: string) { if (error instanceof Error) { const code = (error as { code?: string }).code; if (code === 'ER_DUP_ENTRY' || code === '23505') { const duplicateError = error as Error & { statusCode?: number; details?: string }; duplicateError.statusCode = 409; duplicateError.message = '이미 등록된 앱 ID입니다.'; return duplicateError; } } if (error instanceof Error) { const duplicateError = error as Error & { statusCode?: number; details?: string }; duplicateError.message = fallbackMessage; return duplicateError; } const next = new Error(fallbackMessage) as Error & { statusCode?: number }; next.statusCode = 500; return next; } async function ensurePlayAppTable() { const exists = await db.schema.hasTable(PLAY_APP_TABLE); if (!exists) { await db.schema.createTable(PLAY_APP_TABLE, (table) => { table.string('id', 100).primary(); table.string('name', 120).notNullable(); table.string('accent_class_name', 80).notNullable(); table.string('status_label', 80).notNullable(); table.boolean('is_ready').notNullable().defaultTo(false); table.string('icon_name', 80).notNullable(); table.integer('usage_priority').nullable(); table.text('supported_environments').nullable(); table.text('search_keywords').nullable(); table.text('search_description').nullable(); table.index('is_ready', 'play_apps_is_ready_idx'); }); } const existingRows = await db(PLAY_APP_TABLE).select('id'); const existingIds = new Set(existingRows.map((row) => row.id)); const rowsToInsert = DEFAULT_ENTRIES.filter((entry) => !existingIds.has(entry.id)).map((entry) => ({ id: entry.id, name: entry.name, accent_class_name: entry.accentClassName, status_label: entry.statusLabel, is_ready: entry.isReady, icon_name: entry.iconName, usage_priority: entry.usagePriority, supported_environments: encodeJsonList(entry.supportedEnvironments), search_keywords: encodeJsonList(entry.searchKeywords ?? []), search_description: entry.searchDescription ?? '', })); if (rowsToInsert.length === 0) { return; } await db(PLAY_APP_TABLE).insert(rowsToInsert); } async function listPlayAppEntries(environment?: PlayAppEnvironment | null) { await ensurePlayAppTable(); const rows = (await db(PLAY_APP_TABLE) .select('*') .orderBy('usage_priority', 'desc') .orderBy('id', 'asc')) as PlayAppRegistryRecord[]; const normalizedRows = rows.map(toResponseItem); const filteredRows = environment ? normalizedRows.filter((row) => row.supportedEnvironments.includes(environment)) : normalizedRows; return filteredRows; } async function createPlayAppEntry(body: unknown) { await ensurePlayAppTable(); const payload = playAppCreatePayloadSchema.parse(body); const dbPayload = toDbRowPayload(payload); try { await db(PLAY_APP_TABLE).insert(dbPayload); } catch (error) { throw parsePlayAppErrorWithCode(error, `앱 등록에 실패했습니다: ${payload.id}`); } const insertedRow = await db(PLAY_APP_TABLE) .where({ id: payload.id }) .first(); if (!insertedRow) { const notFoundError = new Error('등록된 앱을 조회하지 못했습니다.') as Error & { statusCode?: number }; notFoundError.statusCode = 500; throw notFoundError; } return { ok: true, item: toResponseItem(insertedRow), }; } async function updatePlayAppEntry(params: unknown, body: unknown) { await ensurePlayAppTable(); const { id } = playAppIdParamsSchema.parse(params); const payload = playAppUpdatePayloadSchema.parse(body); if (payload.id && payload.id !== id) { const invalidError = new Error('요청 경로 ID와 본문 ID가 일치하지 않습니다.') as Error & { statusCode?: number }; invalidError.statusCode = 409; throw invalidError; } const dbPayload = toDbRowPayload(payload); const updated = await db(PLAY_APP_TABLE).where({ id }).update(dbPayload as Record); if (isDbUpdateResultEmpty(updated)) { const notFoundError = new Error(`수정할 앱을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number }; notFoundError.statusCode = 404; throw notFoundError; } const updatedRow = await db(PLAY_APP_TABLE).where({ id }).first(); if (!updatedRow) { const notFoundError = new Error(`수정한 앱을 조회할 수 없습니다: ${id}`) as Error & { statusCode?: number }; notFoundError.statusCode = 404; throw notFoundError; } return { ok: true, item: toResponseItem(updatedRow), }; } async function deletePlayAppEntry(params: unknown) { await ensurePlayAppTable(); const { id } = playAppIdParamsSchema.parse(params); const deleted = await db(PLAY_APP_TABLE).where({ id }).delete('*'); if (isDbUpdateResultEmpty(deleted)) { const notFoundError = new Error(`삭제할 앱을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number }; notFoundError.statusCode = 404; throw notFoundError; } if (Array.isArray(deleted) && deleted[0]) { return { ok: true, deletedId: id, item: toResponseItem(deleted[0] as PlayAppRegistryRecord), }; } return { ok: true, deletedId: id, }; } export async function registerPlayAppRoutes(app: FastifyInstance) { app.get('/api/play-apps', async (request) => { const query = listQuerySchema.parse(request.query); const items = await listPlayAppEntries(query.environment); return { ok: true, items, }; }); app.post('/api/play-apps', async (request, reply) => { if (!ensurePlayAppWriteAuthorized(request, reply)) { return; } return createPlayAppEntry(request.body); }); app.put('/api/play-apps/:id', async (request, reply) => { if (!ensurePlayAppWriteAuthorized(request, reply)) { return; } return updatePlayAppEntry(request.params, request.body); }); app.delete('/api/play-apps/:id', async (request, reply) => { if (!ensurePlayAppWriteAuthorized(request, reply)) { return; } return deletePlayAppEntry(request.params); }); }