rework issue system

This commit is contained in:
Matthias Hochmeister
2026-03-24 14:21:17 +01:00
parent abb337c683
commit 6c7531438e
9 changed files with 1260 additions and 189 deletions

View File

@@ -11,7 +11,34 @@ class IssueController {
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
const issues = await issueService.getIssues(userId, canViewAll);
// Parse filter query params
const filters: {
typ_id?: number[];
prioritaet?: string[];
status?: string[];
erstellt_von?: string;
zugewiesen_an?: string;
} = {};
if (req.query.typ_id) {
filters.typ_id = String(req.query.typ_id).split(',').map(Number).filter((n) => !isNaN(n));
}
if (req.query.prioritaet) {
filters.prioritaet = String(req.query.prioritaet).split(',');
}
if (req.query.status) {
filters.status = String(req.query.status).split(',');
}
if (req.query.erstellt_von) {
filters.erstellt_von = req.query.erstellt_von as string;
}
if (req.query.zugewiesen_an) {
filters.zugewiesen_an =
req.query.zugewiesen_an === 'me' ? userId : (req.query.zugewiesen_an as string);
}
const issues = await issueService.getIssues({ userId, canViewAll, filters });
res.status(200).json({ success: true, data: issues });
} catch (error) {
logger.error('IssueController.getIssues error', { error });
@@ -34,7 +61,7 @@ class IssueController {
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
if (!canViewAll && issue.erstellt_von !== userId) {
if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) {
res.status(403).json({ success: false, message: 'Kein Zugriff' });
return;
}
@@ -69,7 +96,8 @@ class IssueController {
try {
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canManage = permissionService.hasPermission(groups, 'issues:manage');
const canEdit = permissionService.hasPermission(groups, 'issues:edit');
const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status');
const existing = await issueService.getIssueById(id);
if (!existing) {
@@ -78,19 +106,80 @@ class IssueController {
}
const isOwner = existing.erstellt_von === userId;
if (!canManage && !isOwner) {
const isAssignee = existing.zugewiesen_an === userId;
// Determine what update data is allowed
let updateData: Record<string, any>;
if (canEdit) {
// Full edit access
updateData = { ...req.body };
// Explicit null for unassign is handled by 'zugewiesen_an' in data check in service
} else if (canChangeStatus || isAssignee) {
// Can only change status (+ kommentar is handled separately)
updateData = {};
if (req.body.status !== undefined) updateData.status = req.body.status;
} else if (isOwner) {
// Owner without change_status: can only close own issue or reopen from erledigt
updateData = {};
if (req.body.status !== undefined) {
const newStatus = req.body.status;
if (newStatus === 'erledigt') {
updateData.status = 'erledigt';
} else if (newStatus === 'offen' && existing.status === 'erledigt') {
// Reopen: require kommentar
if (!req.body.kommentar || typeof req.body.kommentar !== 'string' || req.body.kommentar.trim().length === 0) {
res.status(400).json({
success: false,
message: 'Beim Wiedereröffnen ist ein Kommentar erforderlich',
});
return;
}
updateData.status = 'offen';
} else {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Statusänderung' });
return;
}
} else {
// Owner trying to change non-status fields without edit permission
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
} else {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
// Owners without manage permission can only change status
const updateData = canManage ? req.body : { status: req.body.status };
// Validate: if setting status to 'abgelehnt', check if type allows it
if (updateData.status === 'abgelehnt' && existing.typ_id) {
if (!existing.typ_erlaubt_abgelehnt) {
res.status(400).json({
success: false,
message: 'Dieser Issue-Typ erlaubt den Status "Abgelehnt" nicht',
});
return;
}
}
const issue = await issueService.updateIssue(id, updateData);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: issue });
// Handle reopen comment (owner reopen flow)
if (isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt' && req.body.kommentar) {
await issueService.addComment(id, userId, `[Wiedereröffnet] ${req.body.kommentar.trim()}`);
}
// If kommentar was provided alongside a status change (not the reopen flow above)
if (req.body.kommentar && updateData.status && !(isOwner && !canChangeStatus && req.body.status === 'offen' && existing.status === 'erledigt')) {
await issueService.addComment(id, userId, req.body.kommentar.trim());
}
// Re-fetch to include any new comment info
const updated = await issueService.getIssueById(id);
res.status(200).json({ success: true, data: updated });
} catch (error) {
logger.error('IssueController.updateIssue error', { error });
res.status(500).json({ success: false, message: 'Issue konnte nicht aktualisiert werden' });
@@ -111,8 +200,8 @@ class IssueController {
}
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canManage = permissionService.hasPermission(groups, 'issues:manage');
if (!canManage && issue.erstellt_von !== userId) {
const canDelete = permissionService.hasPermission(groups, 'issues:delete');
if (!canDelete && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
@@ -139,7 +228,7 @@ class IssueController {
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
if (!canViewAll && issue.erstellt_von !== userId) {
if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) {
res.status(403).json({ success: false, message: 'Kein Zugriff' });
return;
}
@@ -170,12 +259,17 @@ class IssueController {
}
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
const canManage = permissionService.hasPermission(groups, 'issues:manage');
if (!canViewAll && !canManage && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Kein Zugriff' });
const isOwner = issue.erstellt_von === userId;
const isAssignee = issue.zugewiesen_an === userId;
const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status');
const canEdit = permissionService.hasPermission(groups, 'issues:edit');
// Authorization: owner, assignee, change_status, or edit can comment
if (!isOwner && !isAssignee && !canChangeStatus && !canEdit) {
res.status(403).json({ success: false, message: 'Keine Berechtigung zum Kommentieren' });
return;
}
const comment = await issueService.addComment(issueId, userId, inhalt.trim());
res.status(201).json({ success: true, data: comment });
} catch (error) {
@@ -183,6 +277,81 @@ class IssueController {
res.status(500).json({ success: false, message: 'Kommentar konnte nicht erstellt werden' });
}
}
// --- Type management ---
async getTypes(_req: Request, res: Response): Promise<void> {
try {
const types = await issueService.getTypes();
res.status(200).json({ success: true, data: types });
} catch (error) {
logger.error('IssueController.getTypes error', { error });
res.status(500).json({ success: false, message: 'Issue-Typen konnten nicht geladen werden' });
}
}
async createType(req: Request, res: Response): Promise<void> {
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
try {
const type = await issueService.createType(req.body);
res.status(201).json({ success: true, data: type });
} catch (error) {
logger.error('IssueController.createType error', { error });
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht erstellt werden' });
}
}
async updateType(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const type = await issueService.updateType(id, req.body);
if (!type) {
res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: type });
} catch (error) {
logger.error('IssueController.updateType error', { error });
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht aktualisiert werden' });
}
}
async deleteType(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const type = await issueService.deactivateType(id);
if (!type) {
res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: type });
} catch (error) {
logger.error('IssueController.deleteType error', { error });
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht deaktiviert werden' });
}
}
async getMembers(_req: Request, res: Response): Promise<void> {
try {
const members = await issueService.getAssignableMembers();
res.status(200).json({ success: true, data: members });
} catch (error) {
logger.error('IssueController.getMembers error', { error });
res.status(500).json({ success: false, message: 'Mitglieder konnten nicht geladen werden' });
}
}
}
export default new IssueController();

View File

@@ -0,0 +1,128 @@
-- Migration 053: Issues rework
-- 1. Fix update trigger bug (uses wrong column name)
-- 2. Dynamic issue types table (issue_typen)
-- 3. Migrate issues.typ → issues.typ_id
-- 4. Permission rework: replace issues:manage with granular permissions
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Fix update trigger
-- The old trigger calls update_aktualisiert_am() which sets NEW.aktualisiert_am,
-- but issues table uses updated_at → crashes every UPDATE.
-- ═══════════════════════════════════════════════════════════════════════════
DROP TRIGGER IF EXISTS trg_issues_updated ON issues;
CREATE OR REPLACE FUNCTION update_issues_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_issues_updated BEFORE UPDATE ON issues
FOR EACH ROW EXECUTE FUNCTION update_issues_updated_at();
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Dynamic types table
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS issue_typen (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INT REFERENCES issue_typen(id) ON DELETE CASCADE,
icon VARCHAR(50) DEFAULT NULL,
farbe VARCHAR(20) DEFAULT NULL,
erlaubt_abgelehnt BOOLEAN NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
aktiv BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_issue_typen_parent ON issue_typen(parent_id);
-- Seed default types
INSERT INTO issue_typen (id, name, parent_id, icon, farbe, erlaubt_abgelehnt, sort_order) VALUES
(1, 'Bug', NULL, 'BugReport', 'error', false, 1),
(2, 'Funktionsanfrage', NULL, 'FiberNew', 'info', true, 2),
(3, 'Sonstiges', NULL, 'HelpOutline', 'action', true, 3)
ON CONFLICT (id) DO NOTHING;
-- Ensure sequence is past seeded IDs
SELECT setval('issue_typen_id_seq', GREATEST((SELECT MAX(id) FROM issue_typen), 3));
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Migrate issues.typ column → typ_id FK
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE issues ADD COLUMN IF NOT EXISTS typ_id INT REFERENCES issue_typen(id) ON DELETE SET NULL;
-- Migrate existing data
UPDATE issues SET typ_id = 1 WHERE typ = 'bug' AND typ_id IS NULL;
UPDATE issues SET typ_id = 2 WHERE typ = 'feature' AND typ_id IS NULL;
UPDATE issues SET typ_id = 3 WHERE typ = 'sonstiges' AND typ_id IS NULL;
-- Fallback: anything unmapped → Sonstiges
UPDATE issues SET typ_id = 3 WHERE typ_id IS NULL;
-- Drop old constraint and column
ALTER TABLE issues DROP CONSTRAINT IF EXISTS issues_typ_check;
ALTER TABLE issues DROP COLUMN IF EXISTS typ;
CREATE INDEX IF NOT EXISTS idx_issues_typ_id ON issues(typ_id);
CREATE INDEX IF NOT EXISTS idx_issues_zugewiesen_an ON issues(zugewiesen_an);
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Permission rework
-- Replace issues:manage with granular permissions
-- ═══════════════════════════════════════════════════════════════════════════
-- 4a. Find all groups that had issues:manage and give them the new permissions
-- We use a temp table to store the groups that had manage
CREATE TEMP TABLE IF NOT EXISTS _issues_manage_groups AS
SELECT authentik_group FROM group_permissions WHERE permission_id = 'issues:manage';
-- 4b. Insert new permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('issues:change_status', 'issues', 'Status ändern', 'Status ändern und kommentieren', 4),
('issues:edit', 'issues', 'Bearbeiten', 'Issues bearbeiten (Titel, Beschreibung, Typ, Priorität, Zuweisung)', 5),
('issues:edit_settings', 'issues', 'Einstellungen', 'Issue-Kategorien verwalten', 6),
('issues:delete', 'issues', 'Löschen', 'Issues löschen', 7)
ON CONFLICT (id) DO NOTHING;
-- 4c. Grant all new permissions to groups that had manage
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT g.authentik_group, p.id
FROM _issues_manage_groups g
CROSS JOIN (VALUES
('issues:view_all'),
('issues:change_status'),
('issues:edit'),
('issues:edit_settings'),
('issues:delete')
) AS p(id)
ON CONFLICT DO NOTHING;
-- 4d. Remove old manage permission
DELETE FROM group_permissions WHERE permission_id = 'issues:manage';
DELETE FROM permissions WHERE id = 'issues:manage';
DROP TABLE IF EXISTS _issues_manage_groups;
-- 4e. Update permission_deps in app_settings JSON
-- Remove old issues entries and add new ones
UPDATE app_settings
SET value = (
SELECT jsonb_strip_nulls(
(value::jsonb - 'issues:view_own' - 'issues:view_all' - 'issues:manage')
|| '{
"issues:create": ["issues:view_own"],
"issues:view_all": ["issues:view_own"],
"issues:change_status": ["issues:view_all"],
"issues:edit": ["issues:view_all"],
"issues:delete": ["issues:view_all"],
"issues:edit_settings": ["issues:view_all"]
}'::jsonb
)::text
FROM app_settings WHERE key = 'permission_deps'
)
WHERE key = 'permission_deps';

View File

@@ -5,6 +5,42 @@ import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
// --- Type management routes (BEFORE /:id to avoid conflict) ---
router.get(
'/typen',
authenticate,
issueController.getTypes.bind(issueController)
);
router.post(
'/typen',
authenticate,
requirePermission('issues:edit_settings'),
issueController.createType.bind(issueController)
);
router.patch(
'/typen/:id',
authenticate,
requirePermission('issues:edit_settings'),
issueController.updateType.bind(issueController)
);
router.delete(
'/typen/:id',
authenticate,
requirePermission('issues:edit_settings'),
issueController.deleteType.bind(issueController)
);
// --- Members route ---
router.get(
'/members',
authenticate,
issueController.getMembers.bind(issueController)
);
// --- Issue CRUD ---
router.get(
'/',
authenticate,
@@ -30,6 +66,12 @@ router.post(
issueController.addComment.bind(issueController)
);
router.get(
'/:id',
authenticate,
issueController.getIssue.bind(issueController)
);
router.patch(
'/:id',
authenticate,

View File

@@ -1,21 +1,76 @@
import pool from '../config/database';
import logger from '../utils/logger';
async function getIssues(userId: string, canViewAll: boolean) {
interface IssueFilters {
typ_id?: number[];
prioritaet?: string[];
status?: string[];
erstellt_von?: string;
zugewiesen_an?: string;
}
interface GetIssuesParams {
userId: string;
canViewAll: boolean;
filters?: IssueFilters;
}
const BASE_SELECT = `
SELECT i.*,
it.name AS typ_name, it.icon AS typ_icon, it.farbe AS typ_farbe, it.erlaubt_abgelehnt AS typ_erlaubt_abgelehnt,
u1.name AS erstellt_von_name,
u2.name AS zugewiesen_an_name
FROM issues i
LEFT JOIN issue_typen it ON it.id = i.typ_id
LEFT JOIN users u1 ON u1.id = i.erstellt_von
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
`;
async function getIssues({ userId, canViewAll, filters }: GetIssuesParams) {
try {
const query = `
SELECT i.*,
u1.name AS erstellt_von_name,
u2.name AS zugewiesen_an_name
FROM issues i
LEFT JOIN users u1 ON u1.id = i.erstellt_von
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
${canViewAll ? '' : 'WHERE i.erstellt_von = $1'}
ORDER BY i.created_at DESC
`;
const result = canViewAll
? await pool.query(query)
: await pool.query(query, [userId]);
const conditions: string[] = [];
const values: any[] = [];
let idx = 1;
if (!canViewAll) {
conditions.push(`(i.erstellt_von = $${idx} OR i.zugewiesen_an = $${idx})`);
values.push(userId);
idx++;
}
if (filters?.typ_id && filters.typ_id.length > 0) {
conditions.push(`i.typ_id = ANY($${idx}::int[])`);
values.push(filters.typ_id);
idx++;
}
if (filters?.prioritaet && filters.prioritaet.length > 0) {
conditions.push(`i.prioritaet = ANY($${idx}::text[])`);
values.push(filters.prioritaet);
idx++;
}
if (filters?.status && filters.status.length > 0) {
conditions.push(`i.status = ANY($${idx}::text[])`);
values.push(filters.status);
idx++;
}
if (filters?.erstellt_von) {
conditions.push(`i.erstellt_von = $${idx}`);
values.push(filters.erstellt_von);
idx++;
}
if (filters?.zugewiesen_an) {
conditions.push(`i.zugewiesen_an = $${idx}`);
values.push(filters.zugewiesen_an);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const query = `${BASE_SELECT} ${where} ORDER BY i.created_at DESC`;
const result = await pool.query(query, values);
return result.rows;
} catch (error) {
logger.error('IssueService.getIssues failed', { error });
@@ -26,13 +81,7 @@ async function getIssues(userId: string, canViewAll: boolean) {
async function getIssueById(id: number) {
try {
const result = await pool.query(
`SELECT i.*,
u1.name AS erstellt_von_name,
u2.name AS zugewiesen_an_name
FROM issues i
LEFT JOIN users u1 ON u1.id = i.erstellt_von
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
WHERE i.id = $1`,
`${BASE_SELECT} WHERE i.id = $1`,
[id]
);
return result.rows[0] || null;
@@ -43,63 +92,96 @@ async function getIssueById(id: number) {
}
async function createIssue(
data: { titel: string; beschreibung?: string; typ?: string; prioritaet?: string },
data: { titel: string; beschreibung?: string; typ_id?: number; prioritaet?: string },
userId: string
) {
try {
const result = await pool.query(
`INSERT INTO issues (titel, beschreibung, typ, prioritaet, erstellt_von)
`INSERT INTO issues (titel, beschreibung, typ_id, prioritaet, erstellt_von)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[
data.titel,
data.beschreibung || null,
data.typ || 'sonstiges',
data.typ_id || 3,
data.prioritaet || 'mittel',
userId,
]
);
return result.rows[0];
return getIssueById(result.rows[0].id);
} catch (error) {
logger.error('IssueService.createIssue failed', { error });
throw new Error('Issue konnte nicht erstellt werden');
}
}
// Sentinel value to explicitly set zugewiesen_an to NULL (not used currently but kept for reference)
const UNASSIGN = '__UNASSIGN__';
async function updateIssue(
id: number,
data: {
titel?: string;
beschreibung?: string;
typ?: string;
typ_id?: number;
prioritaet?: string;
status?: string;
zugewiesen_an?: string | null;
}
) {
try {
const result = await pool.query(
`UPDATE issues
SET titel = COALESCE($1, titel),
beschreibung = COALESCE($2, beschreibung),
typ = COALESCE($3, typ),
prioritaet = COALESCE($4, prioritaet),
status = COALESCE($5, status),
zugewiesen_an = COALESCE($6, zugewiesen_an),
updated_at = NOW()
WHERE id = $7
RETURNING *`,
[
data.titel,
data.beschreibung,
data.typ,
data.prioritaet,
data.status,
data.zugewiesen_an,
id,
]
);
return result.rows[0] || null;
const setClauses: string[] = [];
const values: any[] = [];
let idx = 1;
if (data.titel !== undefined) {
setClauses.push(`titel = $${idx}`);
values.push(data.titel);
idx++;
}
if (data.beschreibung !== undefined) {
setClauses.push(`beschreibung = $${idx}`);
values.push(data.beschreibung);
idx++;
}
if (data.typ_id !== undefined) {
setClauses.push(`typ_id = $${idx}`);
values.push(data.typ_id);
idx++;
}
if (data.prioritaet !== undefined) {
setClauses.push(`prioritaet = $${idx}`);
values.push(data.prioritaet);
idx++;
}
if (data.status !== undefined) {
setClauses.push(`status = $${idx}`);
values.push(data.status);
idx++;
}
if ('zugewiesen_an' in data) {
setClauses.push(`zugewiesen_an = $${idx}`);
values.push(data.zugewiesen_an ?? null);
idx++;
}
if (setClauses.length === 0) {
return getIssueById(id);
}
setClauses.push(`updated_at = NOW()`);
values.push(id);
const query = `
UPDATE issues
SET ${setClauses.join(', ')}
WHERE id = $${idx}
RETURNING id
`;
const result = await pool.query(query, values);
if (result.rows.length === 0) return null;
return getIssueById(id);
} catch (error) {
logger.error('IssueService.updateIssue failed', { error, id });
throw new Error('Issue konnte nicht aktualisiert werden');
@@ -151,6 +233,134 @@ async function addComment(issueId: number, autorId: string, inhalt: string) {
}
}
async function getTypes() {
try {
const result = await pool.query(
`SELECT * FROM issue_typen WHERE aktiv = true ORDER BY sort_order ASC, id ASC`
);
return result.rows;
} catch (error) {
logger.error('IssueService.getTypes failed', { error });
throw new Error('Issue-Typen konnten nicht geladen werden');
}
}
async function createType(data: {
name: string;
parent_id?: number | null;
icon?: string | null;
farbe?: string | null;
erlaubt_abgelehnt?: boolean;
sort_order?: number;
}) {
try {
const result = await pool.query(
`INSERT INTO issue_typen (name, parent_id, icon, farbe, erlaubt_abgelehnt, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
data.name,
data.parent_id ?? null,
data.icon ?? null,
data.farbe ?? null,
data.erlaubt_abgelehnt ?? true,
data.sort_order ?? 0,
]
);
return result.rows[0];
} catch (error) {
logger.error('IssueService.createType failed', { error });
throw new Error('Issue-Typ konnte nicht erstellt werden');
}
}
async function updateType(
id: number,
data: {
name?: string;
parent_id?: number | null;
icon?: string | null;
farbe?: string | null;
erlaubt_abgelehnt?: boolean;
sort_order?: number;
}
) {
try {
const setClauses: string[] = [];
const values: any[] = [];
let idx = 1;
if (data.name !== undefined) {
setClauses.push(`name = $${idx}`);
values.push(data.name);
idx++;
}
if ('parent_id' in data) {
setClauses.push(`parent_id = $${idx}`);
values.push(data.parent_id);
idx++;
}
if ('icon' in data) {
setClauses.push(`icon = $${idx}`);
values.push(data.icon);
idx++;
}
if ('farbe' in data) {
setClauses.push(`farbe = $${idx}`);
values.push(data.farbe);
idx++;
}
if (data.erlaubt_abgelehnt !== undefined) {
setClauses.push(`erlaubt_abgelehnt = $${idx}`);
values.push(data.erlaubt_abgelehnt);
idx++;
}
if (data.sort_order !== undefined) {
setClauses.push(`sort_order = $${idx}`);
values.push(data.sort_order);
idx++;
}
if (setClauses.length === 0) {
const result = await pool.query(`SELECT * FROM issue_typen WHERE id = $1`, [id]);
return result.rows[0] || null;
}
values.push(id);
const query = `UPDATE issue_typen SET ${setClauses.join(', ')} WHERE id = $${idx} RETURNING *`;
const result = await pool.query(query, values);
return result.rows[0] || null;
} catch (error) {
logger.error('IssueService.updateType failed', { error, id });
throw new Error('Issue-Typ konnte nicht aktualisiert werden');
}
}
async function deactivateType(id: number) {
try {
const result = await pool.query(
`UPDATE issue_typen SET aktiv = false WHERE id = $1 RETURNING *`,
[id]
);
return result.rows[0] || null;
} catch (error) {
logger.error('IssueService.deactivateType failed', { error, id });
throw new Error('Issue-Typ konnte nicht deaktiviert werden');
}
}
async function getAssignableMembers() {
try {
const result = await pool.query(
`SELECT id, name FROM users WHERE id IS NOT NULL ORDER BY name`
);
return result.rows;
} catch (error) {
logger.error('IssueService.getAssignableMembers failed', { error });
throw new Error('Mitglieder konnten nicht geladen werden');
}
}
export default {
getIssues,
getIssueById,
@@ -159,4 +369,10 @@ export default {
deleteIssue,
getComments,
addComment,
getTypes,
createType,
updateType,
deactivateType,
getAssignableMembers,
UNASSIGN,
};