591 lines
18 KiB
TypeScript
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);
|
|
});
|
|
}
|