feat: add Node ACS admin workflows

This commit is contained in:
unknown
2026-07-16 11:00:09 +09:00
parent a25872394a
commit c6f342a861
24 changed files with 3442 additions and 266 deletions

682
server/routes/admin.ts Normal file
View File

@@ -0,0 +1,682 @@
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 { 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.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();

195
server/routes/auth.test.ts Normal file
View File

@@ -0,0 +1,195 @@
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
import test from 'node:test';
import bcrypt from 'bcryptjs';
import express from 'express';
import { createAuthRouter } from './auth.js';
import { errorHandler } from '../http/errors.js';
type QueryFn = NonNullable<Parameters<typeof createAuthRouter>[0]>['query'];
type JsonBody = {
code: number;
message: string;
data: any;
};
interface UserRow {
id: string;
username: string;
password_hash: string;
full_name: string;
email: string | null;
department: string | null;
must_change_password: boolean;
enabled: boolean;
locked: boolean;
roles: string[];
}
function queryResult<T>(rows: T[]) {
return {
command: 'SELECT',
rowCount: rows.length,
oid: 0,
fields: [],
rows,
};
}
function buildApp(query: QueryFn, sessionData: Record<string, unknown> = {}) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.session = {
...sessionData,
destroy(callback: (error?: Error) => void) {
callback();
},
} as typeof req.session;
next();
});
app.use('/auth', createAuthRouter({ query }));
app.use(errorHandler);
return app;
}
async function requestJson(
app: ReturnType<typeof buildApp>,
method: string,
path: string,
body?: unknown,
) {
const server = app.listen(0);
try {
const address = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${address.port}${path}`, {
method,
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return {
status: response.status,
body: await response.json() as JsonBody,
};
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
}
function makeQuery(user: UserRow, calls: Array<{ text: string; params?: unknown[] }>): QueryFn {
return (async (text, params) => {
const sql = String(text);
calls.push({ text: sql, params });
if (sql.includes('WHERE u.username = $1')) {
return queryResult(params?.[0] === user.username ? [user] : []);
}
if (sql.includes('WHERE u.id = $1')) {
return queryResult(Number(params?.[0]) === Number(user.id) ? [user] : []);
}
if (sql.includes('UPDATE users')) {
return queryResult([]);
}
throw new Error(`Unexpected query: ${sql}`);
}) as QueryFn;
}
test('login trims username and returns the current user shape', async () => {
const user: UserRow = {
id: '1',
username: 'a',
password_hash: await bcrypt.hash('1', 4),
full_name: 'ACS Admin',
email: 'admin@example.test',
department: 'IT',
must_change_password: false,
enabled: true,
locked: false,
roles: ['ADMIN'],
};
const calls: Array<{ text: string; params?: unknown[] }> = [];
const app = buildApp(makeQuery(user, calls));
const response = await requestJson(app, 'POST', '/auth/login', {
username: ' a ',
password: '1',
});
assert.equal(response.status, 200);
assert.equal(response.body.data.username, 'a');
assert.equal(response.body.data.fullName, 'ACS Admin');
assert.deepEqual(response.body.data.roles, ['ADMIN']);
assert.deepEqual(calls[0]?.params, ['a']);
});
test('login rejects disabled accounts even with a valid password', async () => {
const user: UserRow = {
id: '2',
username: 'disabled',
password_hash: await bcrypt.hash('1', 4),
full_name: 'Disabled User',
email: null,
department: null,
must_change_password: false,
enabled: false,
locked: false,
roles: ['HOST'],
};
const app = buildApp(makeQuery(user, []));
const response = await requestJson(app, 'POST', '/auth/login', {
username: 'disabled',
password: '1',
});
assert.equal(response.status, 401);
assert.equal(response.body.data, null);
});
test('me returns 401 without a session user', async () => {
const app = buildApp((async () => {
throw new Error('query should not run for unauthenticated /me');
}) as QueryFn);
const response = await requestJson(app, 'GET', '/auth/me');
assert.equal(response.status, 401);
assert.equal(response.body.data, null);
});
test('change-password updates the password hash and clears must_change_password', async () => {
const user: UserRow = {
id: '3',
username: 'host',
password_hash: await bcrypt.hash('OldPassword123!', 4),
full_name: 'Host User',
email: null,
department: null,
must_change_password: true,
enabled: true,
locked: false,
roles: ['HOST'],
};
const calls: Array<{ text: string; params?: unknown[] }> = [];
const app = buildApp(makeQuery(user, calls), { userId: 3 });
const response = await requestJson(app, 'POST', '/auth/change-password', {
oldPassword: 'OldPassword123!',
newPassword: 'NewPassword123!',
});
assert.equal(response.status, 200);
const updateCall = calls.find((call) => call.text.includes('UPDATE users'));
assert.ok(updateCall);
assert.equal(updateCall.params?.[1], 3);
assert.equal(await bcrypt.compare('NewPassword123!', String(updateCall.params?.[0])), true);
assert.notEqual(updateCall.params?.[0], user.password_hash);
});

View File

@@ -4,19 +4,7 @@ import { Router } from 'express';
import { ok } from '../http/apiResponse.js';
import { ApiError } from '../http/errors.js';
import { query } from '../db/pool.js';
interface UserRow {
id: string;
username: string;
password_hash: string;
full_name: string;
email: string | null;
department: string | null;
must_change_password: boolean;
enabled: boolean;
locked: boolean;
roles: string[];
}
import { findUserById, findUserByUsername, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
interface CurrentUser {
id: number;
@@ -28,7 +16,9 @@ interface CurrentUser {
mustChangePassword: boolean;
}
const router = Router();
interface AuthRouterDeps {
query: QueryFn;
}
function asyncRoute(
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
@@ -38,54 +28,6 @@ function asyncRoute(
};
}
async function findUserByUsername(username: string): Promise<UserRow | undefined> {
const result = await query<UserRow>(
`
SELECT
u.id,
u.username,
u.password_hash,
u.full_name,
u.email,
u.department,
u.must_change_password,
u.enabled,
u.locked,
COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
WHERE u.username = $1
GROUP BY u.id
`,
[username],
);
return result.rows[0];
}
async function findUserById(id: number): Promise<UserRow | undefined> {
const result = await query<UserRow>(
`
SELECT
u.id,
u.username,
u.password_hash,
u.full_name,
u.email,
u.department,
u.must_change_password,
u.enabled,
u.locked,
COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
WHERE u.id = $1
GROUP BY u.id
`,
[id],
);
return result.rows[0];
}
function toCurrentUser(user: UserRow): CurrentUser {
return {
id: Number(user.id),
@@ -98,168 +40,160 @@ function toCurrentUser(user: UserRow): CurrentUser {
};
}
async function requireCurrentUser(req: Request): Promise<UserRow> {
const userId = req.session.userId;
if (!userId) {
throw new ApiError(401, '로그인이 필요합니다.');
}
export function createAuthRouter(deps: AuthRouterDeps = { query }): Router {
const router = Router();
const dbQuery = deps.query;
const user = await findUserById(userId);
if (!user || !user.enabled || user.locked) {
req.session.userId = undefined;
throw new ApiError(401, '로그인이 필요합니다.');
}
router.post('/login', asyncRoute(async (req, res) => {
const username = String(req.body?.username ?? '').trim();
const password = String(req.body?.password ?? '');
return user;
if (!username || !password) {
throw new ApiError(400, '아이디와 비밀번호를 입력하세요.');
}
const user = await findUserByUsername(dbQuery, username);
const matches = user ? await bcrypt.compare(password, user.password_hash) : false;
if (!user || !matches || !user.enabled || user.locked) {
throw new ApiError(401, '아이디 또는 비밀번호가 올바르지 않습니다.');
}
req.session.userId = Number(user.id);
ok(res, toCurrentUser(user));
}));
router.post('/logout', (req, res, next) => {
req.session.destroy((error) => {
if (error) {
next(error);
return;
}
res.clearCookie('connect.sid');
ok(res, '로그아웃되었습니다.');
});
});
router.get('/me', asyncRoute(async (req, res) => {
const user = await requireCurrentUser(dbQuery, req);
ok(res, toCurrentUser(user));
}));
router.post('/change-password', asyncRoute(async (req, res) => {
const user = await requireCurrentUser(dbQuery, req);
const oldPassword = String(req.body?.oldPassword ?? '');
const newPassword = String(req.body?.newPassword ?? '');
if (newPassword.length < 8) {
throw new ApiError(400, '새 비밀번호는 8자 이상이어야 합니다.');
}
if (!(await bcrypt.compare(oldPassword, user.password_hash))) {
throw new ApiError(400, '현재 비밀번호가 올바르지 않습니다.');
}
if (await bcrypt.compare(newPassword, user.password_hash)) {
throw new ApiError(400, '기존 비밀번호와 다른 비밀번호를 입력하세요.');
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await dbQuery(
`
UPDATE users
SET password_hash = $1,
must_change_password = FALSE,
updated_at = now()
WHERE id = $2
`,
[passwordHash, Number(user.id)],
);
ok(res, '비밀번호가 변경되었습니다.');
}));
router.post('/diagnostics', asyncRoute(async (_req, res) => {
const tableResult = await dbQuery<{
current_schema: string;
users_table: string | null;
user_roles_table: string | null;
sessions_table: string | null;
migrations_table: string | null;
}>(
`
SELECT
current_schema() AS current_schema,
to_regclass('users')::text AS users_table,
to_regclass('user_roles')::text AS user_roles_table,
to_regclass('user_sessions')::text AS sessions_table,
to_regclass('schema_migrations')::text AS migrations_table
`,
);
const tables = tableResult.rows[0];
let users: Array<{
username: string;
enabled: boolean;
locked: boolean;
must_change_password: boolean;
password_hash_length: number;
roles: string[];
}> = [];
let userQueryError: string | undefined;
let migrations: string[] = [];
let migrationQueryError: string | undefined;
if (tables?.users_table && tables.user_roles_table) {
try {
const userResult = await dbQuery<{
username: string;
enabled: boolean;
locked: boolean;
must_change_password: boolean;
password_hash_length: number;
roles: string[];
}>(
`
SELECT
u.username,
u.enabled,
u.locked,
u.must_change_password,
length(u.password_hash) AS password_hash_length,
COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
WHERE u.username IN ('admin', 'security', 'host', 'a', 's', 'h')
GROUP BY u.id
ORDER BY u.username
`,
);
users = userResult.rows;
} catch (error) {
userQueryError = error instanceof Error ? error.message : String(error);
}
}
if (tables?.migrations_table) {
try {
const migrationResult = await dbQuery<{ filename: string }>(
'SELECT filename FROM schema_migrations ORDER BY filename',
);
migrations = migrationResult.rows.map((row) => row.filename);
} catch (error) {
migrationQueryError = error instanceof Error ? error.message : String(error);
}
}
ok(res, {
authApi: true,
checkedAt: new Date().toISOString(),
tables,
users,
userQueryError,
migrations,
migrationQueryError,
});
}));
return router;
}
router.post('/login', asyncRoute(async (req, res) => {
const username = String(req.body?.username ?? '').trim();
const password = String(req.body?.password ?? '');
if (!username || !password) {
throw new ApiError(400, '아이디와 비밀번호를 입력하세요.');
}
const user = await findUserByUsername(username);
const matches = user ? await bcrypt.compare(password, user.password_hash) : false;
if (!user || !matches || !user.enabled || user.locked) {
throw new ApiError(401, '아이디 또는 비밀번호가 올바르지 않습니다.');
}
req.session.userId = Number(user.id);
ok(res, toCurrentUser(user));
}));
router.post('/logout', (req, res, next) => {
req.session.destroy((error) => {
if (error) {
next(error);
return;
}
res.clearCookie('connect.sid');
ok(res, '로그아웃되었습니다.');
});
});
router.get('/me', asyncRoute(async (req, res) => {
const user = await requireCurrentUser(req);
ok(res, toCurrentUser(user));
}));
router.post('/change-password', asyncRoute(async (req, res) => {
const user = await requireCurrentUser(req);
const oldPassword = String(req.body?.oldPassword ?? '');
const newPassword = String(req.body?.newPassword ?? '');
if (newPassword.length < 8) {
throw new ApiError(400, '새 비밀번호는 8자 이상이어야 합니다.');
}
if (!(await bcrypt.compare(oldPassword, user.password_hash))) {
throw new ApiError(400, '현재 비밀번호가 올바르지 않습니다.');
}
if (await bcrypt.compare(newPassword, user.password_hash)) {
throw new ApiError(400, '기존 비밀번호와 다른 비밀번호를 입력하세요.');
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await query(
`
UPDATE users
SET password_hash = $1,
must_change_password = FALSE,
updated_at = now()
WHERE id = $2
`,
[passwordHash, Number(user.id)],
);
ok(res, '비밀번호가 변경되었습니다.');
}));
router.post('/diagnostics', asyncRoute(async (_req, res) => {
const tableResult = await query<{
current_schema: string;
users_table: string | null;
user_roles_table: string | null;
sessions_table: string | null;
migrations_table: string | null;
}>(
`
SELECT
current_schema() AS current_schema,
to_regclass('users')::text AS users_table,
to_regclass('user_roles')::text AS user_roles_table,
to_regclass('user_sessions')::text AS sessions_table,
to_regclass('schema_migrations')::text AS migrations_table
`,
);
const tables = tableResult.rows[0];
let users: Array<{
username: string;
enabled: boolean;
locked: boolean;
must_change_password: boolean;
password_hash_length: number;
roles: string[];
}> = [];
let userQueryError: string | undefined;
let migrations: string[] = [];
let migrationQueryError: string | undefined;
if (tables?.users_table && tables.user_roles_table) {
try {
const userResult = await query<{
username: string;
enabled: boolean;
locked: boolean;
must_change_password: boolean;
password_hash_length: number;
roles: string[];
}>(
`
SELECT
u.username,
u.enabled,
u.locked,
u.must_change_password,
length(u.password_hash) AS password_hash_length,
COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
WHERE u.username IN ('admin', 'security', 'host', 'a', 's', 'h')
GROUP BY u.id
ORDER BY u.username
`,
);
users = userResult.rows;
} catch (error) {
userQueryError = error instanceof Error ? error.message : String(error);
}
}
if (tables?.migrations_table) {
try {
const migrationResult = await query<{ filename: string }>(
'SELECT filename FROM schema_migrations ORDER BY filename',
);
migrations = migrationResult.rows.map((row) => row.filename);
} catch (error) {
migrationQueryError = error instanceof Error ? error.message : String(error);
}
}
ok(res, {
authApi: true,
checkedAt: new Date().toISOString(),
tables,
users,
userQueryError,
migrations,
migrationQueryError,
});
}));
export const authRouter = router;
export const authRouter = createAuthRouter();

706
server/routes/business.ts Normal file
View File

@@ -0,0 +1,706 @@
import type { NextFunction, Request, Response } from 'express';
import { Router } from 'express';
import multer from 'multer';
import ExcelJS from 'exceljs';
import QRCode from 'qrcode';
import { randomUUID } from 'node:crypto';
import { ok } from '../http/apiResponse.js';
import { ApiError } from '../http/errors.js';
import { env } from '../config/env.js';
import { query } from '../db/pool.js';
import { requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
import { watcher1 } from './admin.js';
interface BusinessRouterDeps {
query: QueryFn;
}
interface VisitRow {
id: string;
visitor_name: string;
company: string | null;
contact: string | null;
email: string | null;
vehicle_no: string | null;
host_id: string;
host_name: string;
host_department: string | null;
zone_name: string | null;
purpose: string;
purpose_code: string | null;
purpose_detail: string | null;
work_name: string | null;
control_name: string | null;
control_team: string | null;
control_contact: string | null;
watcher1_name: string | null;
watcher1_team: string | null;
watcher1_contact: string | null;
watcher2_name: string | null;
watcher2_team: string | null;
watcher2_contact: string | null;
visit_from: string | Date;
visit_to: string | Date;
status: string;
qr_token: string | null;
created_at: string | Date;
}
interface AccessRow {
visit_request_id: string;
visitor_name: string;
company: string | null;
zone_name: string | null;
host_name: string;
visit_from: string | Date;
visit_to: string | Date;
check_in_at: string | Date | null;
check_out_at: string | Date | null;
}
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 3 * 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 toIso(value: string | Date | null | undefined): string | undefined {
if (value == null) return undefined;
return new Date(value).toISOString();
}
function toVisit(row: VisitRow) {
return {
id: Number(row.id),
visitorName: row.visitor_name,
company: row.company ?? undefined,
contact: row.contact ?? undefined,
email: row.email ?? undefined,
vehicleNo: row.vehicle_no ?? undefined,
hostId: Number(row.host_id),
hostName: row.host_name,
hostDepartment: row.host_department ?? undefined,
zoneName: row.zone_name ?? undefined,
purpose: row.purpose,
purposeCode: row.purpose_code ?? undefined,
purposeDetail: row.purpose_detail ?? undefined,
workName: row.work_name ?? undefined,
controlName: row.control_name ?? undefined,
controlTeam: row.control_team ?? undefined,
controlContact: row.control_contact ?? undefined,
watcher1Name: row.watcher1_name ?? undefined,
watcher1Team: row.watcher1_team ?? undefined,
watcher1Contact: row.watcher1_contact ?? undefined,
watcher2Name: row.watcher2_name ?? undefined,
watcher2Team: row.watcher2_team ?? undefined,
watcher2Contact: row.watcher2_contact ?? undefined,
visitFrom: toIso(row.visit_from)!,
visitTo: toIso(row.visit_to)!,
status: row.status,
qrToken: row.qr_token ?? undefined,
createdAt: toIso(row.created_at)!,
};
}
function required(value: unknown, label: string): string {
const text = String(value ?? '').trim();
if (!text) throw new ApiError(400, `${label}을(를) 입력하세요.`);
return text;
}
function sameDate(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
async function visitById(dbQuery: QueryFn, id: number): Promise<VisitRow | undefined> {
const result = await dbQuery<VisitRow>(
`
SELECT
vr.id,
v.name AS visitor_name,
v.company,
v.contact,
v.email,
v.vehicle_no,
h.id AS host_id,
h.full_name AS host_name,
h.department AS host_department,
vr.zone_name,
vr.purpose,
vr.purpose_code,
vr.purpose_detail,
vr.work_name,
vr.control_name,
vr.control_team,
vr.control_contact,
vr.watcher1_name,
vr.watcher1_team,
vr.watcher1_contact,
vr.watcher2_name,
vr.watcher2_team,
vr.watcher2_contact,
vr.visit_from,
vr.visit_to,
vr.status,
vr.qr_token,
vr.created_at
FROM visit_requests vr
JOIN visitors v ON v.id = vr.visitor_id
JOIN users h ON h.id = vr.host_id
WHERE vr.id = $1
`,
[id],
);
return result.rows[0];
}
async function listVisitRows(dbQuery: QueryFn, where = '', params: unknown[] = []) {
const result = await dbQuery<VisitRow>(
`
SELECT
vr.id,
v.name AS visitor_name,
v.company,
v.contact,
v.email,
v.vehicle_no,
h.id AS host_id,
h.full_name AS host_name,
h.department AS host_department,
vr.zone_name,
vr.purpose,
vr.purpose_code,
vr.purpose_detail,
vr.work_name,
vr.control_name,
vr.control_team,
vr.control_contact,
vr.watcher1_name,
vr.watcher1_team,
vr.watcher1_contact,
vr.watcher2_name,
vr.watcher2_team,
vr.watcher2_contact,
vr.visit_from,
vr.visit_to,
vr.status,
vr.qr_token,
vr.created_at
FROM visit_requests vr
JOIN visitors v ON v.id = vr.visitor_id
JOIN users h ON h.id = vr.host_id
${where}
ORDER BY vr.created_at DESC
`,
params,
);
return result.rows.map(toVisit);
}
async function audit(dbQuery: QueryFn, actor: UserRow, action: string, 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, $3, $4, $5, $6)
`,
[Number(actor.id), actor.username, action, targetType, targetId, detail],
);
}
async function resolvePurpose(dbQuery: QueryFn, code: string | undefined, purpose: string, detail?: string) {
if (code) {
const result = await dbQuery<{ code: string; name: string; custom_allowed: boolean; active: boolean }>(
'SELECT code, name, custom_allowed, active FROM purpose_codes WHERE upper(code) = upper($1)',
[code],
);
const item = result.rows[0];
if (!item || !item.active) throw new ApiError(400, `존재하지 않는 출입목적 코드입니다: ${code}`);
if (!item.custom_allowed) return { code: item.code, name: item.name, detail: null, display: item.name };
const text = String(detail || purpose || '').trim();
if (!text || text === item.name) throw new ApiError(400, '기타 출입 목적을 입력하세요.');
const matched = await dbQuery<{ code: string; name: string }>(
'SELECT code, name FROM purpose_codes WHERE active = TRUE AND custom_allowed = FALSE AND (upper(code) = upper($1) OR name = $1)',
[text],
);
if (matched.rows[0]) return { code: matched.rows[0].code, name: matched.rows[0].name, detail: null, display: matched.rows[0].name };
return { code: item.code, name: text, detail: text, display: text };
}
const text = required(purpose, '출입 목적');
return { code: null, name: text, detail: null, display: text };
}
async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record<string, unknown>, zoneName: string) {
const visitorName = required(body.visitorName, '방문자 이름');
const contact = required(body.contact, '방문자 연락처');
const visitFrom = new Date(required(body.visitFrom, '출입 일시'));
const visitTo = new Date(required(body.visitTo, '퇴실 일시'));
if (visitFrom > visitTo) throw new ApiError(400, '퇴장일시는 출입일시 이후로 입력해야 합니다.');
if (!sameDate(visitFrom, visitTo)) {
throw new ApiError(400, '퇴장일이 출입일 다음날 이후가 되는 경우는 2건으로 신청해야 합니다');
}
const purpose = await resolvePurpose(
dbQuery,
body.purposeCode ? String(body.purposeCode) : undefined,
String(body.purpose ?? ''),
body.purposeDetail ? String(body.purposeDetail) : undefined,
);
const watcher = await watcher1(dbQuery);
const visitor = await dbQuery<{ id: string }>(
`
INSERT INTO visitors (created_at, updated_at, name, company, contact, email, vehicle_no)
VALUES (now(), now(), $1, $2, $3, $4, $5)
RETURNING id
`,
[
visitorName,
body.company ? String(body.company).trim() : null,
contact,
body.email ? String(body.email).trim() : null,
body.vehicleNo ? String(body.vehicleNo).trim() : null,
],
);
const qrToken = randomUUID().replaceAll('-', '');
const created = await dbQuery<{ id: string }>(
`
INSERT INTO visit_requests (
created_at, updated_at, visitor_id, host_id, zone_name, purpose, purpose_code, purpose_detail,
work_name, visit_from, visit_to, status, qr_token,
control_name, control_team, control_contact,
watcher1_name, watcher1_team, watcher1_contact,
watcher2_name, watcher2_team, watcher2_contact
)
VALUES (
now(), now(), $1, $2, $3, $4, $5, $6,
$7, $8, $9, 'PENDING', $10,
$11, $12, $13,
$14, $15, $16,
$17, $18, $19
)
RETURNING id
`,
[
Number(visitor.rows[0].id),
Number(actor.id),
zoneName,
purpose.display,
purpose.code,
purpose.detail,
body.workName ? String(body.workName).trim() : null,
visitFrom,
visitTo,
qrToken,
actor.full_name,
actor.department,
null,
watcher.name,
watcher.team,
watcher.contact,
body.watcher2Name ? String(body.watcher2Name).trim() : null,
body.watcher2Team ? String(body.watcher2Team).trim() : null,
body.watcher2Contact ? String(body.watcher2Contact).trim() : null,
],
);
return toVisit((await visitById(dbQuery, Number(created.rows[0].id)))!);
}
export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Router {
const router = Router();
const dbQuery = deps.query;
router.get('/zones', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const result = await dbQuery('SELECT id, code, name, security_level FROM zones WHERE active = TRUE ORDER BY id');
ok(res, result.rows.map((row) => ({
id: Number(row.id),
code: row.code,
name: row.name,
securityLevel: row.security_level,
})));
}));
router.get('/visit-requests', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
ok(res, await listVisitRows(dbQuery));
}));
router.get('/visit-requests/pending', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'PENDING'"));
}));
router.get('/visit-requests/:id', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const visit = await visitById(dbQuery, Number(req.params.id));
if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.');
ok(res, toVisit(visit));
}));
router.post('/visit-requests', asyncRoute(async (req, res) => {
const actor = await requireCurrentUser(dbQuery, req);
const rooms = Array.isArray(req.body?.serverRooms) ? req.body.serverRooms.map(String).filter(Boolean) : [];
const roomZone = req.body?.roomZone ? String(req.body.roomZone).trim() : '';
const zones = rooms.length > 0 ? rooms : roomZone ? [roomZone] : [];
if (zones.length === 0) throw new ApiError(400, '출입 구역을 선택하세요.');
const created = [];
for (const zone of zones) {
created.push(await createOneVisit(dbQuery, actor, req.body, zone));
}
ok(res, created);
}));
router.post('/visit-requests/:id/cancel', asyncRoute(async (req, res) => {
const actor = await requireCurrentUser(dbQuery, req);
await dbQuery("UPDATE visit_requests SET status = 'CANCELLED', updated_at = now() WHERE id = $1 AND status IN ('PENDING', 'APPROVED')", [Number(req.params.id)]);
await audit(dbQuery, actor, 'REJECT', 'VISIT_REQUEST', Number(req.params.id), '신청 취소');
ok(res, toVisit((await visitById(dbQuery, Number(req.params.id)))!));
}));
router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (_req, res) => {
ok(res, { totalRows: 0, successCount: 0, errors: ['Node 배포본의 방문자 명단 엑셀 업로드는 다음 단계에서 이관 예정입니다.'], success: false });
}));
router.post('/approvals/:id/approve', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const id = Number(req.params.id);
await dbQuery("UPDATE visit_requests SET status = 'APPROVED', updated_at = now() WHERE id = $1", [id]);
await dbQuery(
"INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'APPROVED', $3, now())",
[id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null],
);
await dbQuery(
"INSERT INTO pass_deliveries (created_at, updated_at, visit_request_id, channel, recipient, status, attempts, last_error) VALUES (now(), now(), $1, 'DEV', NULL, 'SENT', 1, NULL)",
[id],
);
await audit(dbQuery, actor, 'APPROVE', 'VISIT_REQUEST', id, '승인');
ok(res, toVisit((await visitById(dbQuery, id))!));
}));
router.post('/approvals/:id/reject', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const id = Number(req.params.id);
await dbQuery("UPDATE visit_requests SET status = 'REJECTED', updated_at = now() WHERE id = $1", [id]);
await dbQuery(
"INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'REJECTED', $3, now())",
[id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null],
);
await audit(dbQuery, actor, 'REJECT', 'VISIT_REQUEST', id, '반려');
ok(res, toVisit((await visitById(dbQuery, id))!));
}));
router.get('/passes/:id', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const visit = await visitById(dbQuery, Number(req.params.id));
if (!visit) throw new ApiError(404, '출입증을 찾을 수 없습니다.');
ok(res, toVisit(visit));
}));
router.get('/passes/:id/qr.png', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const visit = await visitById(dbQuery, Number(req.params.id));
if (!visit?.qr_token) throw new ApiError(404, 'QR을 찾을 수 없습니다.');
const png = await QRCode.toBuffer(`${env.publicBaseUrl}/pass/${visit.qr_token}`, { type: 'png', width: 220 });
res.type('png').send(png);
}));
router.get('/public/passes/:token', asyncRoute(async (req, res) => {
const result = await dbQuery<VisitRow & { inside: boolean; completed_today: boolean }>(
`
SELECT
vr.*, v.name AS visitor_name, v.company, v.contact, v.email, v.vehicle_no,
h.id AS host_id, h.full_name AS host_name, h.department AS host_department,
EXISTS (
SELECT 1 FROM access_events ae
WHERE ae.visit_request_id = vr.id AND ae.direction = 'IN'
AND NOT EXISTS (
SELECT 1 FROM access_events out_ae
WHERE out_ae.visit_request_id = vr.id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at
)
) AS inside,
EXISTS (
SELECT 1 FROM access_events ae
WHERE ae.visit_request_id = vr.id AND ae.direction = 'OUT' AND ae.event_at::date = now()::date
) AS completed_today
FROM visit_requests vr
JOIN visitors v ON v.id = vr.visitor_id
JOIN users h ON h.id = vr.host_id
WHERE vr.qr_token = $1
`,
[req.params.token],
);
const row = result.rows[0];
if (!row) throw new ApiError(404, '출입증을 찾을 수 없습니다.');
ok(res, {
visitorName: row.visitor_name,
company: row.company ?? undefined,
zoneName: row.zone_name ?? undefined,
visitFrom: toIso(row.visit_from)!,
visitTo: toIso(row.visit_to)!,
inside: row.inside,
completedToday: row.completed_today,
});
}));
router.get('/public/passes/:token/qr.png', asyncRoute(async (req, res) => {
const png = await QRCode.toBuffer(`${env.publicBaseUrl}/pass/${req.params.token}`, { type: 'png', width: 220 });
res.type('png').send(png);
}));
async function accessAction(req: Request, res: Response, direction: 'IN' | 'OUT') {
const actor = req.path.startsWith('/public/') ? null : await requireCurrentUser(dbQuery, req);
const token = req.params.token || req.body?.qrToken;
let id = req.body?.visitRequestId ? Number(req.body.visitRequestId) : undefined;
if (!id && token) {
const found = await dbQuery<{ id: string }>('SELECT id FROM visit_requests WHERE qr_token = $1', [String(token)]);
id = found.rows[0] ? Number(found.rows[0].id) : undefined;
}
if (!id) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.');
const visit = await visitById(dbQuery, id);
if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.');
if (visit.status !== 'APPROVED') throw new ApiError(400, '승인된 방문 신청이 아닙니다.');
const today = new Date();
if (!sameDate(today, new Date(visit.visit_from))) {
await dbQuery("UPDATE visit_requests SET status = 'EXPIRED', updated_at = now() WHERE id = $1", [id]);
throw new ApiError(400, 'QR 유효기간이 지났습니다.');
}
await dbQuery(
'INSERT INTO access_events (created_at, updated_at, visit_request_id, direction, gate_id, operator_id, event_at) VALUES (now(), now(), $1, $2, $3, $4, now())',
[id, direction, req.body?.gateId ? String(req.body.gateId) : null, actor ? Number(actor.id) : null],
);
if (direction === 'OUT') {
await dbQuery(
"UPDATE visitors SET name='***', contact='***', email='***', vehicle_no='***', updated_at = now() WHERE id = (SELECT visitor_id FROM visit_requests WHERE id = $1)",
[id],
);
}
ok(res, {
visitRequestId: id,
visitorName: visit.visitor_name,
direction,
eventAt: new Date().toISOString(),
gateOpened: true,
message: direction === 'IN' ? '입장 처리되었습니다.' : '퇴장 처리되었습니다.',
});
}
router.post('/access/check-in', asyncRoute(async (req, res) => accessAction(req, res, 'IN')));
router.post('/access/check-out', asyncRoute(async (req, res) => accessAction(req, res, 'OUT')));
router.post('/public/passes/:token/check-in', asyncRoute(async (req, res) => accessAction(req, res, 'IN')));
router.post('/public/passes/:token/check-out', asyncRoute(async (req, res) => accessAction(req, res, 'OUT')));
router.get('/access/search', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const q = `%${String(req.query.q ?? '').trim()}%`;
ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'APPROVED' AND (v.name ILIKE $1 OR v.company ILIKE $1 OR vr.zone_name ILIKE $1)", [q]));
}));
router.get('/access/inside', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const result = await dbQuery<AccessRow>(
`
SELECT DISTINCT ON (vr.id)
vr.id AS visit_request_id, v.name AS visitor_name, v.company, vr.zone_name, h.full_name AS host_name,
vr.visit_from, vr.visit_to, ae.event_at AS check_in_at, NULL::timestamp AS check_out_at
FROM visit_requests vr
JOIN visitors v ON v.id = vr.visitor_id
JOIN users h ON h.id = vr.host_id
JOIN access_events ae ON ae.visit_request_id = vr.id AND ae.direction = 'IN'
WHERE NOT EXISTS (
SELECT 1 FROM access_events out_ae
WHERE out_ae.visit_request_id = vr.id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at
)
ORDER BY vr.id, ae.event_at DESC
`,
);
ok(res, result.rows.map((row) => ({
visitRequestId: Number(row.visit_request_id),
visitorName: row.visitor_name,
company: row.company ?? undefined,
zoneName: row.zone_name ?? undefined,
hostName: row.host_name,
checkInAt: toIso(row.check_in_at)!,
})));
}));
router.get('/access/today', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const result = await dbQuery<AccessRow & { inside: boolean }>(
`
SELECT
vr.id AS visit_request_id, v.name AS visitor_name, v.company, vr.zone_name, h.full_name AS host_name,
vr.visit_from, vr.visit_to,
min(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at,
max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at,
(max(ae.event_at) FILTER (WHERE ae.direction = 'IN')) > COALESCE(max(ae.event_at) FILTER (WHERE ae.direction = 'OUT'), 'epoch') AS inside
FROM visit_requests vr
JOIN visitors v ON v.id = vr.visitor_id
JOIN users h ON h.id = vr.host_id
LEFT JOIN access_events ae ON ae.visit_request_id = vr.id AND ae.event_at::date = now()::date
WHERE vr.visit_from::date = now()::date
GROUP BY vr.id, v.id, h.id
ORDER BY vr.visit_from
`,
);
ok(res, result.rows.map((row) => ({
visitRequestId: Number(row.visit_request_id),
visitorName: row.visitor_name,
company: row.company ?? undefined,
zoneName: row.zone_name ?? undefined,
visitFrom: toIso(row.visit_from)!,
visitTo: toIso(row.visit_to)!,
checkInAt: toIso(row.check_in_at),
checkOutAt: toIso(row.check_out_at),
inside: row.inside,
})));
}));
router.get('/stats/summary', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const result = await dbQuery<{ today_visits: string; pending: string; approved: string; total: string }>(
`
SELECT
count(*) FILTER (WHERE visit_from::date = now()::date) AS today_visits,
count(*) FILTER (WHERE status = 'PENDING') AS pending,
count(*) FILTER (WHERE status = 'APPROVED') AS approved,
count(*) AS total
FROM visit_requests
`,
);
const inside = await dbQuery<{ count: string }>(
`
SELECT count(DISTINCT ae.visit_request_id)
FROM access_events ae
WHERE ae.direction = 'IN'
AND NOT EXISTS (
SELECT 1 FROM access_events out_ae
WHERE out_ae.visit_request_id = ae.visit_request_id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at
)
`,
);
const row = result.rows[0];
ok(res, {
todayVisits: Number(row.today_visits),
pending: Number(row.pending),
approved: Number(row.approved),
currentlyInside: Number(inside.rows[0].count),
total: Number(row.total),
});
}));
router.get('/blacklist', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
const result = await dbQuery(
`
SELECT b.*, u.full_name AS created_by_name
FROM blacklist b
LEFT JOIN users u ON u.id = b.created_by
ORDER BY b.created_at DESC
`,
);
ok(res, result.rows.map((row) => ({
id: Number(row.id),
name: row.name,
company: row.company ?? undefined,
contact: row.contact ?? undefined,
reason: row.reason,
active: row.active,
createdByName: row.created_by_name ?? undefined,
createdAt: toIso(row.created_at)!,
})));
}));
router.post('/blacklist', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
const result = await dbQuery<{ id: string }>(
'INSERT INTO blacklist (created_at, updated_at, name, company, contact, reason, active, created_by) VALUES (now(), now(), $1, $2, $3, $4, TRUE, $5) RETURNING id',
[required(req.body?.name, '이름'), req.body?.company || null, req.body?.contact || null, required(req.body?.reason, '사유'), Number(actor.id)],
);
await audit(dbQuery, actor, 'BLACKLIST_ADD', 'BLACKLIST', Number(result.rows[0].id), '블랙리스트 추가');
ok(res, (await dbQuery('SELECT * FROM blacklist WHERE id = $1', [Number(result.rows[0].id)])).rows[0]);
}));
router.delete('/blacklist/:id', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req);
await dbQuery('UPDATE blacklist SET active = FALSE, updated_at = now() WHERE id = $1', [Number(req.params.id)]);
await audit(dbQuery, actor, 'BLACKLIST_REMOVE', 'BLACKLIST', Number(req.params.id), '블랙리스트 해제');
ok(res, '삭제되었습니다.');
}));
router.get('/reports/visits', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
ok(res, await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]));
}));
router.get('/reports/visits.xlsx', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const visits = await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]);
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('visits');
sheet.addRow(['방문자', '회사', '구역', '목적', '작업명', '상태', '출입일시', '퇴실일시']);
for (const visit of visits) {
sheet.addRow([visit.visitorName, visit.company ?? '', visit.zoneName ?? '', visit.purpose, visit.workName ?? '', visit.status, visit.visitFrom, visit.visitTo]);
}
sheet.getRow(1).font = { bold: true };
const buffer = await workbook.xlsx.writeBuffer();
res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));
}));
router.get('/admin/audit', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
const result = await dbQuery('SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 300');
ok(res, result.rows.map((row) => ({
id: Number(row.id),
at: toIso(row.created_at)!,
actorId: row.actor_id ? Number(row.actor_id) : undefined,
actorUsername: row.actor_username ?? undefined,
action: row.action,
targetType: row.target_type ?? undefined,
targetId: row.target_id ? Number(row.target_id) : undefined,
detail: row.detail ?? undefined,
})));
}));
router.get('/admin/deliveries', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
const status = req.query.status ? String(req.query.status) : null;
const result = await dbQuery(
`SELECT * FROM pass_deliveries ${status ? 'WHERE status = $1' : ''} ORDER BY created_at DESC LIMIT 300`,
status ? [status] : [],
);
ok(res, result.rows.map((row) => ({
id: Number(row.id),
visitRequestId: Number(row.visit_request_id),
channel: row.channel ?? undefined,
recipient: row.recipient ?? undefined,
status: row.status,
attempts: Number(row.attempts),
lastError: row.last_error ?? undefined,
createdAt: toIso(row.created_at)!,
updatedAt: toIso(row.updated_at)!,
})));
}));
router.post('/admin/deliveries/:id/retry', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req);
const result = await dbQuery(
"UPDATE pass_deliveries SET attempts = attempts + 1, status = 'SENT', last_error = NULL, updated_at = now() WHERE id = $1 RETURNING *",
[Number(req.params.id)],
);
ok(res, {
id: Number(result.rows[0].id),
visitRequestId: Number(result.rows[0].visit_request_id),
channel: result.rows[0].channel ?? undefined,
recipient: result.rows[0].recipient ?? undefined,
status: result.rows[0].status,
attempts: Number(result.rows[0].attempts),
lastError: result.rows[0].last_error ?? undefined,
createdAt: toIso(result.rows[0].created_at)!,
updatedAt: toIso(result.rows[0].updated_at)!,
});
}));
return router;
}
export const businessRouter = createBusinessRouter();

