747 lines
28 KiB
TypeScript
747 lines
28 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 VisitRequestResetResult {
|
|
visitRequests: number;
|
|
visitors: number;
|
|
approvals: number;
|
|
accessEvents: number;
|
|
passDeliveries: number;
|
|
}
|
|
|
|
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.post('/visit-requests/reset-test-data', asyncRoute(async (req, res) => {
|
|
const actor = await requireAdmin(dbQuery, req);
|
|
if (String(req.body?.confirm ?? '').replace(/\s/g, '').trim() !== '초기화') {
|
|
throw new ApiError(400, '확인 입력란에 "초기화"를 입력하세요.');
|
|
}
|
|
|
|
const targetRequests = await dbQuery<{ id: string; visitor_id: string }>(
|
|
'SELECT id, visitor_id FROM visit_requests',
|
|
);
|
|
const visitorIds = [...new Set(targetRequests.rows.map((row) => Number(row.visitor_id)))];
|
|
|
|
const passDeliveries = await dbQuery<{ id: string }>('DELETE FROM pass_deliveries RETURNING id');
|
|
const approvals = await dbQuery<{ id: string }>('DELETE FROM approvals RETURNING id');
|
|
const accessEvents = await dbQuery<{ id: string }>('DELETE FROM access_events RETURNING id');
|
|
const visitRequests = await dbQuery<{ id: string }>('DELETE FROM visit_requests RETURNING id');
|
|
const visitors = visitorIds.length > 0
|
|
? await dbQuery<{ id: string }>(
|
|
`
|
|
DELETE FROM visitors v
|
|
WHERE v.id = ANY($1::bigint[])
|
|
AND NOT EXISTS (SELECT 1 FROM visit_requests vr WHERE vr.visitor_id = v.id)
|
|
RETURNING id
|
|
`,
|
|
[visitorIds],
|
|
)
|
|
: { rows: [] };
|
|
|
|
const result: VisitRequestResetResult = {
|
|
visitRequests: visitRequests.rows.length,
|
|
visitors: visitors.rows.length,
|
|
approvals: approvals.rows.length,
|
|
accessEvents: accessEvents.rows.length,
|
|
passDeliveries: passDeliveries.rows.length,
|
|
};
|
|
await audit(
|
|
dbQuery,
|
|
actor,
|
|
'VISIT_REQUEST',
|
|
null,
|
|
`출입신청 테스트 데이터 초기화: 신청 ${result.visitRequests}건, 방문자 ${result.visitors}건, 승인 ${result.approvals}건, 입퇴장 ${result.accessEvents}건, 발송 ${result.passDeliveries}건`,
|
|
);
|
|
ok(res, result);
|
|
}));
|
|
|
|
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();
|