chore: test deploy snapshot
This commit is contained in:
560
etc/servers/work-server/src/routes/play-app.ts
Normal file
560
etc/servers/work-server/src/routes/play-app.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
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';
|
||||
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) => {
|
||||
return createPlayAppEntry(request.body);
|
||||
});
|
||||
|
||||
app.put('/api/play-apps/:id', async (request) => {
|
||||
return updatePlayAppEntry(request.params, request.body);
|
||||
});
|
||||
|
||||
app.delete('/api/play-apps/:id', async (request) => {
|
||||
return deletePlayAppEntry(request.params);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user