Реестр /users: - кнопка «Колонки» — чекбоксы базовых колонок и кастомных полей профиля (Оценка, Средняя оценка и др.), скрытые ключи в localStorage; - /api/users/list?includeFieldValues=1 — значения полей текущей страницы; - поля типа history-number (Оценка) кликабельны — открывают график истории. График истории (FieldHistoryDialog): - точки красные и крупные (на тёмном фоне дефолтные не видны), линия primary; - клик по точке — под графиком список оценок за бакет со ссылками на задачи (название задачи → /forms/<formId>/tasks/<id>); - API field-history возвращает entries (value, taskId, taskTitle, formId, bucket).
405 lines
18 KiB
TypeScript
405 lines
18 KiB
TypeScript
import { Router } from "express";
|
||
import { storage } from "../storage";
|
||
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||
import { isValidFieldType } from "@shared/field-types";
|
||
import { canManageUser } from "../utils/user-access";
|
||
|
||
export function registerUserProfileRoutes(router: Router): void {
|
||
// Поле профиля с options.readOnly менять через API нельзя — только из автоматизаций (storage напрямую)
|
||
async function assertProfileFieldEditable(fieldId: number, organizationId: number): Promise<string | null> {
|
||
const field = await storage.getUserProfileFieldById(fieldId, organizationId);
|
||
if (!field) return `Поле профиля ${fieldId} не найдено`;
|
||
if ((field.options as { readOnly?: boolean } | null)?.readOnly) {
|
||
return `Поле «${field.name}» доступно только для чтения`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// === USER PROFILE TABS ===
|
||
router.get('/api/user-profile/tabs',
|
||
authenticateToken,
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const tabs = await storage.getUserProfileTabs(req.organizationId!);
|
||
res.json({ success: true, tabs });
|
||
} catch (error) {
|
||
console.error('Get user profile tabs error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при получении вкладок профиля' });
|
||
}
|
||
}
|
||
);
|
||
|
||
router.post('/api/user-profile/tabs',
|
||
authenticateToken,
|
||
tenantIsolation,
|
||
requirePermission('users.manage'),
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const { tabs } = req.body;
|
||
if (!Array.isArray(tabs)) {
|
||
return res.status(400).json({ success: false, error: 'Вкладки должны быть массивом' });
|
||
}
|
||
const savedTabs = await storage.bulkSaveUserProfileTabs(req.organizationId!, tabs);
|
||
res.json({ success: true, tabs: savedTabs, message: 'Вкладки сохранены' });
|
||
} catch (error) {
|
||
console.error('Save user profile tabs error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при сохранении вкладок профиля' });
|
||
}
|
||
}
|
||
);
|
||
|
||
// === USER PROFILE FIELDS ===
|
||
router.get('/api/user-profile/fields',
|
||
authenticateToken,
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const fields = await storage.getUserProfileFields(req.organizationId!);
|
||
res.json({ success: true, fields });
|
||
} catch (error) {
|
||
console.error('Get user profile fields error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при получении полей профиля' });
|
||
}
|
||
}
|
||
);
|
||
|
||
router.post('/api/user-profile/fields',
|
||
authenticateToken,
|
||
tenantIsolation,
|
||
requirePermission('users.manage'),
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const { fields } = req.body;
|
||
if (!Array.isArray(fields)) {
|
||
return res.status(400).json({ success: false, error: 'Поля должны быть массивом' });
|
||
}
|
||
|
||
const existingFields = await storage.getUserProfileFields(req.organizationId!);
|
||
const incomingIds = fields.filter((f: Record<string, unknown>) => f.id).map((f: Record<string, unknown>) => f.id as number);
|
||
const fieldsToDelete = existingFields.filter(existing => !incomingIds.includes(existing.id));
|
||
|
||
// Check if fields to delete are in use
|
||
if (fieldsToDelete.length > 0) {
|
||
const allUsers = await storage.getUsersByOrganization(req.organizationId!);
|
||
const usedFieldIds = new Set<number>();
|
||
for (const user of allUsers) {
|
||
const values = await storage.getUserProfileFieldValues(user.id);
|
||
for (const value of values) {
|
||
usedFieldIds.add(value.fieldId);
|
||
}
|
||
}
|
||
for (const field of fieldsToDelete) {
|
||
if (usedFieldIds.has(field.id)) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: `Невозможно удалить поле "${field.name}" - оно используется в профилях пользователей. Сначала удалите значения этого поля во всех профилях.`
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const fieldIdsToDelete = fieldsToDelete.map(f => f.id);
|
||
const normalizedFields = fields.map((f: Record<string, unknown>) => ({
|
||
...f,
|
||
maxFileCount: f.maxFileCount != null && parseInt(String(f.maxFileCount)) > 0 ? parseInt(String(f.maxFileCount)) : null,
|
||
maxFileSizeMB: f.maxFileSizeMB != null && parseInt(String(f.maxFileSizeMB)) > 0 ? parseInt(String(f.maxFileSizeMB)) : null,
|
||
}));
|
||
|
||
// Validate field types for new fields
|
||
for (const field of fields as Array<Record<string, unknown>>) {
|
||
const fieldId = field.id as number | undefined;
|
||
const isNew = !fieldId || (typeof fieldId === 'number' && fieldId < 0);
|
||
const fieldType = field.type as string;
|
||
if (isNew && fieldType && !isValidFieldType(fieldType, 'user')) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: `Тип поля "${fieldType}" нельзя использовать в профиле пользователя.`,
|
||
});
|
||
}
|
||
}
|
||
|
||
const resultFields = await storage.bulkSaveUserProfileFields(
|
||
req.organizationId!,
|
||
fieldIdsToDelete,
|
||
normalizedFields as Parameters<typeof storage.bulkSaveUserProfileFields>[2]
|
||
);
|
||
|
||
res.status(201).json({ success: true, message: 'Поля сохранены', fields: resultFields });
|
||
} catch (error) {
|
||
console.error('Save user profile fields error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при сохранении полей профиля' });
|
||
}
|
||
}
|
||
);
|
||
|
||
// === USER PROFILE DETAIL ===
|
||
router.get('/api/users/:id/profile',
|
||
authenticateToken,
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const userId = parseInt(req.params.id);
|
||
if (isNaN(userId)) {
|
||
return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
|
||
}
|
||
const targetUser = await storage.getUser(userId);
|
||
if (!targetUser || targetUser.organizationId !== req.organizationId) {
|
||
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
|
||
}
|
||
const detail = await storage.getUserProfileDetail(userId, req.organizationId!);
|
||
const canEdit = await canManageUser(req.user, userId, req.organizationId!) || req.user?.id === userId;
|
||
res.json({ success: true, ...detail, canEdit });
|
||
} catch (error) {
|
||
console.error('Get user profile detail error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при получении профиля пользователя' });
|
||
}
|
||
}
|
||
);
|
||
|
||
// === USER PROFILE AUDIT LOG ===
|
||
router.get('/api/users/:id/profile/audit-log',
|
||
authenticateToken,
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const userId = parseInt(req.params.id);
|
||
if (isNaN(userId)) {
|
||
return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
|
||
}
|
||
const targetUser = await storage.getUser(userId);
|
||
if (!targetUser || targetUser.organizationId !== req.organizationId) {
|
||
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
|
||
}
|
||
const canView = await canManageUser(req.user, userId, req.organizationId!) || req.user?.id === userId;
|
||
if (!canView) {
|
||
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
|
||
}
|
||
const auditLog = await storage.getUserProfileAuditLog(userId, req.organizationId!);
|
||
res.json({ success: true, auditLog });
|
||
} catch (error) {
|
||
console.error('Get user profile audit log error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при получении истории изменений' });
|
||
}
|
||
}
|
||
);
|
||
|
||
// === USER FIELD HISTORY (history-number) ===
|
||
const FIELD_HISTORY_PERIODS: Record<string, { days: number; bucket: 'day' | 'week' | 'month' }> = {
|
||
week: { days: 7, bucket: 'day' },
|
||
month: { days: 30, bucket: 'day' },
|
||
quarter: { days: 90, bucket: 'week' },
|
||
year: { days: 365, bucket: 'month' },
|
||
};
|
||
|
||
router.get('/api/users/:id/field-history/:fieldId',
|
||
authenticateToken,
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const userId = parseInt(req.params.id);
|
||
const fieldId = parseInt(req.params.fieldId);
|
||
if (isNaN(userId) || isNaN(fieldId)) {
|
||
return res.status(400).json({ success: false, error: 'Неверный ID' });
|
||
}
|
||
// Права — как у просмотра профиля: любой сотрудник той же организации
|
||
const targetUser = await storage.getUser(userId);
|
||
if (!targetUser || targetUser.organizationId !== req.organizationId) {
|
||
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
|
||
}
|
||
const field = await storage.getUserProfileFieldById(fieldId, req.organizationId!);
|
||
if (!field || field.type !== 'history-number') {
|
||
return res.status(404).json({ success: false, error: 'Поле не найдено' });
|
||
}
|
||
const periodParam = String(req.query.period || 'month');
|
||
const period = FIELD_HISTORY_PERIODS[periodParam];
|
||
if (!period) {
|
||
return res.status(400).json({ success: false, error: 'Неверный период. Допустимо: week, month, quarter, year' });
|
||
}
|
||
const { points, overallAvg, totalCount } = await storage.getUserFieldHistoryPoints(
|
||
userId,
|
||
fieldId,
|
||
req.organizationId!,
|
||
period.days,
|
||
period.bucket
|
||
);
|
||
// Сырые записи с привязкой к задачам — для клика по точке графика
|
||
const entries = await storage.getUserFieldHistoryEntries(
|
||
userId,
|
||
fieldId,
|
||
req.organizationId!,
|
||
period.days,
|
||
period.bucket
|
||
);
|
||
res.json({
|
||
success: true,
|
||
field: { id: field.id, name: field.name },
|
||
period: periodParam,
|
||
points,
|
||
entries,
|
||
overallAvg,
|
||
totalCount,
|
||
});
|
||
} catch (error) {
|
||
console.error('Get user field history error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при получении истории поля' });
|
||
}
|
||
}
|
||
);
|
||
|
||
// === USER PROFILE FIELD VALUES ===
|
||
router.get('/api/users/:id/field-values',
|
||
authenticateToken,
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const userId = parseInt(req.params.id);
|
||
if (isNaN(userId)) {
|
||
return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
|
||
}
|
||
const targetUser = await storage.getUser(userId);
|
||
if (!targetUser || targetUser.organizationId !== req.organizationId) {
|
||
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
|
||
}
|
||
const values = await storage.getUserProfileFieldValues(userId);
|
||
res.json({ success: true, fieldValues: values });
|
||
} catch (error) {
|
||
console.error('Get user profile field values error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при получении значений полей' });
|
||
}
|
||
}
|
||
);
|
||
|
||
router.post('/api/users/:id/field-values',
|
||
authenticateToken,
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const userId = parseInt(req.params.id);
|
||
if (isNaN(userId)) {
|
||
return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
|
||
}
|
||
const targetUser = await storage.getUser(userId);
|
||
if (!targetUser || targetUser.organizationId !== req.organizationId) {
|
||
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
|
||
}
|
||
const canEdit = await canManageUser(req.user, userId, req.organizationId!) || req.user?.id === userId;
|
||
if (!canEdit) {
|
||
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
|
||
}
|
||
|
||
const { fieldValues } = req.body;
|
||
if (!Array.isArray(fieldValues)) {
|
||
return res.status(400).json({ success: false, error: 'Значения полей должны быть массивом' });
|
||
}
|
||
|
||
const changedBy = req.user!.id;
|
||
const createdValues = [];
|
||
for (const fv of fieldValues) {
|
||
const readOnlyError = await assertProfileFieldEditable(fv.fieldId, req.organizationId!);
|
||
if (readOnlyError) {
|
||
return res.status(400).json({ success: false, error: readOnlyError });
|
||
}
|
||
const existing = await storage.getUserProfileFieldValue(userId, fv.fieldId);
|
||
if (fv.value === null || fv.value === undefined) {
|
||
if (existing) {
|
||
await storage.deleteUserProfileFieldValueWithAudit(userId, fv.fieldId, req.organizationId!, changedBy);
|
||
}
|
||
} else {
|
||
if (existing) {
|
||
const updated = await storage.updateUserProfileFieldValueWithAudit(userId, fv.fieldId, { value: fv.value }, req.organizationId!, changedBy);
|
||
createdValues.push(updated);
|
||
} else {
|
||
const created = await storage.createUserProfileFieldValueWithAudit({
|
||
userId,
|
||
fieldId: fv.fieldId,
|
||
value: fv.value,
|
||
}, req.organizationId!, changedBy);
|
||
createdValues.push(created);
|
||
}
|
||
}
|
||
}
|
||
|
||
res.json({ success: true, fieldValues: createdValues });
|
||
} catch (error) {
|
||
console.error('Save user profile field values error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при сохранении значений полей' });
|
||
}
|
||
}
|
||
);
|
||
|
||
router.patch('/api/users/:id/field-values/:fieldId',
|
||
authenticateToken,
|
||
tenantIsolation,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const userId = parseInt(req.params.id);
|
||
const fieldId = parseInt(req.params.fieldId);
|
||
if (isNaN(userId) || isNaN(fieldId)) {
|
||
return res.status(400).json({ success: false, error: 'Неверный ID' });
|
||
}
|
||
const targetUser = await storage.getUser(userId);
|
||
if (!targetUser || targetUser.organizationId !== req.organizationId) {
|
||
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
|
||
}
|
||
const canEdit = await canManageUser(req.user, userId, req.organizationId!) || req.user?.id === userId;
|
||
if (!canEdit) {
|
||
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
|
||
}
|
||
|
||
const changedBy = req.user!.id;
|
||
const { value } = req.body;
|
||
const readOnlyError = await assertProfileFieldEditable(fieldId, req.organizationId!);
|
||
if (readOnlyError) {
|
||
return res.status(400).json({ success: false, error: readOnlyError });
|
||
}
|
||
const field = await storage.getUserProfileFieldById(fieldId, req.organizationId!);
|
||
const existing = await storage.getUserProfileFieldValue(userId, fieldId);
|
||
if (value === null || value === undefined) {
|
||
// Удаление (null): очищается только значение поля, история history-number не трогается
|
||
if (existing) {
|
||
await storage.deleteUserProfileFieldValueWithAudit(userId, fieldId, req.organizationId!, changedBy);
|
||
}
|
||
res.json({ success: true, fieldValue: null });
|
||
} else if (field!.type === 'history-number') {
|
||
// history-number: число пишется в user_field_history (актор — текущий пользователь),
|
||
// становится значением поля, месячная средняя пересчитывается
|
||
let num: number;
|
||
try {
|
||
num = await storage.recordUserFieldHistoryValue(userId, field!, value, req.organizationId!, {
|
||
auditActor: {
|
||
changedBy,
|
||
changedByName: `${req.user!.firstName || ''} ${req.user!.lastName || ''}`.trim(),
|
||
},
|
||
});
|
||
} catch (err) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: err instanceof Error ? err.message : 'Значение должно быть числом',
|
||
});
|
||
}
|
||
if (existing) {
|
||
const updated = await storage.updateUserProfileFieldValueWithAudit(userId, fieldId, { value: num }, req.organizationId!, changedBy);
|
||
res.json({ success: true, fieldValue: updated });
|
||
} else {
|
||
const created = await storage.createUserProfileFieldValueWithAudit({ userId, fieldId, value: num }, req.organizationId!, changedBy);
|
||
res.json({ success: true, fieldValue: created });
|
||
}
|
||
} else {
|
||
if (existing) {
|
||
const updated = await storage.updateUserProfileFieldValueWithAudit(userId, fieldId, { value }, req.organizationId!, changedBy);
|
||
res.json({ success: true, fieldValue: updated });
|
||
} else {
|
||
const created = await storage.createUserProfileFieldValueWithAudit({ userId, fieldId, value }, req.organizationId!, changedBy);
|
||
res.json({ success: true, fieldValue: created });
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Patch user profile field value error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при обновлении значения поля' });
|
||
}
|
||
}
|
||
);
|
||
}
|