Files
acs/server/routes/admin.ts
2026-07-16 17:50:58 +09:00

695 lines
26 KiB
TypeScript

import type { NextFunction, Request, Response } from 'express';
import { Router } from 'express';
import multer from 'multer';
import ExcelJS from 'exceljs';
import { ok } from '../http/apiResponse.js';
import { ApiError } from '../http/errors.js';
import { query } from '../db/pool.js';
import { diagnoseEmailConnectivity } from '../services/mail.js';
import { diagnoseSmsConnectivity } from '../services/sms.js';
import { parseRole, parseRoles, requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
type Row = Record<string, unknown>;
interface TeamRow extends Row {
id: string;
code: string;
name: string;
active: boolean;
default_roles: string[];
}
interface PurposeRow extends Row {
id: string;
code: string;
name: string;
sort_order: number;
active: boolean;
custom_allowed: boolean;
}
interface AdminUserRow extends Row {
id: string;
username: string;
full_name: string;
department: string | null;
team_id: string | null;
team_code: string | null;
team_name: string | null;
email: string | null;
enabled: boolean;
locked: boolean;
roles: string[];
}
interface ImportRow {
rowNumber: number;
status: 'CREATE' | 'UPDATE' | 'ERROR';
key?: string;
summary?: string;
errors: string[];
warnings: string[];
}
interface ImportResult {
totalRows: number;
createCount: number;
updateCount: number;
errorCount: number;
warningCount: number;
applied: boolean;
success: boolean;
rows: ImportRow[];
}
interface AdminRouterDeps {
query: QueryFn;
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 },
});
function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
return (req: Request, res: Response, next: NextFunction) => {
handler(req, res, next).catch(next);
};
}
function toTeam(row: TeamRow) {
return {
id: Number(row.id),
code: row.code,
name: row.name,
active: row.active,
defaultRoles: row.default_roles ?? [],
};
}
function toPurpose(row: PurposeRow) {
return {
id: Number(row.id),
code: row.code,
name: row.name,
sortOrder: row.sort_order,
active: row.active,
customAllowed: row.custom_allowed,
};
}
function toAdminUser(row: AdminUserRow) {
return {
id: Number(row.id),
username: row.username,
fullName: row.full_name,
department: row.department ?? undefined,
teamId: row.team_id ? Number(row.team_id) : undefined,
teamCode: row.team_code ?? undefined,
teamName: row.team_name ?? undefined,
email: row.email ?? undefined,
enabled: row.enabled,
locked: row.locked,
roles: row.roles ?? [],
};
}
async function audit(dbQuery: QueryFn, actor: UserRow, targetType: string, targetId: number | null, detail: string) {
await dbQuery(
`
INSERT INTO audit_logs (created_at, updated_at, actor_id, actor_username, action, target_type, target_id, detail)
VALUES (now(), now(), $1, $2, 'ADMIN_CONFIG_UPDATE', $3, $4, $5)
`,
[Number(actor.id), actor.username, targetType, targetId, detail],
);
}
async function listTeams(dbQuery: QueryFn) {
const result = await dbQuery<TeamRow>(
`
SELECT
t.id,
t.code,
t.name,
t.active,
COALESCE(array_agg(tdr.role ORDER BY tdr.role) FILTER (WHERE tdr.role IS NOT NULL), '{}') AS default_roles
FROM teams t
LEFT JOIN team_default_roles tdr ON tdr.team_id = t.id
GROUP BY t.id
ORDER BY t.name ASC
`,
);
return result.rows.map(toTeam);
}
async function listUsers(dbQuery: QueryFn) {
const result = await dbQuery<AdminUserRow>(
`
SELECT
u.id,
u.username,
u.full_name,
u.department,
u.team_id,
t.code AS team_code,
t.name AS team_name,
u.email,
u.enabled,
u.locked,
COALESCE(array_agg(ur.role ORDER BY ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN teams t ON t.id = u.team_id
LEFT JOIN user_roles ur ON ur.user_id = u.id
GROUP BY u.id, t.id
ORDER BY u.username ASC
`,
);
return result.rows.map(toAdminUser);
}
async function setTeamRoles(dbQuery: QueryFn, teamId: number, roles: string[]) {
await dbQuery('DELETE FROM team_default_roles WHERE team_id = $1', [teamId]);
for (const role of roles) {
await dbQuery('INSERT INTO team_default_roles (team_id, role) VALUES ($1, $2)', [teamId, role]);
}
}
async function setUserRoles(dbQuery: QueryFn, userId: number, roles: string[]) {
await dbQuery('DELETE FROM user_roles WHERE user_id = $1', [userId]);
for (const role of roles) {
await dbQuery('INSERT INTO user_roles (user_id, role) VALUES ($1, $2)', [userId, role]);
}
}
async function findTeamById(dbQuery: QueryFn, id: number): Promise<TeamRow | undefined> {
const result = await dbQuery<TeamRow>(
`
SELECT
t.id, t.code, t.name, t.active,
COALESCE(array_agg(tdr.role ORDER BY tdr.role) FILTER (WHERE tdr.role IS NOT NULL), '{}') AS default_roles
FROM teams t
LEFT JOIN team_default_roles tdr ON tdr.team_id = t.id
WHERE t.id = $1
GROUP BY t.id
`,
[id],
);
return result.rows[0];
}
async function findUserRowById(dbQuery: QueryFn, id: number): Promise<AdminUserRow | undefined> {
const result = await dbQuery<AdminUserRow>(
`
SELECT
u.id, u.username, u.full_name, u.department, u.team_id,
t.code AS team_code, t.name AS team_name,
u.email, u.enabled, u.locked,
COALESCE(array_agg(ur.role ORDER BY ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN teams t ON t.id = u.team_id
LEFT JOIN user_roles ur ON ur.user_id = u.id
WHERE u.id = $1
GROUP BY u.id, t.id
`,
[id],
);
return result.rows[0];
}
function requiredString(value: unknown, label: string): string {
const text = String(value ?? '').trim();
if (!text) {
throw new ApiError(400, `${label}은(는) 필수입니다.`);
}
return text;
}
function roleDisplay(roles: string[]) {
return `[${roles.join(', ')}]`;
}
async function workbookFromBuffer(file?: Express.Multer.File): Promise<ExcelJS.Workbook> {
if (!file) {
throw new ApiError(400, '업로드할 파일을 선택하세요.');
}
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as unknown as ExcelJS.Buffer);
return workbook;
}
function cellText(row: ExcelJS.Row, index: number): string {
const value = row.getCell(index).value;
if (value == null) return '';
if (typeof value === 'object' && 'text' in value) return String(value.text ?? '').trim();
if (typeof value === 'object' && 'result' in value) return String(value.result ?? '').trim();
return String(value).trim();
}
function splitRoles(raw: string, errors: string[]): string[] {
const roles: string[] = [];
for (const token of raw.split(/[,;/\s]+/)) {
if (!token.trim()) continue;
try {
roles.push(parseRole(token));
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
}
return [...new Set(roles)];
}
async function teamImportResult(dbQuery: QueryFn, file?: Express.Multer.File): Promise<{
result: ImportResult;
rows: Array<{ rowNumber: number; code: string; name: string; roles: string[]; errors: string[] }>;
}> {
const workbook = await workbookFromBuffer(file);
const sheet = workbook.worksheets[0];
const parsed: Array<{ rowNumber: number; code: string; name: string; roles: string[]; errors: string[] }> = [];
const seen = new Set<string>();
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const code = cellText(row, 1).toUpperCase();
const name = cellText(row, 2);
const errors: string[] = [];
const roles = splitRoles(cellText(row, 3), errors);
if (!code && !name && roles.length === 0) return;
if (!code) errors.push('팀 코드은(는) 필수입니다.');
if (!name) errors.push('팀명은(는) 필수입니다.');
if (roles.length === 0) errors.push('기본권한은 최소 1개 이상 필요합니다.');
if (seen.has(code)) errors.push('파일 안에 같은 팀 코드가 중복되었습니다.');
seen.add(code);
parsed.push({ rowNumber, code, name, roles, errors });
});
const result: ImportResult = {
totalRows: parsed.length,
createCount: 0,
updateCount: 0,
errorCount: 0,
warningCount: 0,
applied: false,
success: true,
rows: [],
};
for (const row of parsed) {
const codeResult = row.code
? await dbQuery<{ id: string }>('SELECT id FROM teams WHERE upper(code) = upper($1)', [row.code])
: { rows: [] };
const nameResult = row.name
? await dbQuery<{ code: string }>('SELECT code FROM teams WHERE name = $1 AND upper(code) <> upper($2)', [row.name, row.code])
: { rows: [] };
if (nameResult.rows[0]) {
row.errors.push(`이미 다른 팀 코드에서 사용하는 팀명입니다: ${nameResult.rows[0].code}`);
}
const exists = codeResult.rows.length > 0;
if (row.errors.length === 0) {
if (exists) result.updateCount += 1;
else result.createCount += 1;
}
result.rows.push({
rowNumber: row.rowNumber,
status: row.errors.length > 0 ? 'ERROR' : exists ? 'UPDATE' : 'CREATE',
key: row.code,
summary: `${row.name} / ${roleDisplay(row.roles)}`,
errors: row.errors,
warnings: [],
});
}
result.errorCount = result.rows.reduce((sum, row) => sum + row.errors.length, 0);
result.success = result.errorCount === 0;
if (!result.success) {
result.createCount = 0;
result.updateCount = 0;
}
return { result, rows: parsed };
}
async function userRoleImportResult(dbQuery: QueryFn, file?: Express.Multer.File): Promise<{
result: ImportResult;
rows: Array<{ rowNumber: number; username: string; fullName: string; teamName: string; roles: string[]; errors: string[] }>;
}> {
const workbook = await workbookFromBuffer(file);
const sheet = workbook.worksheets[0];
const parsed: Array<{ rowNumber: number; username: string; fullName: string; teamName: string; roles: string[]; errors: string[] }> = [];
const seen = new Set<string>();
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const username = cellText(row, 1);
const fullName = cellText(row, 2);
const teamName = cellText(row, 3);
const errors: string[] = [];
const roles = splitRoles(cellText(row, 4), errors);
if (!username && !fullName && !teamName && roles.length === 0) return;
if (!username) errors.push('아이디은(는) 필수입니다.');
if (!fullName) errors.push('이름은(는) 필수입니다.');
if (!teamName) errors.push('팀명은(는) 필수입니다.');
if (roles.length === 0) errors.push('권한은 최소 1개 이상 필요합니다.');
if (seen.has(username.toLowerCase())) errors.push('파일 안에 같은 아이디가 중복되었습니다.');
seen.add(username.toLowerCase());
parsed.push({ rowNumber, username, fullName, teamName, roles, errors });
});
const result: ImportResult = {
totalRows: parsed.length,
createCount: 0,
updateCount: 0,
errorCount: 0,
warningCount: 0,
applied: false,
success: true,
rows: [],
};
for (const row of parsed) {
const userResult = row.username
? await dbQuery<{ id: string; full_name: string }>('SELECT id, full_name FROM users WHERE username = $1', [row.username])
: { rows: [] };
const teamResult = row.teamName
? await dbQuery<{ id: string; active: boolean }>('SELECT id, active FROM teams WHERE name = $1', [row.teamName])
: { rows: [] };
const user = userResult.rows[0];
const team = teamResult.rows[0];
if (!user) row.errors.push('존재하지 않는 아이디입니다.');
else if (user.full_name !== row.fullName) row.errors.push(`등록된 이름과 일치하지 않습니다: ${user.full_name}`);
if (!team) row.errors.push('존재하지 않는 팀명입니다.');
else if (!team.active) row.errors.push('사용 중지된 팀입니다.');
if (row.errors.length === 0) result.updateCount += 1;
result.rows.push({
rowNumber: row.rowNumber,
status: row.errors.length > 0 ? 'ERROR' : 'UPDATE',
key: row.username,
summary: `${row.fullName} / ${row.teamName} / ${roleDisplay(row.roles)}`,
errors: row.errors,
warnings: [],
});
}
result.errorCount = result.rows.reduce((sum, row) => sum + row.errors.length, 0);
result.success = result.errorCount === 0;
if (!result.success) result.updateCount = 0;
return { result, rows: parsed };
}
async function sendTemplate(res: Response, filename: string, headers: string[], samples: string[][]) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('template');
sheet.addRow(headers);
for (const sample of samples) sheet.addRow(sample);
sheet.getRow(1).font = { bold: true };
sheet.columns.forEach((column) => {
column.width = 18;
});
const buffer = await workbook.xlsx.writeBuffer();
res
.status(200)
.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
.send(Buffer.from(buffer));
}
export function createAdminRouter(deps: AdminRouterDeps = { query }): Router {
const router = Router();
const dbQuery = deps.query;
router.get('/purpose-codes', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
const result = await dbQuery<PurposeRow>('SELECT * FROM purpose_codes ORDER BY sort_order ASC, name ASC');
ok(res, result.rows.map(toPurpose));
}));
router.post('/purpose-codes', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const code = requiredString(req.body?.code, '코드').toUpperCase();
const name = requiredString(req.body?.name, '표시명');
const sortOrder = Number(req.body?.sortOrder ?? 100);
const active = Boolean(req.body?.active);
const customAllowed = Boolean(req.body?.customAllowed);
const result = await dbQuery<PurposeRow>(
`
INSERT INTO purpose_codes (created_at, updated_at, code, name, sort_order, active, custom_allowed)
VALUES (now(), now(), $1, $2, $3, $4, $5)
RETURNING *
`,
[code, name, sortOrder, active, customAllowed],
);
await audit(dbQuery, actor, 'PURPOSE_CODE', Number(result.rows[0].id), `출입목적 코드 추가: ${code}/${name}`);
ok(res, toPurpose(result.rows[0]));
}));
router.put('/purpose-codes/:id', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const id = Number(req.params.id);
const code = requiredString(req.body?.code, '코드').toUpperCase();
const name = requiredString(req.body?.name, '표시명');
const result = await dbQuery<PurposeRow>(
`
UPDATE purpose_codes
SET code = $1, name = $2, sort_order = $3, active = $4, custom_allowed = $5, updated_at = now()
WHERE id = $6
RETURNING *
`,
[code, name, Number(req.body?.sortOrder ?? 100), Boolean(req.body?.active), Boolean(req.body?.customAllowed), id],
);
if (!result.rows[0]) throw new ApiError(404, '출입목적 코드를 찾을 수 없습니다.');
await audit(dbQuery, actor, 'PURPOSE_CODE', id, `출입목적 코드 수정: ${code}/${name}`);
ok(res, toPurpose(result.rows[0]));
}));
router.get('/settings/watcher1', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
ok(res, await watcher1(dbQuery));
}));
router.put('/settings/watcher1', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
await setSetting(dbQuery, 'watcher1.name', requiredString(req.body?.name, '이름'));
await setSetting(dbQuery, 'watcher1.team', requiredString(req.body?.team, '소속'));
await setSetting(dbQuery, 'watcher1.contact', requiredString(req.body?.contact, '연락처'));
await audit(dbQuery, actor, 'SYSTEM_SETTING', null, `현장감시자1 설정 수정: ${req.body.name}/${req.body.team}`);
ok(res, await watcher1(dbQuery));
}));
router.post('/sms/diagnostics', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
ok(res, await diagnoseSmsConnectivity());
}));
router.post('/email/diagnostics', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
ok(res, await diagnoseEmailConnectivity());
}));
router.get('/teams', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
ok(res, await listTeams(dbQuery));
}));
router.post('/teams', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const code = requiredString(req.body?.code, '팀 코드').toUpperCase();
const name = requiredString(req.body?.name, '팀명');
const roles = parseRoles(req.body?.defaultRoles);
const result = await dbQuery<TeamRow>(
`
INSERT INTO teams (created_at, updated_at, code, name, active)
VALUES (now(), now(), $1, $2, $3)
RETURNING id, code, name, active, '{}'::varchar[] AS default_roles
`,
[code, name, Boolean(req.body?.active)],
);
await setTeamRoles(dbQuery, Number(result.rows[0].id), roles);
await audit(dbQuery, actor, 'TEAM', Number(result.rows[0].id), `팀 추가: ${code}/${name}`);
ok(res, toTeam((await findTeamById(dbQuery, Number(result.rows[0].id)))!));
}));
router.put('/teams/:id', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const id = Number(req.params.id);
const code = requiredString(req.body?.code, '팀 코드').toUpperCase();
const name = requiredString(req.body?.name, '팀명');
const roles = parseRoles(req.body?.defaultRoles);
const old = await findTeamById(dbQuery, id);
if (!old) throw new ApiError(404, '팀을 찾을 수 없습니다.');
const result = await dbQuery<TeamRow>(
`
UPDATE teams SET code = $1, name = $2, active = $3, updated_at = now()
WHERE id = $4
RETURNING id, code, name, active, '{}'::varchar[] AS default_roles
`,
[code, name, Boolean(req.body?.active), id],
);
await setTeamRoles(dbQuery, id, roles);
if (old.name !== name) {
await dbQuery('UPDATE users SET department = $1, updated_at = now() WHERE team_id = $2', [name, id]);
}
await audit(dbQuery, actor, 'TEAM', id, `팀 수정: ${code}/${name}`);
ok(res, toTeam((await findTeamById(dbQuery, Number(result.rows[0].id)))!));
}));
router.post('/teams/:id/apply-default-roles', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const id = Number(req.params.id);
const team = await findTeamById(dbQuery, id);
if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.');
const roles = team.default_roles ?? [];
if (roles.length === 0) throw new ApiError(400, '팀 기본권한이 비어 있습니다.');
const users = await dbQuery<{ id: string }>('SELECT id FROM users WHERE team_id = $1 ORDER BY username', [id]);
for (const user of users.rows) {
await setUserRoles(dbQuery, Number(user.id), roles);
}
await audit(dbQuery, actor, 'TEAM', id, `팀원 전체 기본권한 적용: ${team.name} (${users.rows.length}명)`);
ok(res, `${users.rows.length}명의 팀원에게 기본권한을 적용했습니다.`);
}));
router.get('/teams/template', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
await sendTemplate(res, 'admin-teams-template.xlsx', ['팀 코드', '팀명', '기본권한'], [
['DEV2', '개발2팀', 'HOST'],
['SEC', '보안팀', 'SECURITY'],
]);
}));
router.post('/teams/upload', upload.single('file'), asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const dryRun = String(req.query.dryRun ?? 'true') !== 'false';
const { result, rows } = await teamImportResult(dbQuery, req.file);
if (!dryRun && result.success) {
for (const row of rows) {
const existing = await dbQuery<{ id: string }>('SELECT id FROM teams WHERE upper(code) = upper($1)', [row.code]);
let teamId: number;
if (existing.rows[0]) {
teamId = Number(existing.rows[0].id);
await dbQuery('UPDATE teams SET name = $1, updated_at = now() WHERE id = $2', [row.name, teamId]);
} else {
const inserted = await dbQuery<{ id: string }>(
'INSERT INTO teams (created_at, updated_at, code, name, active) VALUES (now(), now(), $1, $2, TRUE) RETURNING id',
[row.code, row.name],
);
teamId = Number(inserted.rows[0].id);
}
await setTeamRoles(dbQuery, teamId, row.roles);
}
result.applied = true;
await audit(dbQuery, actor, 'TEAM', null, `팀 엑셀 업로드: 신규 ${result.createCount}건, 수정 ${result.updateCount}`);
}
ok(res, result);
}));
router.get('/users', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
ok(res, await listUsers(dbQuery));
}));
router.put('/users/:id/team', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const id = Number(req.params.id);
const teamId = Number(req.body?.teamId);
const team = await findTeamById(dbQuery, teamId);
if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.');
await dbQuery('UPDATE users SET team_id = $1, department = $2, updated_at = now() WHERE id = $3', [teamId, team.name, id]);
if (Boolean(req.body?.applyDefaultRoles)) await setUserRoles(dbQuery, id, team.default_roles ?? []);
await audit(dbQuery, actor, 'USER', id, `팀 수정: ${id} -> ${team.name}`);
ok(res, toAdminUser((await findUserRowById(dbQuery, id))!));
}));
router.put('/users/:id/roles', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const id = Number(req.params.id);
const roles = parseRoles(req.body?.roles);
await setUserRoles(dbQuery, id, roles);
await audit(dbQuery, actor, 'USER', id, `권한 수정: ${id} -> ${roles.join(',')}`);
ok(res, toAdminUser((await findUserRowById(dbQuery, id))!));
}));
router.post('/users/:id/apply-team-default-roles', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const user = await findUserRowById(dbQuery, Number(req.params.id));
if (!user || !user.team_id) throw new ApiError(400, '사용자에게 팀이 지정되어 있지 않습니다.');
const team = await findTeamById(dbQuery, Number(user.team_id));
if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.');
await setUserRoles(dbQuery, Number(user.id), team.default_roles ?? []);
await audit(dbQuery, actor, 'USER', Number(user.id), `팀 기본권한 적용: ${user.username} -> ${team.name}`);
ok(res, toAdminUser((await findUserRowById(dbQuery, Number(user.id)))!));
}));
router.get('/users/roles/template', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
await sendTemplate(res, 'admin-user-roles-template.xlsx', ['아이디', '이름', '팀명', '권한'], [
['h', '홍길동', '개발1팀', 'HOST'],
['s', '보안담당', '보안팀', 'SECURITY'],
]);
}));
router.post('/users/roles/upload', upload.single('file'), asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const dryRun = String(req.query.dryRun ?? 'true') !== 'false';
const { result, rows } = await userRoleImportResult(dbQuery, req.file);
if (!dryRun && result.success) {
for (const row of rows) {
const user = await dbQuery<{ id: string }>('SELECT id FROM users WHERE username = $1', [row.username]);
const team = await dbQuery<{ id: string }>('SELECT id FROM teams WHERE name = $1', [row.teamName]);
await dbQuery('UPDATE users SET team_id = $1, department = $2, updated_at = now() WHERE id = $3', [
Number(team.rows[0].id),
row.teamName,
Number(user.rows[0].id),
]);
await setUserRoles(dbQuery, Number(user.rows[0].id), row.roles);
}
result.applied = true;
await audit(dbQuery, actor, 'USER', null, `권한 엑셀 업로드: 수정 ${result.updateCount}`);
}
ok(res, result);
}));
return router;
}
async function setting(dbQuery: QueryFn, key: string, fallback: string): Promise<string> {
const result = await dbQuery<{ setting_value: string | null }>('SELECT setting_value FROM system_settings WHERE setting_key = $1', [key]);
return result.rows[0]?.setting_value || fallback;
}
async function setSetting(dbQuery: QueryFn, key: string, value: string) {
await dbQuery(
`
INSERT INTO system_settings (created_at, updated_at, setting_key, setting_value)
VALUES (now(), now(), $1, $2)
ON CONFLICT (setting_key)
DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = now()
`,
[key, value],
);
}
export async function watcher1(dbQuery: QueryFn) {
return {
name: await setting(dbQuery, 'watcher1.name', '류관순'),
team: await setting(dbQuery, 'watcher1.team', 'IT전략국'),
contact: await setting(dbQuery, 'watcher1.contact', '313'),
};
}
export function createConfigRouter(deps: AdminRouterDeps = { query }): Router {
const router = Router();
const dbQuery = deps.query;
router.get('/purpose-codes', asyncRoute(async (_req, res) => {
const result = await dbQuery<PurposeRow>(
'SELECT * FROM purpose_codes WHERE active = TRUE ORDER BY sort_order ASC, name ASC',
);
ok(res, result.rows.map(toPurpose));
}));
router.get('/settings/watcher1', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
ok(res, await watcher1(dbQuery));
}));
return router;
}
export const adminRouter = createAdminRouter();
export const configRouter = createConfigRouter();