diff --git a/frontend/src/appointments-detail.tsx b/frontend/src/appointments-detail.tsx
index beb9a67..ded0b33 100644
--- a/frontend/src/appointments-detail.tsx
+++ b/frontend/src/appointments-detail.tsx
@@ -54,6 +54,12 @@ export function renderAppointmentsDetail(): string {
+
+
+
+
diff --git a/frontend/src/client/appointments-detail.ts b/frontend/src/client/appointments-detail.ts
index 30ffe88..d4545f9 100644
--- a/frontend/src/client/appointments-detail.ts
+++ b/frontend/src/client/appointments-detail.ts
@@ -1,6 +1,7 @@
import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
+import { projectIndent } from "./project-indent";
interface Appointment {
id: string;
@@ -18,10 +19,12 @@ interface Project {
id: string;
reference?: string | null;
title: string;
+ path?: string;
}
let appointment: Appointment | null = null;
let project: Project | null = null;
+let allProjects: Project[] = [];
function parseAppointmentID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -77,6 +80,32 @@ async function loadProject(id: string) {
}
}
+async function loadAllProjects() {
+ try {
+ const resp = await fetch("/api/projects");
+ if (resp.ok) allProjects = await resp.json();
+ } catch {
+ /* non-fatal */
+ }
+}
+
+function populateProjectPicker() {
+ const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
+ if (!sel) return;
+ const none = sel.querySelector('option[value=""]');
+ sel.innerHTML = "";
+ if (none) sel.appendChild(none);
+ for (const p of allProjects) {
+ const opt = document.createElement("option");
+ opt.value = p.id;
+ opt.textContent = `${projectIndent(p.path)}${p.reference || ""} — ${p.title}`;
+ sel.appendChild(opt);
+ }
+ if (appointment) {
+ sel.value = appointment.project_id ?? "";
+ }
+}
+
function renderHeader() {
if (!appointment) return;
document.getElementById("appointment-title-display")!.textContent = appointment.title;
@@ -114,6 +143,8 @@ function fillEditForm() {
(document.getElementById("appointment-type-edit") as HTMLSelectElement).value = appointment.appointment_type ?? "";
(document.getElementById("appointment-location-edit") as HTMLInputElement).value = appointment.location ?? "";
(document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value = appointment.description ?? "";
+ const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
+ if (projectSel) projectSel.value = appointment.project_id ?? "";
}
async function saveEdit(ev: Event) {
@@ -129,6 +160,9 @@ async function saveEdit(ev: Event) {
const type = (document.getElementById("appointment-type-edit") as HTMLSelectElement).value;
const location = (document.getElementById("appointment-location-edit") as HTMLInputElement).value.trim();
const description = (document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value;
+ const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
+ const newProjectID = projectSel ? projectSel.value : "";
+ const currentProjectID = appointment.project_id ?? "";
const payload: Record
= {
title,
@@ -138,6 +172,13 @@ async function saveEdit(ev: Event) {
location,
description,
};
+ if (newProjectID !== currentProjectID) {
+ if (newProjectID === "") {
+ payload.clear_project = true;
+ } else {
+ payload.project_id = newProjectID;
+ }
+ }
submitBtn.disabled = true;
try {
@@ -147,7 +188,13 @@ async function saveEdit(ev: Event) {
body: JSON.stringify(payload),
});
if (resp.ok) {
+ const prevProjectID = appointment.project_id ?? "";
appointment = await resp.json();
+ const nextProjectID = appointment?.project_id ?? "";
+ if (nextProjectID !== prevProjectID) {
+ project = null;
+ if (appointment?.project_id) await loadProject(appointment.project_id);
+ }
renderHeader();
msg.textContent = t("appointments.detail.saved");
msg.className = "form-msg form-msg-ok";
@@ -200,10 +247,14 @@ async function main() {
notFound.style.display = "block";
return;
}
- if (appointment.project_id) await loadProject(appointment.project_id);
+ await Promise.all([
+ appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(),
+ loadAllProjects(),
+ ]);
loading.style.display = "none";
body.style.display = "";
renderHeader();
+ populateProjectPicker();
fillEditForm();
document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit);
diff --git a/frontend/src/client/deadlines-detail.ts b/frontend/src/client/deadlines-detail.ts
index 120db3d..adcf15b 100644
--- a/frontend/src/client/deadlines-detail.ts
+++ b/frontend/src/client/deadlines-detail.ts
@@ -1,6 +1,7 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
+import { projectIndent } from "./project-indent";
import {
attachEventTypePicker,
fetchEventTypes,
@@ -32,6 +33,7 @@ interface Project {
id: string;
reference?: string | null;
title: string;
+ path?: string;
}
interface DeadlineRule {
@@ -51,6 +53,7 @@ let deadline: Deadline | null = null;
let project: Project | null = null;
let rule: DeadlineRule | null = null;
let me: Me | null = null;
+let allProjects: Project[] = [];
function parseDeadlineID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -123,6 +126,30 @@ async function loadProject(projectID: string) {
}
}
+async function loadAllProjects() {
+ try {
+ const resp = await fetch("/api/projects");
+ if (resp.ok) allProjects = await resp.json();
+ } catch {
+ /* non-fatal */
+ }
+}
+
+function populateProjectPicker() {
+ const sel = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
+ if (!sel || !deadline) return;
+ const opts: string[] = [];
+ for (const p of allProjects) {
+ const indent = projectIndent(p.path);
+ const ref = p.reference || "";
+ opts.push(
+ ``,
+ );
+ }
+ sel.innerHTML = opts.join("");
+ sel.value = deadline.project_id;
+}
+
async function loadRule(ruleID: string) {
try {
const resp = await fetch(`/api/deadline-rules`);
@@ -261,6 +288,8 @@ function initEdit() {
const saveBtn = document.getElementById("deadline-save-btn") as HTMLButtonElement;
const etDisplay = document.getElementById("deadline-event-types-display");
const etEdit = document.getElementById("deadline-event-types-edit");
+ const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
+ const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
function enterEdit() {
titleDisplay.style.display = "none";
@@ -271,6 +300,11 @@ function initEdit() {
notesEdit.style.display = "";
if (etDisplay) etDisplay.style.display = "none";
if (etEdit) etEdit.style.display = "";
+ if (projectEdit && deadline) {
+ projectLink.style.display = "none";
+ projectEdit.style.display = "";
+ projectEdit.value = deadline.project_id;
+ }
saveBtn.style.display = "";
editBtn.style.display = "none";
titleEdit.focus();
@@ -285,6 +319,10 @@ function initEdit() {
notesEdit.style.display = "none";
if (etDisplay) etDisplay.style.display = "";
if (etEdit) etEdit.style.display = "none";
+ if (projectEdit) {
+ projectEdit.style.display = "none";
+ projectLink.style.display = "";
+ }
saveBtn.style.display = "none";
editBtn.style.display = "";
}
@@ -307,13 +345,20 @@ function initEdit() {
if (eventTypePicker) {
payload.event_type_ids = eventTypePicker.getIDs();
}
+ if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
+ payload.project_id = projectEdit.value;
+ }
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
+ const prevProjectID = deadline.project_id;
deadline = await resp.json();
+ if (deadline && deadline.project_id !== prevProjectID) {
+ await loadProject(deadline.project_id);
+ }
render();
}
} finally {
@@ -410,7 +455,7 @@ async function main() {
notfound.style.display = "block";
return;
}
- await loadProject(deadline.project_id);
+ await Promise.all([loadProject(deadline.project_id), loadAllProjects()]);
if (deadline.rule_id) await loadRule(deadline.rule_id);
// Load event types in parallel; render once ready (the picker re-renders
@@ -435,6 +480,7 @@ async function main() {
});
}
+ populateProjectPicker();
render();
initEdit();
initComplete();
diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts
index 917304b..f91e201 100644
--- a/frontend/src/client/i18n.ts
+++ b/frontend/src/client/i18n.ts
@@ -795,10 +795,12 @@ const translations: Record> = {
"dashboard.action.short.deadline_completed": "erledigte Frist",
"dashboard.action.short.deadline_reopened": "öffnete Frist wieder",
"dashboard.action.short.deadline_deleted": "l\u00f6schte Frist",
+ "dashboard.action.short.deadline_project_changed": "verschob Frist",
"dashboard.action.short.deadlines_imported": "importierte Fristen",
"dashboard.action.short.appointment_created": "legte Termin an",
"dashboard.action.short.appointment_updated": "\u00e4nderte Termin",
"dashboard.action.short.appointment_deleted": "l\u00f6schte Termin",
+ "dashboard.action.short.appointment_project_changed": "verschob Termin",
// Localized event-row title for the project Verlauf tab \u2014 full noun
// phrase ("Frist ge\u00e4ndert") complementing the dashboard's verb form.
"event.title.project_created": "Projekt angelegt",
@@ -812,10 +814,12 @@ const translations: Record> = {
"event.title.deadline_completed": "Frist erledigt",
"event.title.deadline_reopened": "Frist wiederer\u00f6ffnet",
"event.title.deadline_deleted": "Frist gel\u00f6scht",
+ "event.title.deadline_project_changed": "Frist verschoben",
"event.title.deadlines_imported": "Fristen importiert",
"event.title.appointment_created": "Termin angelegt",
"event.title.appointment_updated": "Termin ge\u00e4ndert",
"event.title.appointment_deleted": "Termin gel\u00f6scht",
+ "event.title.appointment_project_changed": "Termin verschoben",
"event.title.checklist_created": "Checkliste angelegt",
"event.title.checklist_renamed": "Checkliste umbenannt",
"event.title.checklist_linked": "Checkliste verkn\u00fcpft",
@@ -836,10 +840,12 @@ const translations: Record> = {
"event.description.deadline_completed": "Frist \u201e{title}\u201c als erledigt markiert",
"event.description.deadline_reopened": "Frist \u201e{title}\u201c wieder ge\u00f6ffnet",
"event.description.deadline_deleted": "Frist \u201e{title}\u201c gel\u00f6scht",
+ "event.description.deadline_project_changed": "Frist \u201e{title}\u201c einer anderen Akte zugeordnet",
"event.description.deadlines_imported": "{count} Fristen aus Fristenrechner \u00fcbernommen",
"event.description.appointment_created": "Termin \u201e{title}\u201c angelegt",
"event.description.appointment_updated": "Termin \u201e{title}\u201c ge\u00e4ndert",
"event.description.appointment_deleted": "Termin \u201e{title}\u201c gel\u00f6scht",
+ "event.description.appointment_project_changed": "Termin \u201e{title}\u201c einer anderen Akte zugeordnet",
"dashboard.action.short.checklist_created": "legte Checkliste an",
"dashboard.action.short.checklist_renamed": "benannte Checkliste um",
"dashboard.action.short.checklist_unlinked": "trennte Checkliste",
@@ -2387,10 +2393,12 @@ const translations: Record> = {
"dashboard.action.short.deadline_completed": "completed deadline",
"dashboard.action.short.deadline_reopened": "reopened deadline",
"dashboard.action.short.deadline_deleted": "deleted deadline",
+ "dashboard.action.short.deadline_project_changed": "moved deadline",
"dashboard.action.short.deadlines_imported": "imported deadlines",
"dashboard.action.short.appointment_created": "added appointment",
"dashboard.action.short.appointment_updated": "updated appointment",
"dashboard.action.short.appointment_deleted": "deleted appointment",
+ "dashboard.action.short.appointment_project_changed": "moved appointment",
// Localized event-row title for the project Verlauf tab — full noun
// phrase ("Deadline updated") complementing the dashboard's verb form.
"event.title.project_created": "Project created",
@@ -2404,10 +2412,12 @@ const translations: Record> = {
"event.title.deadline_completed": "Deadline completed",
"event.title.deadline_reopened": "Deadline reopened",
"event.title.deadline_deleted": "Deadline deleted",
+ "event.title.deadline_project_changed": "Deadline moved",
"event.title.deadlines_imported": "Deadlines imported",
"event.title.appointment_created": "Appointment created",
"event.title.appointment_updated": "Appointment updated",
"event.title.appointment_deleted": "Appointment deleted",
+ "event.title.appointment_project_changed": "Appointment moved",
"event.title.checklist_created": "Checklist created",
"event.title.checklist_renamed": "Checklist renamed",
"event.title.checklist_linked": "Checklist linked",
@@ -2428,10 +2438,12 @@ const translations: Record> = {
"event.description.deadline_completed": "Deadline “{title}” completed",
"event.description.deadline_reopened": "Deadline “{title}” reopened",
"event.description.deadline_deleted": "Deadline “{title}” deleted",
+ "event.description.deadline_project_changed": "Deadline “{title}” moved to another matter",
"event.description.deadlines_imported": "{count} deadlines imported from Fristenrechner",
"event.description.appointment_created": "Appointment “{title}” added",
"event.description.appointment_updated": "Appointment “{title}” updated",
"event.description.appointment_deleted": "Appointment “{title}” deleted",
+ "event.description.appointment_project_changed": "Appointment “{title}” moved to another matter",
"dashboard.action.short.checklist_created": "added checklist",
"dashboard.action.short.checklist_renamed": "renamed checklist",
"dashboard.action.short.checklist_unlinked": "unlinked checklist",
diff --git a/frontend/src/deadlines-detail.tsx b/frontend/src/deadlines-detail.tsx
index e7844ff..78a2db9 100644
--- a/frontend/src/deadlines-detail.tsx
+++ b/frontend/src/deadlines-detail.tsx
@@ -44,6 +44,7 @@ export function renderDeadlinesDetail(): string {
+
diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts
index 2496bde..3f6797f 100644
--- a/frontend/src/i18n-keys.ts
+++ b/frontend/src/i18n-keys.ts
@@ -460,6 +460,7 @@ export type I18nKey =
| "dashboard.action.short.akte_created"
| "dashboard.action.short.appointment_created"
| "dashboard.action.short.appointment_deleted"
+ | "dashboard.action.short.appointment_project_changed"
| "dashboard.action.short.appointment_updated"
| "dashboard.action.short.checklist_created"
| "dashboard.action.short.checklist_deleted"
@@ -477,6 +478,7 @@ export type I18nKey =
| "dashboard.action.short.deadline_completed"
| "dashboard.action.short.deadline_created"
| "dashboard.action.short.deadline_deleted"
+ | "dashboard.action.short.deadline_project_changed"
| "dashboard.action.short.deadline_reopened"
| "dashboard.action.short.deadline_updated"
| "dashboard.action.short.deadlines_imported"
@@ -847,10 +849,12 @@ export type I18nKey =
| "einstellungen.title"
| "event.description.appointment_created"
| "event.description.appointment_deleted"
+ | "event.description.appointment_project_changed"
| "event.description.appointment_updated"
| "event.description.deadline_completed"
| "event.description.deadline_created"
| "event.description.deadline_deleted"
+ | "event.description.deadline_project_changed"
| "event.description.deadline_reopened"
| "event.description.deadline_updated"
| "event.description.deadlines_imported"
@@ -860,6 +864,7 @@ export type I18nKey =
| "event.note.parent.project"
| "event.title.appointment_created"
| "event.title.appointment_deleted"
+ | "event.title.appointment_project_changed"
| "event.title.appointment_updated"
| "event.title.checklist_created"
| "event.title.checklist_deleted"
@@ -870,6 +875,7 @@ export type I18nKey =
| "event.title.deadline_completed"
| "event.title.deadline_created"
| "event.title.deadline_deleted"
+ | "event.title.deadline_project_changed"
| "event.title.deadline_reopened"
| "event.title.deadline_updated"
| "event.title.deadlines_imported"
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index dc966ba..ed5c126 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -5940,6 +5940,17 @@ input[type="range"]::-moz-range-thumb {
margin-right: 0.25rem;
}
+.entity-ref-select {
+ font-family: var(--font-mono);
+ font-size: 0.85rem;
+ padding: 0.2rem 0.4rem;
+ border: 1px solid var(--color-accent);
+ border-radius: var(--radius);
+ background: var(--color-surface);
+ color: var(--color-text);
+ max-width: 100%;
+}
+
.entity-detail-actions {
display: flex;
gap: 0.5rem;
diff --git a/internal/services/appointment_service.go b/internal/services/appointment_service.go
index c301370..603d799 100644
--- a/internal/services/appointment_service.go
+++ b/internal/services/appointment_service.go
@@ -66,13 +66,21 @@ type CreateAppointmentInput struct {
}
// UpdateAppointmentInput is the partial-update payload for PATCH /api/appointments/{id}.
+//
+// ProjectID + ClearProject control the project move (t-paliad-140). Both
+// nil/false = leave project_id untouched. ClearProject=true unlinks the
+// appointment from its current project (only the creator may do this,
+// matching the personal-appointment edit gate). ProjectID set = move under
+// that project (visibility on the new project is enforced).
type UpdateAppointmentInput struct {
- Title *string `json:"title,omitempty"`
- Description *string `json:"description,omitempty"`
- StartAt *time.Time `json:"start_at,omitempty"`
- EndAt *time.Time `json:"end_at,omitempty"`
- Location *string `json:"location,omitempty"`
- AppointmentType *string `json:"appointment_type,omitempty"`
+ Title *string `json:"title,omitempty"`
+ Description *string `json:"description,omitempty"`
+ StartAt *time.Time `json:"start_at,omitempty"`
+ EndAt *time.Time `json:"end_at,omitempty"`
+ Location *string `json:"location,omitempty"`
+ AppointmentType *string `json:"appointment_type,omitempty"`
+ ProjectID *uuid.UUID `json:"project_id,omitempty"`
+ ClearProject bool `json:"clear_project,omitempty"`
}
// AppointmentListFilter narrows ListVisibleForUser results.
@@ -360,6 +368,41 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
}
appendSet("appointment_type", *input.AppointmentType)
}
+
+ // Project move (t-paliad-140). ClearProject takes precedence over
+ // ProjectID so a payload that sets both falls into the unlink branch
+ // rather than silently ignoring the contradiction. Visibility on the
+ // destination is enforced via projects.GetByID (matches Create).
+ // Unlinking to a personal appointment is creator-only — same gate
+ // personal-only Update branches enforce above — so a non-creator who
+ // can mutate the project-attached row can't strand it on someone else's
+ // personal calendar.
+ var movedFromProject *uuid.UUID
+ var movedToProject *uuid.UUID
+ if input.ClearProject {
+ if current.ProjectID != nil {
+ if current.CreatedBy == nil || *current.CreatedBy != userID {
+ return nil, fmt.Errorf("%w: only the creator can convert this Appointment to personal", ErrForbidden)
+ }
+ from := *current.ProjectID
+ movedFromProject = &from
+ appendSet("project_id", nil)
+ }
+ } else if input.ProjectID != nil {
+ if current.ProjectID == nil || *input.ProjectID != *current.ProjectID {
+ if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
+ return nil, err
+ }
+ to := *input.ProjectID
+ movedToProject = &to
+ if current.ProjectID != nil {
+ from := *current.ProjectID
+ movedFromProject = &from
+ }
+ appendSet("project_id", *input.ProjectID)
+ }
+ }
+
if len(sets) == 0 {
return current, nil
}
@@ -379,12 +422,57 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
return nil, fmt.Errorf("update appointment: %w", err)
}
- if current.ProjectID != nil {
- desc := current.Title
- descPtr := &desc
- if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID, "appointment_updated", "Appointment updated", descPtr,
- map[string]any{"appointment_id": appointmentID}); err != nil {
- return nil, err
+ // Audit emission. Project moves (t-paliad-140) get their own
+ // appointment_project_changed pair so the OLD and NEW project rows
+ // keep an honest chronology. Edits to other fields land as
+ // appointment_updated on whichever project the row sits on AFTER the
+ // move (or on the source project if it was unlinked). Personal
+ // appointments don't have audit history, so unlink/link rows on the
+ // "personal" side are skipped.
+ desc := current.Title
+ descPtr := &desc
+ if movedFromProject != nil || movedToProject != nil {
+ moveMeta := map[string]any{"appointment_id": appointmentID}
+ if movedFromProject != nil {
+ moveMeta["from_project_id"] = *movedFromProject
+ }
+ if movedToProject != nil {
+ moveMeta["to_project_id"] = *movedToProject
+ }
+ if movedFromProject != nil {
+ if err := insertProjectEventWithMeta(ctx, tx, *movedFromProject, userID,
+ "appointment_project_changed", "Appointment project changed", descPtr, moveMeta); err != nil {
+ return nil, err
+ }
+ }
+ if movedToProject != nil {
+ if err := insertProjectEventWithMeta(ctx, tx, *movedToProject, userID,
+ "appointment_project_changed", "Appointment project changed", descPtr, moveMeta); err != nil {
+ return nil, err
+ }
+ }
+ }
+ otherFieldsTouched := input.Title != nil || input.Description != nil ||
+ input.StartAt != nil || input.EndAt != nil || input.Location != nil ||
+ input.AppointmentType != nil
+ if otherFieldsTouched {
+ // After-move project. If the row is now personal (unlink), no
+ // audit row — personal appointments don't surface in any
+ // project's Verlauf.
+ var auditProject *uuid.UUID
+ switch {
+ case movedToProject != nil:
+ auditProject = movedToProject
+ case movedFromProject != nil:
+ // Unlink: no audit project
+ default:
+ auditProject = current.ProjectID
+ }
+ if auditProject != nil {
+ if err := insertProjectEventWithMeta(ctx, tx, *auditProject, userID, "appointment_updated", "Appointment updated", descPtr,
+ map[string]any{"appointment_id": appointmentID}); err != nil {
+ return nil, err
+ }
}
}
if err := tx.Commit(); err != nil {
diff --git a/internal/services/deadline_service.go b/internal/services/deadline_service.go
index 4a66ea0..14a45ec 100644
--- a/internal/services/deadline_service.go
+++ b/internal/services/deadline_service.go
@@ -61,13 +61,19 @@ type CreateDeadlineInput struct {
// UpdateDeadlineInput is the partial-update payload for PATCH.
// EventTypeIDs uses pointer-to-slice semantics: nil = leave existing
// attachments untouched; non-nil (including empty) = replace.
+//
+// ProjectID, when non-nil, moves the deadline under a different project
+// (t-paliad-140). The caller must be able to see the new project; the
+// service emits a deadline_project_changed audit row on both the old and
+// new project so each side's Verlauf still shows the move.
type UpdateDeadlineInput struct {
- Title *string `json:"title,omitempty"`
- Description *string `json:"description,omitempty"`
- DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
- Notes *string `json:"notes,omitempty"`
- Status *string `json:"status,omitempty"`
- EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
+ Title *string `json:"title,omitempty"`
+ Description *string `json:"description,omitempty"`
+ DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
+ Notes *string `json:"notes,omitempty"`
+ Status *string `json:"status,omitempty"`
+ ProjectID *uuid.UUID `json:"project_id,omitempty"`
+ EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
}
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
@@ -410,6 +416,22 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
return nil, err
}
}
+
+ // Project move (t-paliad-140). Visibility on the destination is enforced
+ // the same way as on Create — a GetByID round-trip through ProjectService
+ // returns ErrNotVisible if the user can't see the target. Same-project
+ // "moves" are silently dropped so a UI that always sends project_id in
+ // the PATCH payload doesn't churn the Verlauf.
+ var movedFromProject *uuid.UUID
+ if input.ProjectID != nil && *input.ProjectID != current.ProjectID {
+ if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
+ return nil, err
+ }
+ appendSet("project_id", *input.ProjectID)
+ from := current.ProjectID
+ movedFromProject = &from
+ }
+
if len(sets) == 0 && input.EventTypeIDs == nil {
return current, nil
}
@@ -441,11 +463,43 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
// Description carries value-only payload (the deadline title); frontend
// renders via the localized event.description.deadline_updated template.
// Same pattern below for completed/reopened/deleted/created.
+ //
+ // Audit shape for project moves (t-paliad-140): emit
+ // deadline_project_changed on both old and new project rows so each
+ // side's Verlauf still shows the move (the row is gone from the old
+ // project, but its history shouldn't be). If the same PATCH also
+ // touched other fields, the new project additionally records a
+ // deadline_updated covering those edits.
desc := current.Title
descPtr := &desc
- if err := insertProjectEventWithMeta(ctx, tx, current.ProjectID, userID, "deadline_updated", "Deadline updated", descPtr,
- map[string]any{"deadline_id": deadlineID}); err != nil {
- return nil, err
+ if movedFromProject != nil {
+ moveMeta := map[string]any{
+ "deadline_id": deadlineID,
+ "from_project_id": *movedFromProject,
+ "to_project_id": *input.ProjectID,
+ }
+ if err := insertProjectEventWithMeta(ctx, tx, *movedFromProject, userID,
+ "deadline_project_changed", "Deadline project changed", descPtr, moveMeta); err != nil {
+ return nil, err
+ }
+ if err := insertProjectEventWithMeta(ctx, tx, *input.ProjectID, userID,
+ "deadline_project_changed", "Deadline project changed", descPtr, moveMeta); err != nil {
+ return nil, err
+ }
+ }
+ // Did the PATCH touch anything beyond the project move?
+ otherFieldsTouched := input.Title != nil || input.Description != nil ||
+ input.DueDate != nil || input.Notes != nil || input.Status != nil ||
+ input.EventTypeIDs != nil
+ if otherFieldsTouched {
+ auditProject := current.ProjectID
+ if movedFromProject != nil {
+ auditProject = *input.ProjectID
+ }
+ if err := insertProjectEventWithMeta(ctx, tx, auditProject, userID, "deadline_updated", "Deadline updated", descPtr,
+ map[string]any{"deadline_id": deadlineID}); err != nil {
+ return nil, err
+ }
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update deadline: %w", err)