Files
ai-code-app/etc/servers/work-server/src/routes/play-app.ts
2026-05-29 17:42:33 +09:00

591 lines
18 KiB
TypeScript

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<PlayAppEnvironment>;
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<typeof playAppCreatePayloadSchema>;
type PlayAppUpdateInput = z.infer<typeof playAppUpdatePayloadSchema>;
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<PlayAppUpdateInput, 'id'>) {
const payload: Record<string, unknown> = {
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<PlayAppRegistryRecord[]>('*')
.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<PlayAppRegistryRecord>();
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<string, unknown>);
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<PlayAppRegistryRecord>();
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);
});
}