Benutzer, Ticketzuweißungen
This commit is contained in:
71
server/db.js
71
server/db.js
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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.' });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
27
server/routes/api/assignable-users.js
Normal file
27
server/routes/api/assignable-users.js
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user