Benutzer, Ticketzuweißungen

This commit is contained in:
2026-03-23 03:12:08 +01:00
parent e75a2e5e20
commit 08391cdb6c
29 changed files with 592 additions and 111 deletions

View File

@@ -12,6 +12,13 @@ import {
import { hashPassword } from '../../password.js';
import { requireAdmin, requireAuth } from '../../middleware/auth.js';
const CRM_ROLES = new Set(['admin', 'viewer', 'after_sales']);
function normalizeUserRole(role) {
const r = String(role || '').trim();
return CRM_ROLES.has(r) ? r : 'after_sales';
}
export function createAdminRouter() {
const admin = Router();
admin.use(requireAuth, requireAdmin);
@@ -31,7 +38,7 @@ export function createAdminRouter() {
.trim()
.toLowerCase();
if (!un || !password) return badRequest(res, 'Benutzername und Passwort erforderlich.');
const r = role === 'admin' ? 'admin' : 'user';
const r = normalizeUserRole(role);
const id = randomUUID();
const ph = await hashPassword(password);
try {
@@ -66,7 +73,7 @@ export function createAdminRouter() {
).run(ph, id);
}
if (b.role !== undefined) {
if (b.role !== 'admin' && b.role !== 'user') {
if (!CRM_ROLES.has(b.role)) {
return badRequest(res, 'Ungültige Rolle.');
}
const admins = db
@@ -74,7 +81,7 @@ export function createAdminRouter() {
"SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1",
)
.get().c;
if (cur.role === 'admin' && b.role === 'user' && admins <= 1) {
if (cur.role === 'admin' && b.role !== 'admin' && admins <= 1) {
return res.status(400).json({ message: 'Letzter Administrator kann nicht herabgestuft werden.' });
}
db.prepare('UPDATE users SET role = ?, updated_at = datetime(\'now\') WHERE id = ?').run(

View File

@@ -0,0 +1,27 @@
import db from '../../db.js';
function mapAssignable(r) {
return {
id: r.id,
username: r.username,
firstName: r.firstname ?? null,
lastName: r.lastname ?? null,
};
}
/** Aktive CRM-Benutzer für Ticket-Zuweisung (Dropdown). */
export function registerAssignableUserRoutes(api) {
api.get('/assignable-users', (_req, res) => {
const rows = db
.prepare(
`SELECT id, username, firstname, lastname FROM users
WHERE active = 1
ORDER BY
COALESCE(lastname, '') COLLATE NOCASE ASC,
COALESCE(firstname, '') COLLATE NOCASE ASC,
username COLLATE NOCASE ASC`,
)
.all();
res.json(rows.map(mapAssignable));
});
}

View File

@@ -5,6 +5,7 @@ import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
import multer from 'multer';
import db from '../../db.js';
import { requireCrmEdit } from '../../middleware/auth.js';
import { badRequest, UUID } from '../../lib/http.js';
import { mapEvent } from '../../lib/mappers.js';
@@ -106,6 +107,7 @@ function discardIncomingFiles(files) {
export function registerAttachmentRoutes(api) {
api.post(
'/tickets/:ticketId/events/attachments',
requireCrmEdit,
uploadMiddleware,
async (req, res) => {
const { ticketId } = req.params;

View File

@@ -1,5 +1,6 @@
import { randomUUID } from 'crypto';
import db from '../../db.js';
import { requireCrmEdit } from '../../middleware/auth.js';
import { badRequest } from '../../lib/http.js';
import { mapEvent } from '../../lib/mappers.js';
import { computeRemoteDurationSeconds } from '../../teamviewer.js';
@@ -7,7 +8,7 @@ import { computeRemoteDurationSeconds } from '../../teamviewer.js';
const EVENT_TYPES_USER = new Set(['NOTE', 'CALL', 'REMOTE', 'PART']);
export function registerEventRoutes(api) {
api.post('/events', (req, res) => {
api.post('/events', requireCrmEdit, (req, res) => {
const b = req.body || {};
const ticketId = b.ticketId;
const type = b.type;

View File

@@ -5,12 +5,14 @@ import { registerTeamViewerRoutes } from '../../teamviewer.js';
import { registerAttachmentRoutes } from './attachments.js';
import { registerEventRoutes } from './events.js';
import { registerMachineRoutes } from './machines.js';
import { registerAssignableUserRoutes } from './assignable-users.js';
import { registerTicketRoutes } from './tickets.js';
export function createApiRouter() {
const api = Router();
api.use(requireAuth);
registerMachineRoutes(api);
registerAssignableUserRoutes(api);
registerTicketRoutes(api);
registerAttachmentRoutes(api);
registerTeamViewerRoutes(api, loadIntegrations);

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
import db from '../../db.js';
import { badRequest, UUID } from '../../lib/http.js';
import { mapMachine } from '../../lib/mappers.js';
import { requireCrmEdit } from '../../middleware/auth.js';
const ALLOWED_LIST_STATUS = new Set([
'',
@@ -49,7 +50,7 @@ export function registerMachineRoutes(api) {
res.json(rows.map(mapMachine));
});
api.post('/machines', (req, res) => {
api.post('/machines', requireCrmEdit, (req, res) => {
const b = req.body || {};
const { name, typ, seriennummer, standort, listStatus } = b;
if (!name || !typ || !seriennummer || !standort) {
@@ -81,7 +82,7 @@ export function registerMachineRoutes(api) {
res.json(mapMachine(row));
});
api.put('/machines/:id', (req, res) => {
api.put('/machines/:id', requireCrmEdit, (req, res) => {
const { id } = req.params;
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
@@ -122,7 +123,7 @@ export function registerMachineRoutes(api) {
res.json(mapMachine(row));
});
api.delete('/machines/:id', (req, res) => {
api.delete('/machines/:id', requireCrmEdit, (req, res) => {
const { id } = req.params;
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);

View File

@@ -1,5 +1,6 @@
import { randomUUID } from 'crypto';
import db from '../../db.js';
import { requireCrmEdit } from '../../middleware/auth.js';
import { badRequest, UUID } from '../../lib/http.js';
import { mergeAttachmentEventsForApi } from '../../lib/ticket-events-merge.js';
import {
@@ -36,9 +37,15 @@ ORDER BY
CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN ${ticketSlaDueExpr} ELSE '9999-12-31' END ASC,
${ticketLastActivityExpr} DESC`;
function userLabelFromRow(row) {
if (!row) return '—';
const fn = [row.firstname, row.lastname].filter(Boolean).join(' ').trim();
return fn || row.username || row.id;
}
export function registerTicketRoutes(api) {
api.get('/tickets', (req, res) => {
const { status, priority, machineId, open } = req.query;
const { status, priority, machineId, open, assignedTo } = req.query;
const cond = ['1=1'];
const params = [];
const openFilter = open === '1' || open === 'true';
@@ -56,12 +63,21 @@ export function registerTicketRoutes(api) {
cond.push('t.machine_id = ?');
params.push(machineId);
}
if (assignedTo === 'me' && req.session?.userId) {
cond.push('t.assigned_user_id = ?');
params.push(req.session.userId);
} else if (assignedTo === 'not_me' && req.session?.userId) {
cond.push(
'(t.assigned_user_id IS NULL OR t.assigned_user_id <> ?)',
);
params.push(req.session.userId);
}
const sql = `${ticketJoinSelect} WHERE ${cond.join(' AND ')} ${ticketListOrderBy}`;
const rows = db.prepare(sql).all(...params);
res.json(rows.map(mapTicket));
});
api.post('/tickets', (req, res) => {
api.post('/tickets', requireCrmEdit, (req, res) => {
const { machineId, title, description, status, priority, slaDays } =
req.body || {};
if (!machineId || !title || !description) {
@@ -125,7 +141,7 @@ export function registerTicketRoutes(api) {
res.json(mapTicket(row));
});
api.put('/tickets/:id', (req, res) => {
api.put('/tickets/:id', requireCrmEdit, (req, res) => {
const { id } = req.params;
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
const cur = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
@@ -173,6 +189,46 @@ export function registerTicketRoutes(api) {
`Fälligkeit: ${label(cur.sla_days)}${label(nextSlaDays)}`,
);
}
let nextAssignedUserId = cur.assigned_user_id ?? null;
if (Object.prototype.hasOwnProperty.call(b, 'assignedUserId')) {
const raw = b.assignedUserId;
if (raw === null || raw === undefined || raw === '') {
nextAssignedUserId = null;
} else if (!UUID.test(String(raw))) {
return badRequest(res, 'assignedUserId ungültig.');
} else {
const u = db
.prepare(
'SELECT id, username, firstname, lastname FROM users WHERE id = ? AND active = 1',
)
.get(String(raw));
if (!u) {
return badRequest(res, 'Zugewiesener Benutzer nicht gefunden oder inaktiv.');
}
nextAssignedUserId = u.id;
}
}
if (nextAssignedUserId !== (cur.assigned_user_id ?? null)) {
const prevU = cur.assigned_user_id
? db
.prepare(
'SELECT id, username, firstname, lastname FROM users WHERE id = ?',
)
.get(cur.assigned_user_id)
: null;
const nextU = nextAssignedUserId
? db
.prepare(
'SELECT id, username, firstname, lastname FROM users WHERE id = ?',
)
.get(nextAssignedUserId)
: null;
const fromLabel = prevU ? userLabelFromRow(prevU) : 'nicht zugewiesen';
const toLabel = nextU ? userLabelFromRow(nextU) : 'nicht zugewiesen';
lines.push(`Zuweisung: ${fromLabel}${toLabel}`);
}
if (lines.length > 0) {
const eid = randomUUID();
db.prepare(
@@ -184,6 +240,7 @@ export function registerTicketRoutes(api) {
db.prepare(
`UPDATE tickets SET title = ?, description = ?, status = ?, priority = ?, sla_days = ?,
sla_anchor_at = CASE WHEN ? THEN datetime('now') ELSE sla_anchor_at END,
assigned_user_id = ?,
updated_at = datetime('now')
WHERE id = ?`,
).run(
@@ -193,6 +250,7 @@ export function registerTicketRoutes(api) {
next.priority,
nextSlaDays,
resetSlaAnchor ? 1 : 0,
nextAssignedUserId,
id,
);

View File

@@ -10,12 +10,17 @@ const router = Router();
function userPayload(row) {
if (!row) return null;
const role = row.role;
const canAdmin = role === 'admin';
const canEditCrm = canAdmin || role === 'after_sales';
return {
id: row.id,
username: row.username,
role: row.role,
role,
firstName: row.firstname ?? null,
lastName: row.lastname ?? null,
canAdmin,
canEditCrm,
};
}