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

@@ -128,7 +128,7 @@ if (!tbl) {
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL UNIQUE,
"password_hash" TEXT,
"role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')),
"role" TEXT NOT NULL DEFAULT 'after_sales' CHECK ("role" IN ('admin', 'viewer', 'after_sales')),
"source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')),
"ldap_dn" TEXT,
"firstname" TEXT,
@@ -149,6 +149,65 @@ if (!userCols.some((c) => c.name === 'lastname')) {
db.exec('ALTER TABLE users ADD COLUMN lastname TEXT');
}
const usersTableSql = db
.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='users'",
)
.get()?.sql;
if (usersTableSql && !usersTableSql.includes('after_sales')) {
db.exec('BEGIN');
try {
db.exec(`
CREATE TABLE "users_new" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL UNIQUE,
"password_hash" TEXT,
"role" TEXT NOT NULL DEFAULT 'after_sales' CHECK ("role" IN ('admin', 'viewer', 'after_sales')),
"source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')),
"ldap_dn" TEXT,
"firstname" TEXT,
"lastname" TEXT,
"active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)),
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
"updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
db.exec(`
INSERT INTO users_new (
id, username, password_hash, role, source, ldap_dn, firstname, lastname, active, created_at, updated_at
)
SELECT
id,
username,
password_hash,
CASE
WHEN role = 'user' THEN 'after_sales'
WHEN role IN ('admin', 'viewer', 'after_sales') THEN role
ELSE 'after_sales'
END,
source,
ldap_dn,
firstname,
lastname,
active,
created_at,
updated_at
FROM users;
`);
db.exec('DROP TABLE users');
db.exec('ALTER TABLE users_new RENAME TO users');
db.exec('CREATE INDEX IF NOT EXISTS users_username_idx ON "users" ("username")');
db.exec('COMMIT');
} catch (e) {
try {
db.exec('ROLLBACK');
} catch {
/* ignore */
}
throw e;
}
}
const tblSet = db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'",
@@ -276,4 +335,14 @@ if (ticketCols2.some((c) => c.name === 'sla_anchor_at')) {
).run();
}
const ticketCols3 = db.prepare('PRAGMA table_info(tickets)').all();
if (!ticketCols3.some((c) => c.name === 'assigned_user_id')) {
db.exec(
'ALTER TABLE tickets ADD COLUMN assigned_user_id TEXT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE',
);
db.exec(
'CREATE INDEX IF NOT EXISTS tickets_assigned_user_id_idx ON tickets (assigned_user_id)',
);
}
export default db;

View File

@@ -198,7 +198,7 @@ export async function performLdapSync(dbSync, loadIntegrations, trigger) {
dbSync
.prepare(
`INSERT INTO users (id, username, password_hash, role, source, ldap_dn, firstname, lastname, active, updated_at)
VALUES (?, ?, ?, 'user', 'ldap', ?, ?, ?, 1, datetime('now'))`,
VALUES (?, ?, ?, 'after_sales', 'ldap', ?, ?, ?, 1, datetime('now'))`,
)
.run(
id,

View File

@@ -45,10 +45,29 @@ export function mapTicket(r) {
createdAt: r.created_at,
/** Letzte Änderung: neueres aus Ticket-Zeile oder letztem Event (für Anzeige „Aktualisiert“). */
updatedAt: r.ticket_last_activity_at ?? r.updated_at,
assignedTo: null,
};
if (machine_row) {
t.machine = mapMachine(machine_row);
}
if (r.assignee_row != null && String(r.assignee_row).trim() !== '') {
try {
const ar =
typeof r.assignee_row === 'string'
? JSON.parse(r.assignee_row)
: r.assignee_row;
if (ar && ar.id) {
t.assignedTo = {
id: ar.id,
username: ar.username,
firstName: ar.firstname ?? null,
lastName: ar.lastname ?? null,
};
}
} catch {
/* ignore */
}
}
return t;
}
@@ -135,6 +154,14 @@ export const ticketJoinSelect = `
'extras', m.extras,
'created_at', m.created_at,
'updated_at', m.updated_at
) AS machine_row
) AS machine_row,
CASE WHEN u_assign.id IS NULL THEN NULL ELSE
json_object(
'id', u_assign.id,
'username', u_assign.username,
'firstname', u_assign.firstname,
'lastname', u_assign.lastname
) END AS assignee_row
FROM tickets t
JOIN machines m ON m.id = t.machine_id`;
JOIN machines m ON m.id = t.machine_id
LEFT JOIN users u_assign ON u_assign.id = t.assigned_user_id`;

View File

@@ -20,3 +20,12 @@ export function requireAdmin(req, res, next) {
}
next();
}
/** Maschinen, Tickets, Events, Anhänge bearbeiten (nicht: nur Viewer). */
export function requireCrmEdit(req, res, next) {
const r = req.session?.role;
if (r === 'admin' || r === 'after_sales') {
return next();
}
return res.status(403).json({ message: 'Keine Bearbeitungsrechte.' });
}

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,
};
}