Merge pull request #129 from ulsklyc/feat/multi-assignment
feat: multi-person assignment for tasks and calendar events (v0.48.0)
This commit is contained in:
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.48.0] - 2026-05-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Multi-person assignment**: tasks and calendar events can now be assigned to multiple family members simultaneously. A new `task_assignments` / `event_assignments` join table (migration v32) stores the assignments; existing single-user data is migrated automatically.
|
||||||
|
- **Avatar stack**: task cards, Kanban cards, and the calendar agenda view display stacked avatars for all assigned users (up to 3 visible, then a `+N` overflow badge).
|
||||||
|
- **Shared UserMultiSelect component** (`public/components/user-multi-select.js`): checkbox-based dropdown used in both the task modal and the calendar event modal; replaces the previous single-user `<select>`.
|
||||||
|
- **`assigned_to` filter extended**: `GET /api/v1/tasks?assigned_to=<id>` and `GET /api/v1/calendar?assigned_to=<id>` now match any task/event where the user appears in the assignments list.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- API response for tasks and calendar events now includes `assigned_users: [{id, display_name, color}]` array alongside the legacy `assigned_to` / `assigned_name` / `assigned_color` fields.
|
||||||
|
- Recurring task completion copies all multi-person assignments to the new recurring instance.
|
||||||
|
|
||||||
## [0.47.5] - 2026-05-06
|
## [0.47.5] - 2026-05-06
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.47.4",
|
"version": "0.48.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.47.4",
|
"version": "0.48.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.47.5",
|
"version": "0.48.0",
|
||||||
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"setup": "node --import dotenv/config setup.js",
|
"setup": "node --import dotenv/config setup.js",
|
||||||
"test:db": "node --experimental-sqlite test-db.js",
|
"test:db": "node --experimental-sqlite test-db.js",
|
||||||
"test:dashboard": "node --experimental-sqlite test-dashboard.js",
|
"test:dashboard": "node --experimental-sqlite test-dashboard.js",
|
||||||
|
"test:multi-assignment": "node --experimental-sqlite test-multi-assignment.js",
|
||||||
"test:tasks": "node --experimental-sqlite test-tasks.js",
|
"test:tasks": "node --experimental-sqlite test-tasks.js",
|
||||||
"test:shopping": "node --experimental-sqlite test-shopping.js",
|
"test:shopping": "node --experimental-sqlite test-shopping.js",
|
||||||
"test:meals": "node --experimental-sqlite test-meals.js",
|
"test:meals": "node --experimental-sqlite test-meals.js",
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
|
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
|
||||||
"test:caldav": "node --experimental-sqlite test-caldav-sync.js",
|
"test:caldav": "node --experimental-sqlite test-caldav-sync.js",
|
||||||
"test:carddav": "node --experimental-sqlite test-carddav.js",
|
"test:carddav": "node --experimental-sqlite test-carddav.js",
|
||||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav"
|
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-multi-assignment.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Modul: User Multi-Select
|
||||||
|
* Zweck: Wiederverwendbare Mehrfachauswahl-Komponente für Benutzer (Tasks & Kalender)
|
||||||
|
* Abhängigkeiten: public/utils/html.js, public/i18n.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
|
import { t } from '/i18n.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert einen Avatar-Stack für mehrere zugewiesene Benutzer.
|
||||||
|
* @param {Array<{id, display_name, color}>} users
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {number} [opts.size=28] Avatar-Größe in px
|
||||||
|
* @param {number} [opts.maxVisible=3] Maximale Avatare vor "+N"-Anzeige
|
||||||
|
* @returns {string} HTML-String
|
||||||
|
*/
|
||||||
|
export function renderAvatarStack(users, { size = 28, maxVisible = 3 } = {}) {
|
||||||
|
if (!users?.length) return '';
|
||||||
|
const visible = users.slice(0, maxVisible);
|
||||||
|
const overflow = users.length - visible.length;
|
||||||
|
const fs = Math.round(size * 0.4);
|
||||||
|
const avatars = visible.map((u) => {
|
||||||
|
const initials = (u.display_name ?? '')
|
||||||
|
.split(' ')
|
||||||
|
.map((w) => w[0] ?? '')
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
return `<span class="avatar-stack__item"
|
||||||
|
style="width:${size}px;height:${size}px;font-size:${fs}px;background-color:${esc(u.color ?? '#8E8E93')}"
|
||||||
|
title="${esc(u.display_name ?? '')}">
|
||||||
|
${esc(initials)}
|
||||||
|
</span>`;
|
||||||
|
});
|
||||||
|
if (overflow > 0) {
|
||||||
|
avatars.push(`<span class="avatar-stack__item avatar-stack__overflow"
|
||||||
|
style="width:${size}px;height:${size}px;font-size:${fs}px"
|
||||||
|
title="${overflow} ${t('userMultiSelect.moreUsers')}">+${overflow}</span>`);
|
||||||
|
}
|
||||||
|
return `<span class="avatar-stack">${avatars.join('')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert das Multi-Select-Widget als Dropdown-Checkbox-Liste.
|
||||||
|
* @param {Array<{id, display_name, avatar_color}>} allUsers Alle verfügbaren Benutzer
|
||||||
|
* @param {number[]} selectedIds Bereits ausgewählte IDs
|
||||||
|
* @param {string} inputName Name-Attribut des Widgets
|
||||||
|
* @param {string} labelKey i18n-Schlüssel für das Label
|
||||||
|
* @returns {string} HTML-String
|
||||||
|
*/
|
||||||
|
export function renderUserMultiSelect(allUsers, selectedIds, inputName, labelKey) {
|
||||||
|
const selectedSet = new Set(selectedIds ?? []);
|
||||||
|
const items = allUsers.map((u) => {
|
||||||
|
const checked = selectedSet.has(u.id) ? 'checked' : '';
|
||||||
|
const initials = (u.display_name ?? '')
|
||||||
|
.split(' ')
|
||||||
|
.map((w) => w[0] ?? '')
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
return `
|
||||||
|
<label class="user-ms__option">
|
||||||
|
<input type="checkbox" class="user-ms__checkbox" value="${u.id}" ${checked}
|
||||||
|
data-ms-input="${esc(inputName)}">
|
||||||
|
<span class="user-ms__avatar" style="background-color:${esc(u.avatar_color ?? '#8E8E93')}">
|
||||||
|
${esc(initials)}
|
||||||
|
</span>
|
||||||
|
<span class="user-ms__name">${esc(u.display_name)}</span>
|
||||||
|
</label>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const noneLabel = t('userMultiSelect.nobody');
|
||||||
|
return `
|
||||||
|
<div class="user-ms" data-ms-name="${esc(inputName)}">
|
||||||
|
<label class="label">${t(labelKey)}</label>
|
||||||
|
<div class="user-ms__options">
|
||||||
|
<label class="user-ms__option">
|
||||||
|
<input type="checkbox" class="user-ms__checkbox user-ms__none" value=""
|
||||||
|
data-ms-input="${esc(inputName)}" ${selectedSet.size === 0 ? 'checked' : ''}>
|
||||||
|
<span class="user-ms__avatar user-ms__avatar--none">–</span>
|
||||||
|
<span class="user-ms__name">${noneLabel}</span>
|
||||||
|
</label>
|
||||||
|
${items.join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die ausgewählten User-IDs aus einem gerenderten Multi-Select-Widget.
|
||||||
|
* @param {Element} container DOM-Element, das das Widget enthält
|
||||||
|
* @param {string} inputName Name-Attribut des Widgets
|
||||||
|
* @returns {number[]}
|
||||||
|
*/
|
||||||
|
export function getSelectedUserIds(container, inputName) {
|
||||||
|
const checkboxes = container.querySelectorAll(
|
||||||
|
`[data-ms-input="${CSS.escape(inputName)}"]:not(.user-ms__none):checked`
|
||||||
|
);
|
||||||
|
return Array.from(checkboxes).map((cb) => Number(cb.value)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bindet die Checkbox-Logik:
|
||||||
|
* - "Niemand" deselektiert alle anderen
|
||||||
|
* - Andere Auswahl deselektiert "Niemand"
|
||||||
|
* @param {Element} container
|
||||||
|
* @param {string} inputName
|
||||||
|
*/
|
||||||
|
export function bindUserMultiSelect(container, inputName) {
|
||||||
|
const widget = container.querySelector(`.user-ms[data-ms-name="${CSS.escape(inputName)}"]`);
|
||||||
|
if (!widget) return;
|
||||||
|
|
||||||
|
widget.addEventListener('change', (e) => {
|
||||||
|
const cb = e.target;
|
||||||
|
if (!cb.matches('.user-ms__checkbox')) return;
|
||||||
|
|
||||||
|
if (cb.classList.contains('user-ms__none') && cb.checked) {
|
||||||
|
widget.querySelectorAll('.user-ms__checkbox:not(.user-ms__none)').forEach((c) => { c.checked = false; });
|
||||||
|
} else if (!cb.classList.contains('user-ms__none') && cb.checked) {
|
||||||
|
const none = widget.querySelector('.user-ms__none');
|
||||||
|
if (none) none.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
<link rel="stylesheet" href="/styles/glass.css" />
|
<link rel="stylesheet" href="/styles/glass.css" />
|
||||||
<link rel="stylesheet" href="/styles/sub-tabs.css" />
|
<link rel="stylesheet" href="/styles/sub-tabs.css" />
|
||||||
<link rel="stylesheet" href="/styles/kitchen-tabs.css" />
|
<link rel="stylesheet" href="/styles/kitchen-tabs.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/user-multi-select.css" />
|
||||||
<link rel="stylesheet" href="/styles/login.css" />
|
<link rel="stylesheet" href="/styles/login.css" />
|
||||||
|
|
||||||
<!-- Lucide Icons (lokal, v0.469.0) -->
|
<!-- Lucide Icons (lokal, v0.469.0) -->
|
||||||
|
|||||||
@@ -1293,5 +1293,9 @@
|
|||||||
"dropzoneTitle": "Datei hier ablegen oder klicken",
|
"dropzoneTitle": "Datei hier ablegen oder klicken",
|
||||||
"dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.",
|
"dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.",
|
||||||
"selectedFileLabel": "Ausgewählt: {{name}}"
|
"selectedFileLabel": "Ausgewählt: {{name}}"
|
||||||
|
},
|
||||||
|
"userMultiSelect": {
|
||||||
|
"nobody": "- Niemand -",
|
||||||
|
"moreUsers": "weitere"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-19
@@ -11,6 +11,7 @@ import { stagger } from '/utils/ux.js';
|
|||||||
import { t, formatDate as formatPreferredDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js';
|
import { t, formatDate as formatPreferredDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js';
|
||||||
import { esc, fmtLocation } from '/utils/html.js';
|
import { esc, fmtLocation } from '/utils/html.js';
|
||||||
import { refresh as refreshReminders } from '/reminders.js';
|
import { refresh as refreshReminders } from '/reminders.js';
|
||||||
|
import { renderUserMultiSelect, getSelectedUserIds, bindUserMultiSelect, renderAvatarStack } from '/components/user-multi-select.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -1032,11 +1033,8 @@ function renderAgendaEvent(ev) {
|
|||||||
: formatTime(ev.start_datetime)
|
: formatTime(ev.start_datetime)
|
||||||
+ (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} ${t('calendar.timeSuffix')}`.trimEnd() : ` ${t('calendar.timeSuffix')}`.trimEnd());
|
+ (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} ${t('calendar.timeSuffix')}`.trimEnd() : ` ${t('calendar.timeSuffix')}`.trimEnd());
|
||||||
|
|
||||||
const initials = ev.assigned_name
|
|
||||||
? ev.assigned_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const displayColor = ev.cal_color || ev.color;
|
const displayColor = ev.cal_color || ev.color;
|
||||||
|
const assignedUsers = ev.assigned_users ?? [];
|
||||||
return `
|
return `
|
||||||
<div class="agenda-event" data-id="${ev.id}">
|
<div class="agenda-event" data-id="${ev.id}">
|
||||||
<div class="agenda-event__color" style="background-color:${esc(displayColor)};"></div>
|
<div class="agenda-event__color" style="background-color:${esc(displayColor)};"></div>
|
||||||
@@ -1046,11 +1044,7 @@ function renderAgendaEvent(ev) {
|
|||||||
<span>${timeStr}</span>
|
<span>${timeStr}</span>
|
||||||
${ev.location ? `<span>📍 ${esc(fmtLocation(ev.location))}</span>` : ''}
|
${ev.location ? `<span>📍 ${esc(fmtLocation(ev.location))}</span>` : ''}
|
||||||
${ev.cal_name ? `<span class="event-cal-label" style="--cal-color:${esc(displayColor)}">${esc(ev.cal_name)}</span>` : ''}
|
${ev.cal_name ? `<span class="event-cal-label" style="--cal-color:${esc(displayColor)}">${esc(ev.cal_name)}</span>` : ''}
|
||||||
${ev.assigned_name ? `
|
${assignedUsers.length ? `<span class="agenda-event__assigned">${renderAvatarStack(assignedUsers, { size: 20, maxVisible: 3 })}</span>` : ''}
|
||||||
<span class="agenda-event__assigned">
|
|
||||||
<span class="agenda-event__avatar" style="background-color:${esc(ev.assigned_color || '#8E8E93')}">${initials}</span>
|
|
||||||
${esc(ev.assigned_name)}
|
|
||||||
</span>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1328,6 +1322,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
|||||||
onSave(panel) {
|
onSave(panel) {
|
||||||
// RRULE-Events binden
|
// RRULE-Events binden
|
||||||
bindRRuleEvents(panel, 'event');
|
bindRRuleEvents(panel, 'event');
|
||||||
|
bindUserMultiSelect(panel, 'cal_assigned');
|
||||||
|
|
||||||
const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0];
|
const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0];
|
||||||
|
|
||||||
@@ -1573,12 +1568,9 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
|
|
||||||
const userOpts = [
|
const selectedUserIds = isEdit
|
||||||
`<option value="">${t('calendar.assignedNobody')}</option>`,
|
? (event.assigned_users?.map((u) => u.id) ?? (event.assigned_to ? [event.assigned_to] : []))
|
||||||
...state.users.map((u) =>
|
: [];
|
||||||
`<option value="${u.id}" ${isEdit && event.assigned_to === u.id ? 'selected' : ''}>${esc(u.display_name)}</option>`
|
|
||||||
),
|
|
||||||
].join('');
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="event-title-picker">
|
<div class="event-title-picker">
|
||||||
@@ -1660,8 +1652,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-assigned">${t('calendar.assignedLabel')}</label>
|
${renderUserMultiSelect(state.users, selectedUserIds, 'cal_assigned', 'calendar.assignedLabel')}
|
||||||
<select class="form-input" id="modal-assigned">${userOpts}</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -1738,7 +1729,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach
|
|||||||
const color = overlay.querySelector('.color-swatch--active')?.dataset.color || EVENT_COLORS[0];
|
const color = overlay.querySelector('.color-swatch--active')?.dataset.color || EVENT_COLORS[0];
|
||||||
const icon = eventIconName(overlay.querySelector('#modal-icon')?.value);
|
const icon = eventIconName(overlay.querySelector('#modal-icon')?.value);
|
||||||
const location = overlay.querySelector('#modal-location').value.trim() || null;
|
const location = overlay.querySelector('#modal-location').value.trim() || null;
|
||||||
const assigned_to = overlay.querySelector('#modal-assigned').value || null;
|
const assigned_to = getSelectedUserIds(overlay, 'cal_assigned');
|
||||||
const description = overlay.querySelector('#modal-description').value.trim() || null;
|
const description = overlay.querySelector('#modal-description').value.trim() || null;
|
||||||
|
|
||||||
let start_datetime, end_datetime;
|
let start_datetime, end_datetime;
|
||||||
@@ -1815,7 +1806,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach
|
|||||||
const body = {
|
const body = {
|
||||||
title, description, start_datetime, end_datetime,
|
title, description, start_datetime, end_datetime,
|
||||||
all_day: allday ? 1 : 0,
|
all_day: allday ? 1 : 0,
|
||||||
location, color, icon, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
|
location, color, icon, assigned_to,
|
||||||
recurrence_rule: rrule.recurrence_rule,
|
recurrence_rule: rrule.recurrence_rule,
|
||||||
attachment_name: attachmentPayload.name,
|
attachment_name: attachmentPayload.name,
|
||||||
attachment_mime: attachmentPayload.mime,
|
attachment_mime: attachmentPayload.mime,
|
||||||
|
|||||||
+7
-19
@@ -11,6 +11,7 @@ import { stagger, vibrate } from '/utils/ux.js';
|
|||||||
import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js';
|
import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js';
|
||||||
import { esc } from '/utils/html.js';
|
import { esc } from '/utils/html.js';
|
||||||
import { refresh as refreshReminders } from '/reminders.js';
|
import { refresh as refreshReminders } from '/reminders.js';
|
||||||
|
import { renderUserMultiSelect, getSelectedUserIds, bindUserMultiSelect, renderAvatarStack } from '/components/user-multi-select.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -203,11 +204,7 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${task.assigned_color ? `
|
${renderAvatarStack(task.assigned_users ?? [], { size: 28 })}
|
||||||
<div class="task-avatar" style="background-color:${esc(task.assigned_color)}"
|
|
||||||
title="${esc(task.assigned_name)}">
|
|
||||||
${esc(initials(task.assigned_name ?? ''))}
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
<button class="btn btn--ghost btn--icon btn--icon-sm" data-action="edit-task" data-id="${task.id}"
|
<button class="btn btn--ghost btn--icon btn--icon-sm" data-action="edit-task" data-id="${task.id}"
|
||||||
aria-label="${t('tasks.editButton')}">
|
aria-label="${t('tasks.editButton')}">
|
||||||
@@ -305,9 +302,7 @@ function renderTaskGroups(tasks, groupMode) {
|
|||||||
function renderModalContent({ task = null, users = [], reminder = null } = {}) {
|
function renderModalContent({ task = null, users = [], reminder = null } = {}) {
|
||||||
const isEdit = !!task;
|
const isEdit = !!task;
|
||||||
|
|
||||||
const userOptions = users.map((u) =>
|
const selectedIds = task?.assigned_users?.map((u) => u.id) ?? (task?.assigned_to ? [task.assigned_to] : []);
|
||||||
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${esc(u.display_name)}</option>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
const catLabels = CATEGORY_LABELS();
|
const catLabels = CATEGORY_LABELS();
|
||||||
const categoryOptions = CATEGORIES.map((c) =>
|
const categoryOptions = CATEGORIES.map((c) =>
|
||||||
@@ -374,11 +369,7 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top:var(--space-4)">
|
<div class="form-group" style="margin-top:var(--space-4)">
|
||||||
<label class="label" for="task-assigned">${t('tasks.assignedLabel')}</label>
|
${renderUserMultiSelect(users, selectedIds, 'task_assigned', 'tasks.assignedLabel')}
|
||||||
<select class="input" id="task-assigned" name="assigned_to">
|
|
||||||
<option value="">${t('tasks.assignedNobody')}</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${isEdit ? `
|
${isEdit ? `
|
||||||
@@ -553,6 +544,7 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai
|
|||||||
panel.querySelector('.modal-panel__body')?.classList.add('modal-panel__body--tasks-fit');
|
panel.querySelector('.modal-panel__body')?.classList.add('modal-panel__body--tasks-fit');
|
||||||
// RRULE-Events binden
|
// RRULE-Events binden
|
||||||
bindRRuleEvents(document, 'task');
|
bindRRuleEvents(document, 'task');
|
||||||
|
bindUserMultiSelect(panel, 'task_assigned');
|
||||||
|
|
||||||
// Blur-Validierung für required-Felder aktivieren
|
// Blur-Validierung für required-Felder aktivieren
|
||||||
wireBlurValidation(panel);
|
wireBlurValidation(panel);
|
||||||
@@ -629,7 +621,7 @@ async function handleFormSubmit(e, container) {
|
|||||||
priority: form.priority.value,
|
priority: form.priority.value,
|
||||||
category: form.category.value,
|
category: form.category.value,
|
||||||
due_date: dueDate || null,
|
due_date: dueDate || null,
|
||||||
assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null,
|
assigned_to: getSelectedUserIds(form, 'task_assigned'),
|
||||||
is_recurring: rrule.is_recurring ? 1 : 0,
|
is_recurring: rrule.is_recurring ? 1 : 0,
|
||||||
recurrence_rule: rrule.recurrence_rule,
|
recurrence_rule: rrule.recurrence_rule,
|
||||||
};
|
};
|
||||||
@@ -773,11 +765,7 @@ function renderKanbanCard(task) {
|
|||||||
${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" class="icon-xs" aria-hidden="true"></i> ${due.label}</span>` : ''}
|
${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" class="icon-xs" aria-hidden="true"></i> ${due.label}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="kanban-card__footer">
|
<div class="kanban-card__footer">
|
||||||
${task.assigned_color ? `
|
${renderAvatarStack(task.assigned_users ?? [], { size: 22 }) || '<span></span>'}
|
||||||
<div class="task-avatar" style="background-color:${task.assigned_color};width:22px;height:22px;font-size:9px"
|
|
||||||
title="${esc(task.assigned_name ?? '')}">
|
|
||||||
${initials(task.assigned_name ?? '')}
|
|
||||||
</div>` : '<span></span>'}
|
|
||||||
<button class="kanban-card__status-btn" type="button"
|
<button class="kanban-card__status-btn" type="button"
|
||||||
data-next-status="${next}" title="${nextLabel}" aria-label="${nextLabel}">
|
data-next-status="${next}" title="${nextLabel}" aria-label="${nextLabel}">
|
||||||
<i data-lucide="${icon}" aria-hidden="true"></i>
|
<i data-lucide="${icon}" aria-hidden="true"></i>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Avatar-Stack (Mehrfach-Zuweisung Anzeige) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.avatar-stack {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-stack__item {
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
border: 2px solid var(--color-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-stack__item:last-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-stack__overflow {
|
||||||
|
background-color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* User Multi-Select Widget */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.user-ms {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-ms__options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-1);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-ms__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-ms__option:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-ms__checkbox {
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-ms__avatar {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-ms__avatar--none {
|
||||||
|
background-color: var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-ms__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -252,6 +252,17 @@ const MIGRATIONS_SQL = {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_birthdays_calendar_ref ON birthdays(calendar_event_id);
|
CREATE INDEX IF NOT EXISTS idx_birthdays_calendar_ref ON birthdays(calendar_event_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS task_assignments (
|
||||||
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (task_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS event_assignments (
|
||||||
|
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (event_id, user_id)
|
||||||
|
);
|
||||||
`,
|
`,
|
||||||
2: `
|
2: `
|
||||||
CREATE TABLE IF NOT EXISTS sync_config (
|
CREATE TABLE IF NOT EXISTS sync_config (
|
||||||
|
|||||||
@@ -1189,6 +1189,26 @@ const MIGRATIONS = [
|
|||||||
ALTER TABLE birthdays ADD COLUMN reminder_custom_unit TEXT;
|
ALTER TABLE birthdays ADD COLUMN reminder_custom_unit TEXT;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 32,
|
||||||
|
description: 'Multi-person assignment for tasks and calendar events',
|
||||||
|
up: `
|
||||||
|
CREATE TABLE IF NOT EXISTS task_assignments (
|
||||||
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (task_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS event_assignments (
|
||||||
|
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (event_id, user_id)
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO task_assignments (task_id, user_id)
|
||||||
|
SELECT id, assigned_to FROM tasks WHERE assigned_to IS NOT NULL;
|
||||||
|
INSERT OR IGNORE INTO event_assignments (event_id, user_id)
|
||||||
|
SELECT id, assigned_to FROM calendar_events WHERE assigned_to IS NOT NULL;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+106
-68
@@ -94,10 +94,33 @@ function attachmentDataUrl(event) {
|
|||||||
return `data:${event.attachment_mime};base64,${event.attachment_data}`;
|
return `data:${event.attachment_mime};base64,${event.attachment_data}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ASSIGNED_USERS_SQL = `(
|
||||||
|
SELECT json_group_array(json_object(
|
||||||
|
'id', u.id, 'display_name', u.display_name, 'color', u.avatar_color
|
||||||
|
))
|
||||||
|
FROM event_assignments ea JOIN users u ON u.id = ea.user_id
|
||||||
|
WHERE ea.event_id = e.id
|
||||||
|
) AS assigned_users_json`;
|
||||||
|
|
||||||
|
function parseAssignedTo(val) {
|
||||||
|
if (Array.isArray(val)) return val.map(Number).filter(Boolean);
|
||||||
|
if (val !== null && val !== undefined && val !== '') return [Number(val)].filter(Boolean);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEventAssignments(d, eventId, userIds) {
|
||||||
|
d.prepare('DELETE FROM event_assignments WHERE event_id = ?').run(eventId);
|
||||||
|
const ins = d.prepare('INSERT OR IGNORE INTO event_assignments (event_id, user_id) VALUES (?, ?)');
|
||||||
|
for (const uid of userIds) ins.run(eventId, uid);
|
||||||
|
}
|
||||||
|
|
||||||
function serializeEvent(event) {
|
function serializeEvent(event) {
|
||||||
if (!event) return event;
|
if (!event) return event;
|
||||||
|
const assigned_users = event.assigned_users_json ? JSON.parse(event.assigned_users_json) : [];
|
||||||
|
const { assigned_users_json, ...rest } = event;
|
||||||
return {
|
return {
|
||||||
...event,
|
...rest,
|
||||||
|
assigned_users,
|
||||||
attachment_data: attachmentDataUrl(event),
|
attachment_data: attachmentDataUrl(event),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -207,7 +230,8 @@ router.get('/', (req, res) => {
|
|||||||
u_assigned.avatar_color AS assigned_color,
|
u_assigned.avatar_color AS assigned_color,
|
||||||
u_created.display_name AS creator_name,
|
u_created.display_name AS creator_name,
|
||||||
ec.name AS cal_name,
|
ec.name AS cal_name,
|
||||||
ec.color AS cal_color
|
ec.color AS cal_color,
|
||||||
|
${ASSIGNED_USERS_SQL}
|
||||||
FROM calendar_events e
|
FROM calendar_events e
|
||||||
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
||||||
LEFT JOIN users u_created ON u_created.id = e.created_by
|
LEFT JOIN users u_created ON u_created.id = e.created_by
|
||||||
@@ -229,7 +253,7 @@ router.get('/', (req, res) => {
|
|||||||
const params = [to, from, to, getUserId(req)];
|
const params = [to, from, to, getUserId(req)];
|
||||||
|
|
||||||
if (req.query.assigned_to) {
|
if (req.query.assigned_to) {
|
||||||
sql += ' AND e.assigned_to = ?';
|
sql += ' AND EXISTS (SELECT 1 FROM event_assignments ea WHERE ea.event_id = e.id AND ea.user_id = ?)';
|
||||||
params.push(parseInt(req.query.assigned_to, 10));
|
params.push(parseInt(req.query.assigned_to, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +291,8 @@ router.get('/upcoming', (req, res) => {
|
|||||||
u_assigned.display_name AS assigned_name,
|
u_assigned.display_name AS assigned_name,
|
||||||
u_assigned.avatar_color AS assigned_color,
|
u_assigned.avatar_color AS assigned_color,
|
||||||
ec.name AS cal_name,
|
ec.name AS cal_name,
|
||||||
ec.color AS cal_color
|
ec.color AS cal_color,
|
||||||
|
${ASSIGNED_USERS_SQL}
|
||||||
FROM calendar_events e
|
FROM calendar_events e
|
||||||
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
||||||
LEFT JOIN external_calendars ec ON ec.id = e.calendar_ref_id
|
LEFT JOIN external_calendars ec ON ec.id = e.calendar_ref_id
|
||||||
@@ -580,7 +605,8 @@ router.get('/:id', (req, res) => {
|
|||||||
SELECT e.*,
|
SELECT e.*,
|
||||||
u_assigned.display_name AS assigned_name,
|
u_assigned.display_name AS assigned_name,
|
||||||
u_assigned.avatar_color AS assigned_color,
|
u_assigned.avatar_color AS assigned_color,
|
||||||
u_created.display_name AS creator_name
|
u_created.display_name AS creator_name,
|
||||||
|
${ASSIGNED_USERS_SQL}
|
||||||
FROM calendar_events e
|
FROM calendar_events e
|
||||||
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
||||||
LEFT JOIN users u_created ON u_created.id = e.created_by
|
LEFT JOIN users u_created ON u_created.id = e.created_by
|
||||||
@@ -628,43 +654,45 @@ router.post('/', (req, res) => {
|
|||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
if (!vIcon) return res.status(400).json({ error: 'icon: invalid calendar event icon.', code: 400 });
|
if (!vIcon) return res.status(400).json({ error: 'icon: invalid calendar event icon.', code: 400 });
|
||||||
|
|
||||||
const { all_day = 0, assigned_to = null } = req.body;
|
const { all_day = 0 } = req.body;
|
||||||
|
const userIds = parseAssignedTo(req.body.assigned_to);
|
||||||
if (assigned_to) {
|
const firstUid = userIds[0] ?? null;
|
||||||
const user = db.get().prepare('SELECT id FROM users WHERE id = ?').get(assigned_to);
|
|
||||||
if (!user) return res.status(400).json({ error: 'assigned_to: Benutzer nicht gefunden', code: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachment = req.body.attachment_data ? parseAttachment(req.body.attachment_data) : { mime: null, size: null, data: null };
|
const attachment = req.body.attachment_data ? parseAttachment(req.body.attachment_data) : { mime: null, size: null, data: null };
|
||||||
|
|
||||||
const result = db.get().prepare(`
|
const eventId = db.get().transaction(() => {
|
||||||
INSERT INTO calendar_events
|
const result = db.get().prepare(`
|
||||||
(title, description, start_datetime, end_datetime, all_day,
|
INSERT INTO calendar_events
|
||||||
location, color, icon, assigned_to, created_by, recurrence_rule,
|
(title, description, start_datetime, end_datetime, all_day,
|
||||||
attachment_name, attachment_mime, attachment_size, attachment_data)
|
location, color, icon, assigned_to, created_by, recurrence_rule,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
attachment_name, attachment_mime, attachment_size, attachment_data)
|
||||||
`).run(
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
vTitle.value, vDesc.value,
|
`).run(
|
||||||
vStart.value, vEnd.value,
|
vTitle.value, vDesc.value,
|
||||||
all_day ? 1 : 0, vLoc.value,
|
vStart.value, vEnd.value,
|
||||||
vColor.value, vIcon, assigned_to || null,
|
all_day ? 1 : 0, vLoc.value,
|
||||||
userId, vRrule.value,
|
vColor.value, vIcon, firstUid,
|
||||||
req.body.attachment_name || null,
|
userId, vRrule.value,
|
||||||
attachment.mime,
|
req.body.attachment_name || null,
|
||||||
attachment.size,
|
attachment.mime,
|
||||||
attachment.data
|
attachment.size,
|
||||||
);
|
attachment.data
|
||||||
|
);
|
||||||
|
setEventAssignments(db.get(), result.lastInsertRowid, userIds);
|
||||||
|
return result.lastInsertRowid;
|
||||||
|
})();
|
||||||
|
|
||||||
const event = db.get().prepare(`
|
const event = db.get().prepare(`
|
||||||
SELECT e.*,
|
SELECT e.*,
|
||||||
u_assigned.display_name AS assigned_name,
|
u_assigned.display_name AS assigned_name,
|
||||||
u_assigned.avatar_color AS assigned_color,
|
u_assigned.avatar_color AS assigned_color,
|
||||||
u_created.display_name AS creator_name
|
u_created.display_name AS creator_name,
|
||||||
|
${ASSIGNED_USERS_SQL}
|
||||||
FROM calendar_events e
|
FROM calendar_events e
|
||||||
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
||||||
LEFT JOIN users u_created ON u_created.id = e.created_by
|
LEFT JOIN users u_created ON u_created.id = e.created_by
|
||||||
WHERE e.id = ?
|
WHERE e.id = ?
|
||||||
`).get(result.lastInsertRowid);
|
`).get(eventId);
|
||||||
|
|
||||||
res.status(201).json({ data: serializeEvent(event) });
|
res.status(201).json({ data: serializeEvent(event) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -707,53 +735,63 @@ router.put('/:id', (req, res) => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
title, description, start_datetime, end_datetime,
|
title, description, start_datetime, end_datetime,
|
||||||
all_day, location, color: colorVal, assigned_to, recurrence_rule, attachment_name,
|
all_day, location, color: colorVal, recurrence_rule, attachment_name,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
const userIds = req.body.assigned_to !== undefined
|
||||||
|
? parseAssignedTo(req.body.assigned_to)
|
||||||
|
: db.get().prepare('SELECT user_id FROM event_assignments WHERE event_id = ?')
|
||||||
|
.all(id).map((r) => r.user_id);
|
||||||
|
const firstUid = userIds[0] ?? null;
|
||||||
|
|
||||||
const userModified = event.external_source !== 'local' ? 1 : event.user_modified;
|
const userModified = event.external_source !== 'local' ? 1 : event.user_modified;
|
||||||
|
|
||||||
db.get().prepare(`
|
db.get().transaction(() => {
|
||||||
UPDATE calendar_events
|
db.get().prepare(`
|
||||||
SET title = COALESCE(?, title),
|
UPDATE calendar_events
|
||||||
description = ?,
|
SET title = COALESCE(?, title),
|
||||||
start_datetime = COALESCE(?, start_datetime),
|
description = ?,
|
||||||
end_datetime = ?,
|
start_datetime = COALESCE(?, start_datetime),
|
||||||
all_day = COALESCE(?, all_day),
|
end_datetime = ?,
|
||||||
location = ?,
|
all_day = COALESCE(?, all_day),
|
||||||
color = COALESCE(?, color),
|
location = ?,
|
||||||
icon = COALESCE(?, icon),
|
color = COALESCE(?, color),
|
||||||
assigned_to = ?,
|
icon = COALESCE(?, icon),
|
||||||
recurrence_rule = ?,
|
assigned_to = ?,
|
||||||
attachment_name = ?,
|
recurrence_rule = ?,
|
||||||
attachment_mime = ?,
|
attachment_name = ?,
|
||||||
attachment_size = ?,
|
attachment_mime = ?,
|
||||||
attachment_data = ?,
|
attachment_size = ?,
|
||||||
user_modified = ?
|
attachment_data = ?,
|
||||||
WHERE id = ?
|
user_modified = ?
|
||||||
`).run(
|
WHERE id = ?
|
||||||
title?.trim() ?? null,
|
`).run(
|
||||||
description !== undefined ? (description || null) : event.description,
|
title?.trim() ?? null,
|
||||||
start_datetime ?? null,
|
description !== undefined ? (description || null) : event.description,
|
||||||
end_datetime !== undefined ? (end_datetime || null) : event.end_datetime,
|
start_datetime ?? null,
|
||||||
all_day !== undefined ? (all_day ? 1 : 0) : null,
|
end_datetime !== undefined ? (end_datetime || null) : event.end_datetime,
|
||||||
location !== undefined ? (location || null) : event.location,
|
all_day !== undefined ? (all_day ? 1 : 0) : null,
|
||||||
colorVal ?? null,
|
location !== undefined ? (location || null) : event.location,
|
||||||
req.body.icon !== undefined ? vIcon : null,
|
colorVal ?? null,
|
||||||
assigned_to !== undefined ? (assigned_to || null) : event.assigned_to,
|
req.body.icon !== undefined ? vIcon : null,
|
||||||
recurrence_rule !== undefined ? (recurrence_rule || null) : event.recurrence_rule,
|
firstUid !== undefined ? firstUid : event.assigned_to,
|
||||||
attachment_name !== undefined ? (attachment_name || null) : event.attachment_name,
|
recurrence_rule !== undefined ? (recurrence_rule || null) : event.recurrence_rule,
|
||||||
attachment.mime,
|
attachment_name !== undefined ? (attachment_name || null) : event.attachment_name,
|
||||||
attachment.size,
|
attachment.mime,
|
||||||
attachment.data,
|
attachment.size,
|
||||||
userModified,
|
attachment.data,
|
||||||
id
|
userModified,
|
||||||
);
|
id
|
||||||
|
);
|
||||||
|
setEventAssignments(db.get(), id, userIds);
|
||||||
|
})();
|
||||||
|
|
||||||
const updated = db.get().prepare(`
|
const updated = db.get().prepare(`
|
||||||
SELECT e.*,
|
SELECT e.*,
|
||||||
u_assigned.display_name AS assigned_name,
|
u_assigned.display_name AS assigned_name,
|
||||||
u_assigned.avatar_color AS assigned_color,
|
u_assigned.avatar_color AS assigned_color,
|
||||||
u_created.display_name AS creator_name
|
u_created.display_name AS creator_name,
|
||||||
|
${ASSIGNED_USERS_SQL}
|
||||||
FROM calendar_events e
|
FROM calendar_events e
|
||||||
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
||||||
LEFT JOIN users u_created ON u_created.id = e.created_by
|
LEFT JOIN users u_created ON u_created.id = e.created_by
|
||||||
|
|||||||
+95
-39
@@ -27,15 +27,42 @@ const VALID_CATEGORIES = ['household', 'school', 'shopping', 'repair',
|
|||||||
// Hilfsfunktionen
|
// Hilfsfunktionen
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
const ASSIGNED_USERS_SQL = `(
|
||||||
|
SELECT json_group_array(json_object(
|
||||||
|
'id', u.id, 'display_name', u.display_name, 'color', u.avatar_color
|
||||||
|
))
|
||||||
|
FROM task_assignments ta JOIN users u ON u.id = ta.user_id
|
||||||
|
WHERE ta.task_id = t.id
|
||||||
|
) AS assigned_users_json`;
|
||||||
|
|
||||||
|
function addAssignedUsers(task) {
|
||||||
|
task.assigned_users = task.assigned_users_json ? JSON.parse(task.assigned_users_json) : [];
|
||||||
|
delete task.assigned_users_json;
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssignedTo(val) {
|
||||||
|
if (Array.isArray(val)) return val.map(Number).filter(Boolean);
|
||||||
|
if (val !== null && val !== undefined && val !== '') return [Number(val)].filter(Boolean);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAssignments(d, taskId, userIds) {
|
||||||
|
d.prepare('DELETE FROM task_assignments WHERE task_id = ?').run(taskId);
|
||||||
|
const ins = d.prepare('INSERT OR IGNORE INTO task_assignments (task_id, user_id) VALUES (?, ?)');
|
||||||
|
for (const uid of userIds) ins.run(taskId, uid);
|
||||||
|
}
|
||||||
|
|
||||||
/** Alle Subtasks einer Aufgabe laden (eine Ebene tief). */
|
/** Alle Subtasks einer Aufgabe laden (eine Ebene tief). */
|
||||||
function loadSubtasks(taskId) {
|
function loadSubtasks(taskId) {
|
||||||
return db.get().prepare(`
|
return db.get().prepare(`
|
||||||
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
|
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color,
|
||||||
|
${ASSIGNED_USERS_SQL}
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
LEFT JOIN users u ON t.assigned_to = u.id
|
LEFT JOIN users u ON t.assigned_to = u.id
|
||||||
WHERE t.parent_task_id = ?
|
WHERE t.parent_task_id = ?
|
||||||
ORDER BY t.created_at ASC
|
ORDER BY t.created_at ASC
|
||||||
`).all(taskId);
|
`).all(taskId).map(addAssignedUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fortschritt der Subtasks berechnen (erledigte / gesamt). */
|
/** Fortschritt der Subtasks berechnen (erledigte / gesamt). */
|
||||||
@@ -79,6 +106,7 @@ router.get('/', (req, res) => {
|
|||||||
t.*,
|
t.*,
|
||||||
u.display_name AS assigned_name,
|
u.display_name AS assigned_name,
|
||||||
u.avatar_color AS assigned_color,
|
u.avatar_color AS assigned_color,
|
||||||
|
${ASSIGNED_USERS_SQL},
|
||||||
(SELECT COUNT(*) FROM tasks s WHERE s.parent_task_id = t.id) AS subtask_total,
|
(SELECT COUNT(*) FROM tasks s WHERE s.parent_task_id = t.id) AS subtask_total,
|
||||||
(SELECT COUNT(*) FROM tasks s WHERE s.parent_task_id = t.id AND s.status = 'done') AS subtask_done
|
(SELECT COUNT(*) FROM tasks s WHERE s.parent_task_id = t.id AND s.status = 'done') AS subtask_done
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
@@ -89,7 +117,10 @@ router.get('/', (req, res) => {
|
|||||||
|
|
||||||
if (status) { sql += ' AND t.status = ?'; params.push(status); }
|
if (status) { sql += ' AND t.status = ?'; params.push(status); }
|
||||||
if (priority) { sql += ' AND t.priority = ?'; params.push(priority); }
|
if (priority) { sql += ' AND t.priority = ?'; params.push(priority); }
|
||||||
if (assigned_to) { sql += ' AND t.assigned_to = ?'; params.push(Number(assigned_to)); }
|
if (assigned_to) {
|
||||||
|
sql += ' AND EXISTS (SELECT 1 FROM task_assignments ta WHERE ta.task_id = t.id AND ta.user_id = ?)';
|
||||||
|
params.push(Number(assigned_to));
|
||||||
|
}
|
||||||
if (category) { sql += ' AND t.category = ?'; params.push(category); }
|
if (category) { sql += ' AND t.category = ?'; params.push(category); }
|
||||||
|
|
||||||
sql += `
|
sql += `
|
||||||
@@ -101,7 +132,7 @@ router.get('/', (req, res) => {
|
|||||||
t.created_at DESC
|
t.created_at DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
res.json({ data: db.get().prepare(sql).all(...params) });
|
res.json({ data: db.get().prepare(sql).all(...params).map(addAssignedUsers) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('GET / error:', err);
|
log.error('GET / error:', err);
|
||||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||||
@@ -116,7 +147,8 @@ router.get('/', (req, res) => {
|
|||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const task = db.get().prepare(`
|
const task = db.get().prepare(`
|
||||||
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
|
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color,
|
||||||
|
${ASSIGNED_USERS_SQL}
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
LEFT JOIN users u ON t.assigned_to = u.id
|
LEFT JOIN users u ON t.assigned_to = u.id
|
||||||
WHERE t.id = ? AND t.parent_task_id IS NULL
|
WHERE t.id = ? AND t.parent_task_id IS NULL
|
||||||
@@ -124,6 +156,7 @@ router.get('/:id', (req, res) => {
|
|||||||
|
|
||||||
if (!task) return res.status(404).json({ error: 'Task not found.', code: 404 });
|
if (!task) return res.status(404).json({ error: 'Task not found.', code: 404 });
|
||||||
|
|
||||||
|
addAssignedUsers(task);
|
||||||
task.subtasks = loadSubtasks(task.id);
|
task.subtasks = loadSubtasks(task.id);
|
||||||
res.json({ data: task });
|
res.json({ data: task });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -151,12 +184,14 @@ router.post('/', (req, res) => {
|
|||||||
priority = 'none',
|
priority = 'none',
|
||||||
due_date = null,
|
due_date = null,
|
||||||
due_time = null,
|
due_time = null,
|
||||||
assigned_to = null,
|
|
||||||
parent_task_id = null,
|
parent_task_id = null,
|
||||||
is_recurring = 0,
|
is_recurring = 0,
|
||||||
recurrence_rule = null,
|
recurrence_rule = null,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
const userIds = parseAssignedTo(req.body.assigned_to);
|
||||||
|
const firstUid = userIds[0] ?? null;
|
||||||
|
|
||||||
// Tiefe begrenzen: Subtasks dürfen keine eigenen Subtasks haben (max. 2 Ebenen)
|
// Tiefe begrenzen: Subtasks dürfen keine eigenen Subtasks haben (max. 2 Ebenen)
|
||||||
if (parent_task_id) {
|
if (parent_task_id) {
|
||||||
const parent = db.get().prepare('SELECT parent_task_id FROM tasks WHERE id = ?')
|
const parent = db.get().prepare('SELECT parent_task_id FROM tasks WHERE id = ?')
|
||||||
@@ -166,24 +201,29 @@ router.post('/', (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Maximal 2 Verschachtelungsebenen erlaubt.', code: 400 });
|
return res.status(400).json({ error: 'Maximal 2 Verschachtelungsebenen erlaubt.', code: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.get().prepare(`
|
const taskId = db.get().transaction(() => {
|
||||||
INSERT INTO tasks
|
const result = db.get().prepare(`
|
||||||
(title, description, category, priority, due_date, due_time,
|
INSERT INTO tasks
|
||||||
assigned_to, created_by, parent_task_id, is_recurring, recurrence_rule)
|
(title, description, category, priority, due_date, due_time,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
assigned_to, created_by, parent_task_id, is_recurring, recurrence_rule)
|
||||||
`).run(
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
title.trim(), description, category, priority,
|
`).run(
|
||||||
due_date, due_time, assigned_to, req.session.userId, parent_task_id,
|
title.trim(), description, category, priority,
|
||||||
is_recurring ? 1 : 0, recurrence_rule
|
due_date, due_time, firstUid, req.session.userId, parent_task_id,
|
||||||
);
|
is_recurring ? 1 : 0, recurrence_rule
|
||||||
|
);
|
||||||
|
setAssignments(db.get(), result.lastInsertRowid, userIds);
|
||||||
|
return result.lastInsertRowid;
|
||||||
|
})();
|
||||||
|
|
||||||
const task = db.get().prepare(`
|
const task = db.get().prepare(`
|
||||||
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
|
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color,
|
||||||
|
${ASSIGNED_USERS_SQL}
|
||||||
FROM tasks t LEFT JOIN users u ON t.assigned_to = u.id
|
FROM tasks t LEFT JOIN users u ON t.assigned_to = u.id
|
||||||
WHERE t.id = ?
|
WHERE t.id = ?
|
||||||
`).get(result.lastInsertRowid);
|
`).get(taskId);
|
||||||
|
|
||||||
res.status(201).json({ data: task });
|
res.status(201).json({ data: addAssignedUsers(task) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('POST / error:', err);
|
log.error('POST / error:', err);
|
||||||
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
||||||
@@ -213,26 +253,36 @@ router.put('/:id', (req, res) => {
|
|||||||
status = task.status,
|
status = task.status,
|
||||||
due_date = task.due_date,
|
due_date = task.due_date,
|
||||||
due_time = task.due_time,
|
due_time = task.due_time,
|
||||||
assigned_to = task.assigned_to,
|
|
||||||
is_recurring = task.is_recurring,
|
is_recurring = task.is_recurring,
|
||||||
recurrence_rule = task.recurrence_rule,
|
recurrence_rule = task.recurrence_rule,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
db.get().prepare(`
|
const userIds = req.body.assigned_to !== undefined
|
||||||
UPDATE tasks SET
|
? parseAssignedTo(req.body.assigned_to)
|
||||||
title = ?, description = ?, category = ?, priority = ?,
|
: db.get().prepare('SELECT user_id FROM task_assignments WHERE task_id = ?')
|
||||||
status = ?, due_date = ?, due_time = ?, assigned_to = ?,
|
.all(task.id).map((r) => r.user_id);
|
||||||
is_recurring = ?, recurrence_rule = ?
|
const firstUid = userIds[0] ?? null;
|
||||||
WHERE id = ?
|
|
||||||
`).run(title.trim(), description, category, priority,
|
db.get().transaction(() => {
|
||||||
status, due_date, due_time, assigned_to,
|
db.get().prepare(`
|
||||||
is_recurring ? 1 : 0, recurrence_rule, req.params.id);
|
UPDATE tasks SET
|
||||||
|
title = ?, description = ?, category = ?, priority = ?,
|
||||||
|
status = ?, due_date = ?, due_time = ?, assigned_to = ?,
|
||||||
|
is_recurring = ?, recurrence_rule = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(title.trim(), description, category, priority,
|
||||||
|
status, due_date, due_time, firstUid,
|
||||||
|
is_recurring ? 1 : 0, recurrence_rule, req.params.id);
|
||||||
|
setAssignments(db.get(), task.id, userIds);
|
||||||
|
})();
|
||||||
|
|
||||||
const updated = db.get().prepare(`
|
const updated = db.get().prepare(`
|
||||||
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
|
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color,
|
||||||
|
${ASSIGNED_USERS_SQL}
|
||||||
FROM tasks t LEFT JOIN users u ON t.assigned_to = u.id
|
FROM tasks t LEFT JOIN users u ON t.assigned_to = u.id
|
||||||
WHERE t.id = ?
|
WHERE t.id = ?
|
||||||
`).get(req.params.id);
|
`).get(req.params.id);
|
||||||
|
addAssignedUsers(updated);
|
||||||
updated.subtasks = loadSubtasks(updated.id);
|
updated.subtasks = loadSubtasks(updated.id);
|
||||||
|
|
||||||
res.json({ data: updated });
|
res.json({ data: updated });
|
||||||
@@ -266,15 +316,21 @@ router.patch('/:id/status', (req, res) => {
|
|||||||
if (task?.is_recurring && task.recurrence_rule && !task.parent_task_id) {
|
if (task?.is_recurring && task.recurrence_rule && !task.parent_task_id) {
|
||||||
const nextDate = nextOccurrence(task.due_date, task.recurrence_rule);
|
const nextDate = nextOccurrence(task.due_date, task.recurrence_rule);
|
||||||
if (nextDate) {
|
if (nextDate) {
|
||||||
db.get().prepare(`
|
const existingAssignments = db.get()
|
||||||
INSERT INTO tasks (title, description, category, priority, status,
|
.prepare('SELECT user_id FROM task_assignments WHERE task_id = ?')
|
||||||
due_date, due_time, assigned_to, created_by, is_recurring, recurrence_rule)
|
.all(task.id).map((r) => r.user_id);
|
||||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?)
|
db.get().transaction(() => {
|
||||||
`).run(
|
const newTask = db.get().prepare(`
|
||||||
task.title, task.description, task.category, task.priority,
|
INSERT INTO tasks (title, description, category, priority, status,
|
||||||
nextDate, task.due_time, task.assigned_to, task.created_by,
|
due_date, due_time, assigned_to, created_by, is_recurring, recurrence_rule)
|
||||||
task.recurrence_rule
|
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?)
|
||||||
);
|
`).run(
|
||||||
|
task.title, task.description, task.category, task.priority,
|
||||||
|
nextDate, task.due_time, task.assigned_to, task.created_by,
|
||||||
|
task.recurrence_rule
|
||||||
|
);
|
||||||
|
setAssignments(db.get(), newTask.lastInsertRowid, existingAssignments);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Multi-Assignment-Test
|
||||||
|
* Zweck: Validiert Multi-Personen-Zuweisung für Tasks und Kalendereinträge
|
||||||
|
* Ausführen: node --experimental-sqlite test-multi-assignment.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { MIGRATIONS_SQL } from './server/db-schema-test.js';
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
||||||
|
catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; }
|
||||||
|
}
|
||||||
|
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); }
|
||||||
|
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY, description TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);`);
|
||||||
|
db.exec(MIGRATIONS_SQL[1]);
|
||||||
|
|
||||||
|
// Testdaten
|
||||||
|
const u1 = db.prepare(`INSERT INTO users (username, display_name, password_hash, avatar_color, role)
|
||||||
|
VALUES ('anna', 'Anna', 'x', '#007AFF', 'admin')`).run();
|
||||||
|
const u2 = db.prepare(`INSERT INTO users (username, display_name, password_hash, avatar_color)
|
||||||
|
VALUES ('max', 'Max', 'x', '#34C759')`).run();
|
||||||
|
const u3 = db.prepare(`INSERT INTO users (username, display_name, password_hash, avatar_color)
|
||||||
|
VALUES ('lisa', 'Lisa', 'x', '#FF9500')`).run();
|
||||||
|
const uid1 = u1.lastInsertRowid;
|
||||||
|
const uid2 = u2.lastInsertRowid;
|
||||||
|
const uid3 = u3.lastInsertRowid;
|
||||||
|
|
||||||
|
console.log('\n[Multi-Assignment-Test] Tasks\n');
|
||||||
|
|
||||||
|
let taskId1, taskId2;
|
||||||
|
|
||||||
|
test('Task mit einem Zugewiesenen erstellen', () => {
|
||||||
|
const r = db.prepare(`INSERT INTO tasks (title, category, priority, status, assigned_to, created_by)
|
||||||
|
VALUES ('Aufgabe 1', 'misc', 'low', 'open', ?, ?)`).run(uid1, uid1);
|
||||||
|
taskId1 = r.lastInsertRowid;
|
||||||
|
db.prepare('INSERT OR IGNORE INTO task_assignments (task_id, user_id) VALUES (?, ?)').run(taskId1, uid1);
|
||||||
|
assert(taskId1 > 0, 'ID muss > 0 sein');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zweiten Benutzer zur gleichen Aufgabe hinzufügen', () => {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO task_assignments (task_id, user_id) VALUES (?, ?)').run(taskId1, uid2);
|
||||||
|
const rows = db.prepare('SELECT user_id FROM task_assignments WHERE task_id = ?').all(taskId1);
|
||||||
|
assert(rows.length === 2, `Erwartet 2 Assignments, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dritten Benutzer zur Aufgabe hinzufügen', () => {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO task_assignments (task_id, user_id) VALUES (?, ?)').run(taskId1, uid3);
|
||||||
|
const rows = db.prepare('SELECT user_id FROM task_assignments WHERE task_id = ?').all(taskId1);
|
||||||
|
assert(rows.length === 3, `Erwartet 3 Assignments, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Duplicate-Assignment wird ignoriert (PRIMARY KEY)', () => {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO task_assignments (task_id, user_id) VALUES (?, ?)').run(taskId1, uid1);
|
||||||
|
const rows = db.prepare('SELECT user_id FROM task_assignments WHERE task_id = ?').all(taskId1);
|
||||||
|
assert(rows.length === 3, `Erwartet weiterhin 3, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JSON-Aggregation der zugewiesenen User', () => {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT json_group_array(json_object('id', u.id, 'display_name', u.display_name, 'color', u.avatar_color))
|
||||||
|
AS assigned_users_json
|
||||||
|
FROM task_assignments ta JOIN users u ON u.id = ta.user_id
|
||||||
|
WHERE ta.task_id = ?
|
||||||
|
`).get(taskId1);
|
||||||
|
const users = JSON.parse(row.assigned_users_json);
|
||||||
|
assert(users.length === 3, `Erwartet 3 User-Objekte, erhalten ${users.length}`);
|
||||||
|
assert(users.every((u) => u.id && u.display_name && u.color), 'Alle Felder müssen vorhanden sein');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Filter per EXISTS: Aufgaben für Benutzer 2 finden', () => {
|
||||||
|
const r2 = db.prepare(`INSERT INTO tasks (title, category, priority, status, assigned_to, created_by)
|
||||||
|
VALUES ('Aufgabe 2', 'misc', 'low', 'open', ?, ?)`).run(uid2, uid1);
|
||||||
|
taskId2 = r2.lastInsertRowid;
|
||||||
|
db.prepare('INSERT OR IGNORE INTO task_assignments (task_id, user_id) VALUES (?, ?)').run(taskId2, uid2);
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT t.id FROM tasks t
|
||||||
|
WHERE EXISTS (SELECT 1 FROM task_assignments ta WHERE ta.task_id = t.id AND ta.user_id = ?)
|
||||||
|
`).all(uid2);
|
||||||
|
assert(rows.length === 2, `uid2 sollte in 2 Tasks sein, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Filter: Aufgaben nur für Benutzer 3', () => {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT t.id FROM tasks t
|
||||||
|
WHERE EXISTS (SELECT 1 FROM task_assignments ta WHERE ta.task_id = t.id AND ta.user_id = ?)
|
||||||
|
`).all(uid3);
|
||||||
|
assert(rows.length === 1, `uid3 sollte in 1 Task sein, erhalten ${rows.length}`);
|
||||||
|
assert(rows[0].id === taskId1, 'Falsche Task-ID gefunden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Assignments ersetzen (DELETE + INSERT)', () => {
|
||||||
|
db.prepare('DELETE FROM task_assignments WHERE task_id = ?').run(taskId1);
|
||||||
|
db.prepare('INSERT OR IGNORE INTO task_assignments (task_id, user_id) VALUES (?, ?)').run(taskId1, uid3);
|
||||||
|
const rows = db.prepare('SELECT user_id FROM task_assignments WHERE task_id = ?').all(taskId1);
|
||||||
|
assert(rows.length === 1, `Nach Ersetzen soll 1 Assignment sein, erhalten ${rows.length}`);
|
||||||
|
assert(rows[0].user_id === uid3, 'Falscher User nach Ersetzen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CASCADE: Assignments werden beim Task-Löschen mitgelöscht', () => {
|
||||||
|
db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId1);
|
||||||
|
const rows = db.prepare('SELECT * FROM task_assignments WHERE task_id = ?').all(taskId1);
|
||||||
|
assert(rows.length === 0, `Assignments sollen gelöscht sein, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CASCADE: Assignments werden beim User-Löschen entfernt', () => {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO task_assignments (task_id, user_id) VALUES (?, ?)').run(taskId2, uid3);
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(uid3);
|
||||||
|
const rows = db.prepare('SELECT * FROM task_assignments WHERE user_id = ?').all(uid3);
|
||||||
|
assert(rows.length === 0, 'user_id-Referenz soll entfernt sein');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n[Multi-Assignment-Test] Kalendereinträge\n');
|
||||||
|
|
||||||
|
let eventId1;
|
||||||
|
|
||||||
|
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
test('Event mit zwei Zugewiesenen erstellen', () => {
|
||||||
|
const r = db.prepare(`INSERT INTO calendar_events
|
||||||
|
(title, start_datetime, all_day, color, icon, assigned_to, created_by, external_source)
|
||||||
|
VALUES ('Termin', ?, 0, '#007AFF', 'calendar', ?, ?, 'local')`).run(`${tomorrow}T10:00`, uid1, uid1);
|
||||||
|
eventId1 = r.lastInsertRowid;
|
||||||
|
db.prepare('INSERT OR IGNORE INTO event_assignments (event_id, user_id) VALUES (?, ?)').run(eventId1, uid1);
|
||||||
|
db.prepare('INSERT OR IGNORE INTO event_assignments (event_id, user_id) VALUES (?, ?)').run(eventId1, uid2);
|
||||||
|
const rows = db.prepare('SELECT user_id FROM event_assignments WHERE event_id = ?').all(eventId1);
|
||||||
|
assert(rows.length === 2, `Erwartet 2 Event-Assignments, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Event-Assignments JSON-Aggregation', () => {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT json_group_array(json_object('id', u.id, 'display_name', u.display_name, 'color', u.avatar_color))
|
||||||
|
AS assigned_users_json
|
||||||
|
FROM event_assignments ea JOIN users u ON u.id = ea.user_id
|
||||||
|
WHERE ea.event_id = ?
|
||||||
|
`).get(eventId1);
|
||||||
|
const users = JSON.parse(row.assigned_users_json);
|
||||||
|
assert(users.length === 2, `Erwartet 2, erhalten ${users.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EXISTS-Filter für Events', () => {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT e.id FROM calendar_events e
|
||||||
|
WHERE EXISTS (SELECT 1 FROM event_assignments ea WHERE ea.event_id = e.id AND ea.user_id = ?)
|
||||||
|
`).all(uid2);
|
||||||
|
assert(rows.length === 1, `uid2 soll in 1 Event sein, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CASCADE: Event-Assignments beim Event-Löschen entfernt', () => {
|
||||||
|
db.prepare('DELETE FROM calendar_events WHERE id = ?').run(eventId1);
|
||||||
|
const rows = db.prepare('SELECT * FROM event_assignments WHERE event_id = ?').all(eventId1);
|
||||||
|
assert(rows.length === 0, `Event-Assignments sollen gelöscht sein, erhalten ${rows.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n[Multi-Assignment-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
Reference in New Issue
Block a user