chore: sync backend and deployment changes
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
|
||||
import {
|
||||
getChatContextSettingsConfig,
|
||||
getAppConfigSnapshot,
|
||||
@@ -14,6 +16,124 @@ import {
|
||||
upsertAutomationContextsConfig,
|
||||
} from '../services/automation-context-config-service.js';
|
||||
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
|
||||
import {
|
||||
getTokenSettingsConfig,
|
||||
getTokenSettingById,
|
||||
upsertTokenSettingsConfig,
|
||||
type TokenSettingRecord,
|
||||
} from '../services/token-setting-config-service.js';
|
||||
|
||||
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
|
||||
|
||||
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-chat-share-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function resolveChatSharePath(token: string) {
|
||||
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
type TokenSettingsAccessContext =
|
||||
| { scope: 'full' }
|
||||
| { scope: 'shared'; tokenSetting: TokenSettingRecord };
|
||||
|
||||
type AppConfigAccessContext =
|
||||
| { scope: 'full' }
|
||||
| { scope: 'shared' };
|
||||
|
||||
async function resolveTokenSettingsAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return { scope: 'full' } satisfies TokenSettingsAccessContext;
|
||||
}
|
||||
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (managedResource.token.resourceType === 'chat-share') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
const hasManagePermission = managedResource.token.permissions.includes('manage');
|
||||
const canOpenTokenSetting = normalizedAllowedAppIds.has('token-setting');
|
||||
const tokenSettingId = managedResource.token.tokenSettingId?.trim() ?? '';
|
||||
|
||||
if (!hasManagePermission || !canOpenTokenSetting || !tokenSettingId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenSetting = await getTokenSettingById(tokenSettingId);
|
||||
if (!tokenSetting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { scope: 'shared', tokenSetting } satisfies TokenSettingsAccessContext;
|
||||
}
|
||||
|
||||
async function resolveAppConfigAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return { scope: 'full' } satisfies AppConfigAccessContext;
|
||||
}
|
||||
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (managedResource.token.resourceType === 'chat-share') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
const hasManagePermission = managedResource.token.permissions.includes('manage');
|
||||
const canOpenAppSettings = normalizedAllowedAppIds.has('app-settings');
|
||||
|
||||
if (!hasManagePermission || !canOpenAppSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { scope: 'shared' } satisfies AppConfigAccessContext;
|
||||
}
|
||||
|
||||
function sendTokenSettingsAccessDenied(
|
||||
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
|
||||
) {
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰 또는 토큰관리 관리 권한이 있는 공유 링크에서만 토큰 설정을 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
function sendAppConfigAccessDenied(
|
||||
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
|
||||
) {
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰 또는 앱 설정 관리 권한이 있는 공유 링크에서만 앱 설정을 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
function getRequestAppOrigin(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const rawAppOrigin = request.headers['x-app-origin'];
|
||||
@@ -50,7 +170,15 @@ function getRequestAppDomain(request: { headers: Record<string, string | string[
|
||||
}
|
||||
|
||||
export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
app.get('/api/app-config', async (request) => {
|
||||
app.get('/api/app-config', async (request, reply) => {
|
||||
const accessContext = await resolveAppConfigAccessContext(request);
|
||||
const hasShareToken = Boolean(getRequestChatShareToken(request));
|
||||
|
||||
if (hasShareToken && !accessContext) {
|
||||
sendAppConfigAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const appOrigin = getRequestAppOrigin(request);
|
||||
const config = await getAppConfigSnapshot(appOrigin);
|
||||
|
||||
@@ -96,6 +224,22 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/token-settings', async (request, reply) => {
|
||||
const accessContext = await resolveTokenSettingsAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendTokenSettingsAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenSettings =
|
||||
accessContext.scope === 'full' ? await getTokenSettingsConfig() : [accessContext.tokenSetting];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tokenSettings,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/chat-types', async (request, reply) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
@@ -219,7 +363,67 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/token-settings', async (request, reply) => {
|
||||
const accessContext = await resolveTokenSettingsAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendTokenSettingsAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = z.object({
|
||||
tokenSettings: z.array(z.unknown()),
|
||||
}).parse(payload ?? {});
|
||||
|
||||
const nextTokenSettingsInput = parsed.tokenSettings as Partial<TokenSettingRecord>[];
|
||||
const savedTokenSettings =
|
||||
accessContext.scope === 'full'
|
||||
? await upsertTokenSettingsConfig(nextTokenSettingsInput)
|
||||
: await (async () => {
|
||||
const authorizedSettingId = accessContext.tokenSetting.id;
|
||||
const requestedSetting = nextTokenSettingsInput.find(
|
||||
(item) => typeof item?.id === 'string' && item.id.trim().toLowerCase() === authorizedSettingId,
|
||||
);
|
||||
|
||||
if (!requestedSetting) {
|
||||
throw new Error('공유 링크에서는 현재 연결된 토큰 설정만 저장할 수 있습니다.');
|
||||
}
|
||||
|
||||
const currentTokenSettings = await getTokenSettingsConfig();
|
||||
const nextTokenSettings = currentTokenSettings.map((item) =>
|
||||
item.id === authorizedSettingId ? { ...requestedSetting, id: authorizedSettingId } : item,
|
||||
);
|
||||
return upsertTokenSettingsConfig(nextTokenSettings);
|
||||
})();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tokenSettings: accessContext.scope === 'full' ? savedTokenSettings : savedTokenSettings.filter((item) => item.id === accessContext.tokenSetting.id),
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '토큰 설정 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/app-config', async (request, reply) => {
|
||||
const accessContext = await resolveAppConfigAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAppConfigAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveStaticContentType } from './chat.js';
|
||||
import { resolveStaticContentType, shouldAutoCompleteShareReplyParentVerification } from './chat.js';
|
||||
|
||||
test('resolveStaticContentType returns html content type for chat resource html files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||
@@ -11,3 +11,41 @@ test('resolveStaticContentType keeps plain text content type for code resources'
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.ts'), 'text/plain; charset=utf-8');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
test('shouldAutoCompleteShareReplyParentVerification only completes answered requests that are not already verified', () => {
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: 101,
|
||||
responseText: '',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: null,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: 102,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1241
etc/servers/work-server/src/routes/photoprism.ts
Normal file
1241
etc/servers/work-server/src/routes/photoprism.ts
Normal file
File diff suppressed because it is too large
Load Diff
1417
etc/servers/work-server/src/routes/reader.ts
Normal file
1417
etc/servers/work-server/src/routes/reader.ts
Normal file
File diff suppressed because it is too large
Load Diff
57
etc/servers/work-server/src/routes/resource-manager.test.ts
Normal file
57
etc/servers/work-server/src/routes/resource-manager.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import { env } from '../config/env.js';
|
||||
import { registerResourceManagerRoutes, resolveSingleRange } from './resource-manager.js';
|
||||
|
||||
const fallbackResourceRoot = path.resolve(process.cwd(), '../../../resource');
|
||||
|
||||
test('resolveSingleRange parses open-ended and suffix byte ranges', () => {
|
||||
assert.deepEqual(resolveSingleRange('bytes=5-', 20), {
|
||||
isValid: true,
|
||||
start: 5,
|
||||
end: 19,
|
||||
});
|
||||
assert.deepEqual(resolveSingleRange('bytes=-4', 20), {
|
||||
isValid: true,
|
||||
start: 16,
|
||||
end: 19,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveSingleRange rejects malformed or out-of-bounds values', () => {
|
||||
assert.deepEqual(resolveSingleRange('bytes=', 20), { isValid: false });
|
||||
assert.deepEqual(resolveSingleRange('bytes=25-30', 20), { isValid: false });
|
||||
assert.deepEqual(resolveSingleRange('bytes=4-3', 20), { isValid: false });
|
||||
});
|
||||
|
||||
test('resource manager preview serves 206 partial content for byte ranges', async () => {
|
||||
const app = Fastify();
|
||||
await registerResourceManagerRoutes(app);
|
||||
|
||||
const relativePath = `range-test-${Date.now()}.wav`;
|
||||
const absolutePath = path.join(fallbackResourceRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, Buffer.from('0123456789', 'utf8'));
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/resource-manager/preview/${encodeURIComponent(relativePath)}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
|
||||
headers: {
|
||||
range: 'bytes=2-5',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 206);
|
||||
assert.equal(response.headers['accept-ranges'], 'bytes');
|
||||
assert.equal(response.headers['content-range'], 'bytes 2-5/10');
|
||||
assert.equal(response.headers['content-length'], '4');
|
||||
assert.equal(response.body, '2345');
|
||||
} finally {
|
||||
await fs.rm(absolutePath, { force: true });
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
@@ -48,6 +48,51 @@ const copyMoveBodySchema = z.object({
|
||||
nextName: z.string().trim().max(255).optional().nullable(),
|
||||
});
|
||||
|
||||
export function resolveSingleRange(rangeHeader: string | undefined, fileSize: number) {
|
||||
const rangeValue = String(rangeHeader ?? '').trim();
|
||||
|
||||
if (!rangeValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /^bytes=(\d*)-(\d*)$/u.exec(rangeValue);
|
||||
|
||||
if (!match) {
|
||||
return { isValid: false } as const;
|
||||
}
|
||||
|
||||
const [, startRaw, endRaw] = match;
|
||||
|
||||
if (!startRaw && !endRaw) {
|
||||
return { isValid: false } as const;
|
||||
}
|
||||
|
||||
if (!startRaw) {
|
||||
const suffixLength = Number(endRaw);
|
||||
|
||||
if (!Number.isInteger(suffixLength) || suffixLength <= 0) {
|
||||
return { isValid: false } as const;
|
||||
}
|
||||
|
||||
const start = Math.max(fileSize - suffixLength, 0);
|
||||
const end = fileSize - 1;
|
||||
return start <= end ? { isValid: true, start, end } as const : { isValid: false } as const;
|
||||
}
|
||||
|
||||
const start = Number(startRaw);
|
||||
const end = endRaw ? Number(endRaw) : fileSize - 1;
|
||||
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= fileSize) {
|
||||
return { isValid: false } as const;
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
start,
|
||||
end: Math.min(end, fileSize - 1),
|
||||
} as const;
|
||||
}
|
||||
|
||||
function getRequestAccessToken(request: FastifyRequest) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
@@ -123,10 +168,29 @@ export async function registerResourceManagerRoutes(app: FastifyInstance) {
|
||||
|
||||
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
||||
const preview = await openResourceManagerPreviewStream(resolveRepoRootPath(), decodeURIComponent(wildcard));
|
||||
const rangeHeader = Array.isArray(request.headers.range) ? request.headers.range[0] : request.headers.range;
|
||||
const range = resolveSingleRange(rangeHeader, preview.size);
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.type(preview.contentType);
|
||||
return reply.send(preview.stream);
|
||||
|
||||
if (range) {
|
||||
if (!range.isValid) {
|
||||
reply.status(416);
|
||||
reply.header('Content-Range', `bytes */${preview.size}`);
|
||||
return reply.send();
|
||||
}
|
||||
|
||||
const contentLength = range.end - range.start + 1;
|
||||
reply.status(206);
|
||||
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${preview.size}`);
|
||||
reply.header('Content-Length', String(contentLength));
|
||||
return reply.send(preview.createStream({ start: range.start, end: range.end }));
|
||||
}
|
||||
|
||||
reply.header('Content-Length', String(preview.size));
|
||||
return reply.send(preview.createStream());
|
||||
});
|
||||
|
||||
app.post('/api/resource-manager/directories', async (request, reply) => {
|
||||
|
||||
@@ -39,10 +39,16 @@ function getImmediateRestartBlockInfo(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key === 'work-server' && automationPendingCount > 0) {
|
||||
if (key === 'work-server') {
|
||||
const pendingCount = codexPendingCount + automationPendingCount;
|
||||
|
||||
if (pendingCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pendingCount: automationPendingCount,
|
||||
message: `진행 중인 자동화 작업 ${automationPendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
pendingCount,
|
||||
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
337
etc/servers/work-server/src/routes/shared-resource-token.ts
Normal file
337
etc/servers/work-server/src/routes/shared-resource-token.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
deleteSharedResourceTokens,
|
||||
deleteSharedResourceToken,
|
||||
getSharedResourceTokenDetail,
|
||||
getSharedResourceTokenDetailBySharePath,
|
||||
listSharedResourceTokens,
|
||||
recordSharedResourceTokenUsage,
|
||||
restoreSharedResourceToken,
|
||||
revokeSharedResourceToken,
|
||||
revokeSharedResourceTokens,
|
||||
sharedResourceTokenSchema,
|
||||
upsertSharedResourceToken,
|
||||
} from '../services/shared-resource-token-service.js';
|
||||
|
||||
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-chat-share-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function resolveChatSharePath(token: string) {
|
||||
return `/chat/share/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
type SharedResourceTokenAccessContext =
|
||||
| { scope: 'full' }
|
||||
| { scope: 'shared'; tokenId: string };
|
||||
|
||||
async function resolveSharedResourceTokenAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return { scope: 'full' } satisfies SharedResourceTokenAccessContext;
|
||||
}
|
||||
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
|
||||
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('shared-resource')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'shared',
|
||||
tokenId: managedResource.token.id,
|
||||
} satisfies SharedResourceTokenAccessContext;
|
||||
}
|
||||
|
||||
function sendAccessDenied(
|
||||
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
|
||||
) {
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰 또는 shared-resource 관리 권한이 있는 공유 링크에서만 공유 리소스 관리를 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
function isAllowedSharedTokenTarget(accessContext: SharedResourceTokenAccessContext, tokenId: string) {
|
||||
return accessContext.scope === 'full' || accessContext.tokenId === tokenId;
|
||||
}
|
||||
|
||||
export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
||||
app.get('/api/shared-resource-tokens', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const sharedTokenDetail =
|
||||
accessContext.scope === 'shared' ? await getSharedResourceTokenDetail(accessContext.tokenId) : null;
|
||||
const items =
|
||||
accessContext.scope === 'full'
|
||||
? await listSharedResourceTokens()
|
||||
: sharedTokenDetail?.token
|
||||
? [sharedTokenDetail.token]
|
||||
: [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크로는 이 공유 토큰 상세를 열 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const item = await getSharedResourceTokenDetail(tokenId);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...item,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/shared-resource-tokens', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = sharedResourceTokenSchema.parse(request.body ?? {});
|
||||
|
||||
if (accessContext.scope === 'shared' && payload.id !== accessContext.tokenId) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰 상세만 수정할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await upsertSharedResourceToken(payload);
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '공유 리소스 토큰 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/bulk-revoke', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
|
||||
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/:tokenId/revoke', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
const saved = await revokeSharedResourceToken(tokenId, payload.reason);
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
message: '회수할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/:tokenId/restore', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 복원할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await restoreSharedResourceToken(tokenId);
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
message: '복원할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/:tokenId/usage', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 기록할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
actorLabel: z.string().trim().max(120).optional().nullable(),
|
||||
summary: z.string().trim().max(400).optional().nullable(),
|
||||
detail: z.string().trim().max(2000).optional().nullable(),
|
||||
usageDelta: z.number().int().min(1).max(1_000_000).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
const saved = await recordSharedResourceTokenUsage(tokenId, payload);
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
message: '사용량을 기록할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/bulk-delete', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
|
||||
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await deleteSharedResourceTokens(payload.tokenIds);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await deleteSharedResourceToken(tokenId);
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deleted: true,
|
||||
tokenId,
|
||||
};
|
||||
});
|
||||
}
|
||||
357
etc/servers/work-server/src/routes/test-app.ts
Normal file
357
etc/servers/work-server/src/routes/test-app.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
const TEST_APP_TABLE = 'test_app_maintenance_requests';
|
||||
const TEST_APP_SEED_COUNT = 7000;
|
||||
const PRIORITY_VALUES = ['긴급', '높음', '보통', '낮음'] as const;
|
||||
const STATUS_VALUES = ['접수', '배정완료', '조치중', '부품대기', '완료'] as const;
|
||||
const ISSUE_TYPES = ['센서 오차', '진동 이상', '누유 감지', '온도 상승', '부품 마모', '통신 장애'] as const;
|
||||
const REQUESTERS = ['김민재', '박서윤', '이도윤', '최하린', '정서준', '한지민'] as const;
|
||||
const ASSIGNEES = ['윤태호', '장우진', '서가은', '임현수', '강다온', '문시우'] as const;
|
||||
const LINE_EQUIPMENT_MAP = {
|
||||
PKG: ['실링기 1호', '실링기 2호', '포장로봇 1호', '라벨러 2호'],
|
||||
MFG: ['혼합기 A', '혼합기 B', '압출기 3호', '컨베이어 7호'],
|
||||
UTL: ['냉각펌프 1호', '공조기 2호', '콤프레서 1호', '보일러 1호'],
|
||||
QC: ['비전검사기 1호', '중량선별기 2호', '샘플러 1호', '검사컨베이어 1호'],
|
||||
} as const;
|
||||
const LINE_CODES = Object.keys(LINE_EQUIPMENT_MAP) as Array<keyof typeof LINE_EQUIPMENT_MAP>;
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).default(100),
|
||||
keyword: z.string().trim().default(''),
|
||||
lineCode: z.string().trim().optional(),
|
||||
priority: z.string().trim().optional(),
|
||||
status: z.string().trim().optional(),
|
||||
requestedFrom: z.string().trim().optional(),
|
||||
requestedTo: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
const updateItemSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
priority: z.enum(PRIORITY_VALUES),
|
||||
status: z.enum(STATUS_VALUES),
|
||||
assigneeName: z.string().trim().max(80).optional(),
|
||||
});
|
||||
|
||||
const saveBodySchema = z.object({
|
||||
items: z.array(updateItemSchema).min(1).max(200),
|
||||
});
|
||||
|
||||
const deleteParamsSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
type TestAppRow = {
|
||||
id: number;
|
||||
request_no: string;
|
||||
line_code: string;
|
||||
equipment_name: string;
|
||||
issue_type: (typeof ISSUE_TYPES)[number];
|
||||
priority: (typeof PRIORITY_VALUES)[number];
|
||||
requester_name: string;
|
||||
assignee_name: string | null;
|
||||
status: (typeof STATUS_VALUES)[number];
|
||||
requested_at: Date | string;
|
||||
last_action_at: Date | string;
|
||||
};
|
||||
|
||||
function pad2(value: number) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDateTime(value: Date | string) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function toResponseRow(row: TestAppRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
requestNo: row.request_no,
|
||||
lineCode: row.line_code,
|
||||
equipmentName: row.equipment_name,
|
||||
issueType: row.issue_type,
|
||||
priority: row.priority,
|
||||
requesterName: row.requester_name,
|
||||
assigneeName: row.assignee_name ?? '',
|
||||
status: row.status,
|
||||
requestedAt: formatDateTime(row.requested_at),
|
||||
lastActionAt: formatDateTime(row.last_action_at),
|
||||
};
|
||||
}
|
||||
|
||||
function pickPriority(index: number) {
|
||||
const ratio = index % 100;
|
||||
|
||||
if (ratio < 6) {
|
||||
return '긴급' as const;
|
||||
}
|
||||
|
||||
if (ratio < 24) {
|
||||
return '높음' as const;
|
||||
}
|
||||
|
||||
if (ratio < 72) {
|
||||
return '보통' as const;
|
||||
}
|
||||
|
||||
return '낮음' as const;
|
||||
}
|
||||
|
||||
function pickStatus(index: number, priority: (typeof PRIORITY_VALUES)[number]) {
|
||||
const ratio = index % 100;
|
||||
|
||||
if (priority === '긴급') {
|
||||
if (ratio < 28) {
|
||||
return '접수' as const;
|
||||
}
|
||||
|
||||
if (ratio < 54) {
|
||||
return '배정완료' as const;
|
||||
}
|
||||
|
||||
if (ratio < 86) {
|
||||
return '조치중' as const;
|
||||
}
|
||||
|
||||
if (ratio < 93) {
|
||||
return '부품대기' as const;
|
||||
}
|
||||
|
||||
return '완료' as const;
|
||||
}
|
||||
|
||||
if (ratio < 22) {
|
||||
return '접수' as const;
|
||||
}
|
||||
|
||||
if (ratio < 45) {
|
||||
return '배정완료' as const;
|
||||
}
|
||||
|
||||
if (ratio < 70) {
|
||||
return '조치중' as const;
|
||||
}
|
||||
|
||||
if (ratio < 79) {
|
||||
return '부품대기' as const;
|
||||
}
|
||||
|
||||
return '완료' as const;
|
||||
}
|
||||
|
||||
function buildSeedRow(index: number) {
|
||||
const lineCode = LINE_CODES[index % LINE_CODES.length];
|
||||
const equipmentList = LINE_EQUIPMENT_MAP[lineCode];
|
||||
const equipmentName = equipmentList[Math.floor(index / LINE_CODES.length) % equipmentList.length];
|
||||
const priority = pickPriority(index);
|
||||
const status = pickStatus(index, priority);
|
||||
const issueType = ISSUE_TYPES[(index * 3 + Math.floor(index / 7)) % ISSUE_TYPES.length];
|
||||
const requesterName = REQUESTERS[(index * 5 + 1) % REQUESTERS.length];
|
||||
const assigneeName = status === '접수' ? null : ASSIGNEES[(index * 7 + 2) % ASSIGNEES.length];
|
||||
const requestedAt = new Date(Date.now() - ((index % (45 * 48)) * 30 + (index % 3) * 10) * 60_000);
|
||||
const lastActionAt =
|
||||
status === '접수'
|
||||
? requestedAt
|
||||
: new Date(requestedAt.getTime() + (((index % 9) + 1) * 45 + (priority === '긴급' ? 20 : 0)) * 60_000);
|
||||
const requestNo = `JR-${requestedAt.getFullYear()}${pad2(requestedAt.getMonth() + 1)}${pad2(requestedAt.getDate())}-${String(1000 + index).padStart(4, '0')}`;
|
||||
|
||||
return {
|
||||
request_no: requestNo,
|
||||
line_code: lineCode,
|
||||
equipment_name: equipmentName,
|
||||
issue_type: issueType,
|
||||
priority,
|
||||
requester_name: requesterName,
|
||||
assignee_name: assigneeName,
|
||||
status,
|
||||
requested_at: requestedAt,
|
||||
last_action_at: lastActionAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureTestAppTable() {
|
||||
const tableExists = await db.schema.hasTable(TEST_APP_TABLE);
|
||||
|
||||
if (!tableExists) {
|
||||
await db.schema.createTable(TEST_APP_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('request_no', 40).notNullable().unique();
|
||||
table.string('line_code', 20).notNullable();
|
||||
table.string('equipment_name', 120).notNullable();
|
||||
table.string('issue_type', 80).notNullable();
|
||||
table.string('priority', 20).notNullable();
|
||||
table.string('requester_name', 80).notNullable();
|
||||
table.string('assignee_name', 80).nullable();
|
||||
table.string('status', 40).notNullable();
|
||||
table.timestamp('requested_at').notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('last_action_at').notNullable().defaultTo(db.fn.now());
|
||||
table.index(['requested_at', 'id'], 'test_app_requests_requested_at_idx');
|
||||
table.index(['line_code', 'priority', 'status'], 'test_app_requests_filter_idx');
|
||||
});
|
||||
}
|
||||
|
||||
const countResult = await db(TEST_APP_TABLE).count<{ count: string }[]>({ count: '*' }).first();
|
||||
const currentCount = Number(countResult?.count ?? 0);
|
||||
|
||||
if (currentCount >= TEST_APP_SEED_COUNT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkSize = 500;
|
||||
const rowsToInsert = Array.from({ length: TEST_APP_SEED_COUNT - currentCount }, (_, offset) =>
|
||||
buildSeedRow(currentCount + offset),
|
||||
);
|
||||
|
||||
for (let index = 0; index < rowsToInsert.length; index += chunkSize) {
|
||||
await db(TEST_APP_TABLE).insert(rowsToInsert.slice(index, index + chunkSize));
|
||||
}
|
||||
}
|
||||
|
||||
function applyListFilters(baseQuery: ReturnType<typeof db>, query: z.infer<typeof listQuerySchema>) {
|
||||
if (query.keyword) {
|
||||
baseQuery.where((builder) => {
|
||||
builder
|
||||
.whereILike('request_no', `%${query.keyword}%`)
|
||||
.orWhereILike('equipment_name', `%${query.keyword}%`)
|
||||
.orWhereILike('requester_name', `%${query.keyword}%`);
|
||||
});
|
||||
}
|
||||
|
||||
if (query.lineCode && query.lineCode !== '전체') {
|
||||
baseQuery.where('line_code', query.lineCode);
|
||||
}
|
||||
|
||||
if (query.priority && query.priority !== '전체') {
|
||||
baseQuery.where('priority', query.priority);
|
||||
}
|
||||
|
||||
if (query.status && query.status !== '전체') {
|
||||
baseQuery.where('status', query.status);
|
||||
}
|
||||
|
||||
if (query.requestedFrom) {
|
||||
baseQuery.where('requested_at', '>=', new Date(`${query.requestedFrom}T00:00:00+09:00`));
|
||||
}
|
||||
|
||||
if (query.requestedTo) {
|
||||
baseQuery.where('requested_at', '<=', new Date(`${query.requestedTo}T23:59:59+09:00`));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListRequests(request: { query?: unknown }) {
|
||||
await ensureTestAppTable();
|
||||
|
||||
const query = listQuerySchema.parse(request.query ?? {});
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
const baseQuery = db(TEST_APP_TABLE);
|
||||
|
||||
applyListFilters(baseQuery, query);
|
||||
|
||||
const [countResult, rows] = await Promise.all([
|
||||
baseQuery.clone().count<{ count: string }[]>({ count: '*' }).first(),
|
||||
baseQuery
|
||||
.clone()
|
||||
.select<TestAppRow[]>('*')
|
||||
.orderBy('requested_at', 'desc')
|
||||
.orderBy('id', 'desc')
|
||||
.limit(query.pageSize)
|
||||
.offset(offset),
|
||||
]);
|
||||
|
||||
const total = Number(countResult?.count ?? 0);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: rows.map(toResponseRow),
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
hasNext: offset + rows.length < total,
|
||||
filters: {
|
||||
keyword: query.keyword,
|
||||
lineCode: query.lineCode ?? '전체',
|
||||
priority: query.priority ?? '전체',
|
||||
status: query.status ?? '전체',
|
||||
requestedFrom: query.requestedFrom ?? null,
|
||||
requestedTo: query.requestedTo ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSaveRequests(request: { body?: unknown }) {
|
||||
await ensureTestAppTable();
|
||||
|
||||
const payload = saveBodySchema.parse(request.body ?? {});
|
||||
const ids = payload.items.map((item) => item.id);
|
||||
const existingRows = await db(TEST_APP_TABLE).select<TestAppRow[]>('*').whereIn('id', ids);
|
||||
const existingIdSet = new Set(existingRows.map((row) => row.id));
|
||||
const missingIds = ids.filter((id) => !existingIdSet.has(id));
|
||||
|
||||
if (missingIds.length) {
|
||||
const error = new Error(`저장할 요청을 찾을 수 없습니다: ${missingIds.join(', ')}`) as Error & { statusCode?: number };
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const updatedRows: TestAppRow[] = [];
|
||||
|
||||
for (const item of payload.items) {
|
||||
const [updatedRow] = await db(TEST_APP_TABLE)
|
||||
.where({ id: item.id })
|
||||
.update(
|
||||
{
|
||||
priority: item.priority,
|
||||
status: item.status,
|
||||
assignee_name: item.assigneeName?.trim() || null,
|
||||
last_action_at: db.fn.now(),
|
||||
},
|
||||
'*',
|
||||
);
|
||||
|
||||
if (updatedRow) {
|
||||
updatedRows.push(updatedRow as TestAppRow);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
count: updatedRows.length,
|
||||
items: updatedRows.map(toResponseRow),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDeleteRequest(request: { params?: unknown }) {
|
||||
await ensureTestAppTable();
|
||||
|
||||
const { id } = deleteParamsSchema.parse(request.params ?? {});
|
||||
const [deletedRow] = await db(TEST_APP_TABLE).where({ id }).delete('*');
|
||||
|
||||
if (!deletedRow) {
|
||||
const error = new Error(`삭제할 요청을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deletedId: id,
|
||||
item: toResponseRow(deletedRow as TestAppRow),
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerTestAppRoutes(app: FastifyInstance) {
|
||||
app.get('/api/test-app/maintenance-requests', handleListRequests);
|
||||
app.get('/api/test-app/measurements', handleListRequests);
|
||||
|
||||
app.put('/api/test-app/maintenance-requests', handleSaveRequests);
|
||||
app.put('/api/test-app/measurements', handleSaveRequests);
|
||||
app.delete('/api/test-app/maintenance-requests/:id', handleDeleteRequest);
|
||||
}
|
||||
Reference in New Issue
Block a user