rework issue system
This commit is contained in:
@@ -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();
|
||||
|
||||
128
backend/src/database/migrations/053_issues_rework.sql
Normal file
128
backend/src/database/migrations/053_issues_rework.sql
Normal 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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user