View File

@@ -1,8 +1,13 @@
import { Router } from 'express';
import { adminRouter, configRouter } from './admin.js';
import { authRouter } from './auth.js';
import { businessRouter } from './business.js';
import { healthRouter } from './health.js';
export const apiRouter = Router();
apiRouter.use(healthRouter);
apiRouter.use('/auth', authRouter);
apiRouter.use(configRouter);
apiRouter.use('/admin', adminRouter);
apiRouter.use(businessRouter);

119
server/routes/security.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { Request } from 'express';
import { ApiError } from '../http/errors.js';
import { query } from '../db/pool.js';
export interface UserRow {
id: string;
username: string;
password_hash: string;
full_name: string;
email: string | null;
department: string | null;
team_id?: string | null;
team_code?: string | null;
team_name?: string | null;
must_change_password: boolean;
enabled: boolean;
locked: boolean;
roles: string[];
}
export type QueryFn = typeof query;
export async function findUserByUsername(dbQuery: QueryFn, username: string): Promise<UserRow | undefined> {
const result = await dbQuery<UserRow>(
`
SELECT
u.id,
u.username,
u.password_hash,
u.full_name,
u.email,
u.department,
u.team_id,
t.code AS team_code,
t.name AS team_name,
u.must_change_password,
u.enabled,
u.locked,
COALESCE(array_agg(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.username = $1
GROUP BY u.id, t.id
`,
[username],
);
return result.rows[0];
}
export async function findUserById(dbQuery: QueryFn, id: number): Promise<UserRow | undefined> {
const result = await dbQuery<UserRow>(
`
SELECT
u.id,
u.username,
u.password_hash,
u.full_name,
u.email,
u.department,
u.team_id,
t.code AS team_code,
t.name AS team_name,
u.must_change_password,
u.enabled,
u.locked,
COALESCE(array_agg(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];
}
export async function requireCurrentUser(dbQuery: QueryFn, req: Request): Promise<UserRow> {
const userId = req.session.userId;
if (!userId) {
throw new ApiError(401, '로그인이 필요합니다.');
}
const user = await findUserById(dbQuery, userId);
if (!user || !user.enabled || user.locked) {
req.session.userId = undefined;
throw new ApiError(401, '로그인이 필요합니다.');
}
return user;
}
export async function requireAdmin(dbQuery: QueryFn, req: Request): Promise<UserRow> {
const user = await requireCurrentUser(dbQuery, req);
if (!user.roles.includes('ADMIN')) {
throw new ApiError(403, '접근 권한이 없습니다.');
}
return user;
}
export function parseRole(value: string): string {
const role = value.trim().toUpperCase() === 'USER' ? 'HOST' : value.trim().toUpperCase();
if (!['ADMIN', 'SECURITY', 'HOST'].includes(role)) {
throw new ApiError(400, `알 수 없는 권한입니다: ${value}`);
}
return role;
}
export function parseRoles(values: unknown): string[] {
if (!Array.isArray(values)) {
throw new ApiError(400, '권한은 배열로 입력해야 합니다.');
}
const roles = [...new Set(values.map((value) => parseRole(String(value))))];
if (roles.length === 0) {
throw new ApiError(400, '권한은 최소 1개 이상 필요합니다.');
}
return roles;
}