feat: add Node ACS admin workflows
This commit is contained in:
682
server/routes/admin.ts
Normal file
682
server/routes/admin.ts
Normal 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
195
server/routes/auth.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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
706
server/routes/business.ts
Normal 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();
|
||||
@@ -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
119
server/routes/security.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user