Files
stackpulse/backend/groups/index.js
T
2025-10-29 15:38:25 +00:00

343 lines
9.7 KiB
JavaScript

import { db } from '../db/index.js';
import {
normalizeAvatarColor,
DEFAULT_AVATAR_COLOR,
SUPERUSER_GROUP_NAME,
pickRandomAvatarColor
} from '../auth/superuser.js';
import { logEvent } from '../logging/eventLogs.js';
const selectGroupsWithMembers = db.prepare(`
SELECT
g.id,
g.name,
g.description,
g.avatar_color,
g.created_at,
g.updated_at,
COUNT(DISTINCT u.id) AS member_count,
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
FROM user_groups g
LEFT JOIN user_group_memberships m ON m.group_id = g.id
LEFT JOIN users u ON u.id = m.user_id
GROUP BY g.id
ORDER BY g.name COLLATE NOCASE
`);
const selectGroupWithMembersById = db.prepare(`
SELECT
g.id,
g.name,
g.description,
g.avatar_color,
g.created_at,
g.updated_at,
COUNT(DISTINCT u.id) AS member_count,
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
FROM user_groups g
LEFT JOIN user_group_memberships m ON m.group_id = g.id
LEFT JOIN users u ON u.id = m.user_id
WHERE g.id = ?
GROUP BY g.id
`);
const selectGroupByIdBasic = db.prepare(`
SELECT id, name, description, avatar_color
FROM user_groups
WHERE id = ?
`);
const selectGroupIdByName = db.prepare(`
SELECT id
FROM user_groups
WHERE lower(name) = lower(?)
LIMIT 1
`);
const insertGroupStatement = db.prepare(`
INSERT INTO user_groups (name, description, avatar_color, created_at, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateGroupStatement = db.prepare(`
UPDATE user_groups
SET name = ?,
description = ?,
avatar_color = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const countGroupMembersStatement = db.prepare(`
SELECT COUNT(*) AS member_count
FROM user_group_memberships
WHERE group_id = ?
`);
const deleteGroupStatement = db.prepare(`
DELETE FROM user_groups
WHERE id = ?
`);
const parseMembers = (rawPairs) => {
if (!rawPairs) {
return [];
}
return rawPairs
.split('|||')
.map((entry) => {
const [idPart, usernamePart] = entry.split('::');
const numericId = Number(idPart);
return {
id: Number.isFinite(numericId) ? numericId : idPart,
username: (usernamePart || '').trim()
};
})
.filter((member) => member.username);
};
const sanitizeGroup = (row) => ({
id: row.id,
name: row.name,
description: row.description || '',
avatarColor: row.avatar_color || null,
createdAt: row.created_at || null,
updatedAt: row.updated_at || null,
memberCount: Number(row.member_count) || 0,
members: parseMembers(row.member_pairs)
});
export function listGroups() {
const rows = selectGroupsWithMembers.all();
return rows.map(sanitizeGroup);
}
export function getGroupById(groupId) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
return null;
}
const row = selectGroupWithMembersById.get(numericId);
return row ? sanitizeGroup(row) : null;
}
export function createGroup({ name, description }) {
const normalizedName = typeof name === 'string' ? name.trim() : '';
if (!normalizedName) {
const error = new Error('GROUP_NAME_REQUIRED');
error.code = 'GROUP_NAME_REQUIRED';
throw error;
}
const existing = selectGroupIdByName.get(normalizedName);
if (existing) {
const error = new Error('GROUP_NAME_TAKEN');
error.code = 'GROUP_NAME_TAKEN';
throw error;
}
const normalizedDescription = typeof description === 'string' ? description.trim() : null;
const avatarColor = pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null, avatarColor);
const groupId = Number(insertResult.lastInsertRowid);
const row = selectGroupWithMembersById.get(groupId);
const group = row ? sanitizeGroup(row) : null;
logEvent({
category: 'benutzergruppe',
eventType: 'gruppe-angelegt',
action: 'anlegen',
status: 'erfolgreich',
entityType: 'gruppe',
entityId: String(groupId),
entityName: group?.name ?? normalizedName,
message: `Benutzergruppe "${normalizedName}" angelegt`,
metadata: {
description: group?.description ?? normalizedDescription ?? null,
avatarColor
}
});
return group;
}
export function updateGroupDetails(groupId, { name, description, avatarColor } = {}) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
const error = new Error('INVALID_GROUP_ID');
error.code = 'INVALID_GROUP_ID';
throw error;
}
const existingRow = selectGroupByIdBasic.get(numericId);
if (!existingRow) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
throw error;
}
const normalizedName = typeof name === 'string' ? name.trim() : existingRow.name || '';
if (!normalizedName) {
const error = new Error('GROUP_NAME_REQUIRED');
error.code = 'GROUP_NAME_REQUIRED';
throw error;
}
const nameRow = selectGroupIdByName.get(normalizedName);
if (nameRow && Number(nameRow.id) !== numericId) {
const error = new Error('GROUP_NAME_TAKEN');
error.code = 'GROUP_NAME_TAKEN';
throw error;
}
let normalizedDescription;
if (description === undefined) {
normalizedDescription = existingRow.description ?? null;
} else if (description === null) {
normalizedDescription = null;
} else {
const trimmedDescription = typeof description === 'string' ? description.trim() : '';
normalizedDescription = trimmedDescription ? trimmedDescription : null;
}
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
if (isSuperuserGroup) {
if (name !== undefined) {
const incomingName = typeof name === 'string' ? name.trim() : '';
if (incomingName && incomingName !== existingRow.name) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
}
if (description !== undefined) {
const existingDescriptionComparable = existingRow.description === null || existingRow.description === undefined
? ''
: String(existingRow.description).trim();
const incomingDescriptionComparable = description === null
? ''
: typeof description === 'string'
? description.trim()
: '';
if (incomingDescriptionComparable !== existingDescriptionComparable) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
}
// Enforce original values for protected fields
normalizedDescription = existingRow.description ?? null;
}
let colorToPersist = existingRow.avatar_color || null;
if (avatarColor !== undefined) {
const candidate = String(avatarColor || '').trim();
if (!candidate) {
colorToPersist = DEFAULT_AVATAR_COLOR;
} else {
const normalized = normalizeAvatarColor(candidate);
if (!normalized) {
const error = new Error('INVALID_AVATAR_COLOR');
error.code = 'INVALID_AVATAR_COLOR';
throw error;
}
colorToPersist = normalized;
}
}
updateGroupStatement.run(
isSuperuserGroup ? existingRow.name : normalizedName,
normalizedDescription,
colorToPersist,
numericId
);
const updatedGroup = getGroupById(numericId);
logEvent({
category: 'benutzergruppe',
eventType: 'gruppe-aktualisiert',
action: 'aktualisieren',
status: 'erfolgreich',
entityType: 'gruppe',
entityId: String(numericId),
entityName: updatedGroup?.name ?? normalizedName,
message: `Benutzergruppe "${updatedGroup?.name ?? normalizedName}" aktualisiert`,
metadata: {
previous: {
name: existingRow.name,
description: existingRow.description ?? null,
avatarColor: existingRow.avatar_color ?? null
},
current: updatedGroup
? {
name: updatedGroup.name,
description: updatedGroup.description ?? null,
avatarColor: updatedGroup.avatarColor ?? null,
memberCount: updatedGroup.memberCount ?? 0
}
: {
name: normalizedName,
description: normalizedDescription ?? null,
avatarColor: colorToPersist
}
}
});
return updatedGroup;
}
export function deleteGroup(groupId) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
const error = new Error('INVALID_GROUP_ID');
error.code = 'INVALID_GROUP_ID';
throw error;
}
const existingRow = selectGroupByIdBasic.get(numericId);
if (!existingRow) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
throw error;
}
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
if (isSuperuserGroup) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
const { member_count: memberCount } = countGroupMembersStatement.get(numericId);
if (Number(memberCount) > 0) {
const error = new Error('GROUP_HAS_MEMBERS');
error.code = 'GROUP_HAS_MEMBERS';
error.memberCount = Number(memberCount);
throw error;
}
deleteGroupStatement.run(numericId);
logEvent({
category: 'benutzergruppe',
eventType: 'gruppe-gelöscht',
action: 'löschen',
status: 'erfolgreich',
entityType: 'gruppe',
entityId: String(numericId),
entityName: existingRow.name ?? `ID ${numericId}`,
message: `Benutzergruppe "${existingRow.name ?? numericId}" gelöscht`,
metadata: {
description: existingRow.description ?? null,
avatarColor: existingRow.avatar_color ?? null,
memberCountBeforeDelete: Number(memberCount)
}
});
}