diff --git a/backend/admin_milestone_roles.go b/backend/admin_milestone_roles.go index e57b983..0083919 100644 --- a/backend/admin_milestone_roles.go +++ b/backend/admin_milestone_roles.go @@ -3,6 +3,8 @@ package main import ( "context" "errors" + "fmt" + "log" "net/http" "net/url" "strings" @@ -57,6 +59,21 @@ type milestoneRoleUserInput struct { Source string `json:"source"` } +func writeMilestoneRoleActionError(w http.ResponseWriter, status int, message string, err error) { + if err != nil { + log.Printf("%s: %v", message, err) + } + + payload := map[string]string{ + "error": message, + } + if err != nil { + payload["details"] = err.Error() + } + + writeJSON(w, status, payload) +} + func (s *Server) handleAdminListMilestoneRoles(w http.ResponseWriter, r *http.Request) { currentUser, _ := userFromContext(r.Context()) isAdministrator := userHasRight(currentUser, "admin") @@ -241,26 +258,18 @@ func (s *Server) handleAdminUpdateMilestoneRole(w http.ResponseWriter, r *http.R return } - users, err := listOperationMilestoneRoleUsers(r.Context(), config, roleID) - if err != nil { - writeError(w, http.StatusBadGateway, "Milestone-Rollenbenutzer konnten nicht geladen werden") - return - } - role.Name = roleName role.DisplayName = strings.TrimSpace(input.DisplayName) if role.DisplayName == "" { role.DisplayName = roleName } - if role.Data == nil { - role.Data = map[string]any{} - } - role.Data["name"] = roleName - role.Data["displayName"] = role.DisplayName - role.Data["description"] = strings.TrimSpace(input.Description) - if err := putOperationMilestoneRole(r.Context(), config, *role, users); err != nil { - writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gespeichert werden") + if err := patchOperationMilestoneRole(r.Context(), config, role.ID, map[string]any{ + "name": roleName, + "displayName": role.DisplayName, + "description": strings.TrimSpace(input.Description), + }); err != nil { + writeMilestoneRoleActionError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gespeichert werden", err) return } @@ -269,7 +278,7 @@ func (s *Server) handleAdminUpdateMilestoneRole(w http.ResponseWriter, r *http.R writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden") return } - users, _ = listOperationMilestoneRoleUsers(r.Context(), config, roleID) + users, _ := listOperationMilestoneRoleUsers(r.Context(), config, roleID) writeJSON(w, http.StatusOK, map[string]any{ "role": milestoneRoleResponseFromRole(*role, users), @@ -350,20 +359,25 @@ func (s *Server) handleAdminAddMilestoneRoleUsers(w http.ResponseWriter, r *http } nextUsers := users + usersToAdd := []map[string]any{} for _, userInput := range input.Users { user := milestoneRoleUserReferenceFromInput(userInput) if milestoneItemID(user) == "" || milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) { continue } + if operationMilestoneObjectByID(nextUsers, milestoneItemID(user)) != nil { + continue + } nextUsers = appendOperationMilestoneObjectByID(nextUsers, user) + usersToAdd = appendOperationMilestoneObjectByID(usersToAdd, user) } - if len(nextUsers) == len(users) { - writeError(w, http.StatusBadRequest, "Kein gültiger AD-Benutzer ausgewählt") + if len(usersToAdd) == 0 { + writeError(w, http.StatusBadRequest, "Kein gültiger Benutzer ausgewählt") return } - if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil { - writeError(w, http.StatusBadGateway, "AD-Benutzer konnten der Milestone-Rolle nicht hinzugefügt werden") + if err := addOperationMilestoneRoleMembers(r.Context(), config, role.ID, usersToAdd); err != nil { + writeMilestoneRoleActionError(w, http.StatusBadGateway, "Benutzer konnten der Milestone-Rolle nicht hinzugefügt werden", err) return } @@ -405,23 +419,25 @@ func (s *Server) handleAdminRemoveMilestoneRoleUser(w http.ResponseWriter, r *ht } nextUsers := make([]map[string]any, 0, len(users)) + usersToRemove := []map[string]any{} for _, user := range users { if strings.EqualFold(milestoneItemID(user), userID) { if milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) { writeError(w, http.StatusForbidden, "Dieser Benutzer darf nicht entfernt werden") return } + usersToRemove = append(usersToRemove, user) continue } nextUsers = append(nextUsers, user) } - if len(nextUsers) == len(users) { + if len(usersToRemove) == 0 { writeError(w, http.StatusNotFound, "Benutzer wurde in der Rolle nicht gefunden") return } - if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil { - writeError(w, http.StatusBadGateway, "Benutzer konnte nicht aus der Milestone-Rolle entfernt werden") + if err := removeOperationMilestoneRoleMembers(r.Context(), config, role.ID, usersToRemove); err != nil { + writeMilestoneRoleActionError(w, http.StatusBadGateway, "Benutzer konnte nicht aus der Milestone-Rolle entfernt werden", err) return } @@ -493,9 +509,7 @@ func (s *Server) handleAdminListMilestoneBasicUsers(w http.ResponseWriter, r *ht continue } user.Source = "basic" - if user.Domain == "" { - user.Domain = "Basic" - } + user.Domain = "" users = append(users, user) } @@ -614,8 +628,12 @@ func milestoneRoleUserItemFromObject(user map[string]any) milestoneRoleUserItem if domain == "" && distinguishedName != "" { domain = activeDirectoryDomainFromDN(distinguishedName) } - if domain == "" && source == "basic" { - domain = "Basic" + if milestoneRoleUserDomainIsBasic(domain) { + source = "basic" + domain = "" + } + if source == "basic" { + domain = "" } if displayName == "" { @@ -659,6 +677,32 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin source = "ad" } + if milestoneRoleUserDomainIsBasic(domain) { + source = "basic" + domain = "" + } + if splitDomain, splitUsername := splitMilestoneDomainUser(username); splitDomain != "" { + if domain == "" && source != "basic" { + domain = splitDomain + } + username = splitUsername + } + if splitDomain, splitName := splitMilestoneDomainUser(name); splitDomain != "" { + if domain == "" && source != "basic" { + domain = splitDomain + } + name = splitName + } + if splitDomain, splitDisplayName := splitMilestoneDomainUser(displayName); splitDomain != "" { + if domain == "" && source != "basic" { + domain = splitDomain + } + displayName = splitDisplayName + } + if source == "basic" { + domain = "" + } + if userID == "" { userID = userSID } @@ -688,8 +732,6 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin "userName": username, "username": username, "email": email, - "domain": domain, - "source": source, "relations": map[string]any{ "self": map[string]any{ "type": milestoneRoleUserRelationType(source), @@ -697,6 +739,9 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin }, }, } + if domain != "" { + user["domain"] = domain + } if userSID != "" { user["sid"] = userSID user["objectSid"] = userSID @@ -708,7 +753,140 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin return user } +func addOperationMilestoneRoleMembers( + ctx context.Context, + config milestoneRuntimeConfig, + roleID string, + users []map[string]any, +) error { + return postOperationMilestoneRoleMemberTasks(ctx, config, roleID, "AddRoleMember", users) +} + +func removeOperationMilestoneRoleMembers( + ctx context.Context, + config milestoneRuntimeConfig, + roleID string, + users []map[string]any, +) error { + return postOperationMilestoneRoleMemberTasks(ctx, config, roleID, "RemoveRoleMember", users) +} + +func postOperationMilestoneRoleMemberTasks( + ctx context.Context, + config milestoneRuntimeConfig, + roleID string, + taskName string, + users []map[string]any, +) error { + roleID = strings.TrimSpace(roleID) + taskName = strings.TrimSpace(taskName) + if roleID == "" { + return errors.New("role id is missing") + } + if taskName == "" { + return errors.New("role member task name is missing") + } + + for _, user := range users { + payload, err := milestoneRoleMemberTaskPayload(user) + if err != nil { + return err + } + if len(payload) == 0 { + continue + } + + body, _, err := operationMilestoneRequestJSON( + ctx, + config, + http.MethodPost, + "/API/rest/v1/roles/"+url.PathEscape(roleID)+"/users?task="+url.QueryEscape(taskName), + payload, + ) + if err != nil { + return err + } + if result, ok := milestoneTaskResultFromBody(body); ok && milestoneTaskResultIsError(result) { + return fmt.Errorf( + "milestone %s returned error state=%s errorCode=%s errorText=%s", + taskName, + result.State, + result.ErrorCode, + result.ErrorText, + ) + } + } + + return nil +} + +func milestoneRoleMemberTaskItemSelection(user map[string]any) map[string]any { + userID := strings.TrimSpace(milestoneItemID(user)) + if userID == "" { + return nil + } + + source := milestoneRoleUserSourceFromObject(user) + itemSelection := map[string]any{ + "type": milestoneRoleUserRelationType(source), + "id": userID, + } + + return itemSelection +} + +func milestoneRoleMemberTaskPayload(user map[string]any) (map[string]any, error) { + itemSelection := milestoneRoleMemberTaskItemSelection(user) + userID := strings.TrimSpace(milestoneStringValue(itemSelection["id"])) + if userID == "" { + return nil, nil + } + + payload := map[string]any{ + "itemSelection": itemSelection, + } + + if milestoneRoleUserSourceFromObject(user) != "basic" { + sid := strings.TrimSpace(milestoneStringValue(user["sid"])) + if sid == "" { + sid = strings.TrimSpace(milestoneStringValue(user["objectSid"])) + } + if sid == "" && strings.HasPrefix(strings.ToUpper(userID), "S-") { + sid = userID + } + if sid == "" { + return nil, fmt.Errorf( + "Windows-Benutzer %q hat keine SID. AddRoleMember erwartet den Parameter sid.", + operationMilestoneItemDisplayName(user), + ) + } + + payload["sid"] = sid + } + + return payload, nil +} + +func operationMilestoneObjectByID(items []map[string]any, id string) map[string]any { + id = strings.TrimSpace(id) + if id == "" { + return nil + } + + for _, item := range items { + if strings.EqualFold(milestoneItemID(item), id) { + return item + } + } + + return nil +} + func milestoneRoleUserSourceFromObject(user map[string]any) string { + if milestoneRoleUserDomainIsBasic(milestoneRoleUserDomainFromObject(user)) { + return "basic" + } + source := milestoneRoleUserSource(milestoneStringValue(user["source"])) if source != "" { return source @@ -733,6 +911,13 @@ func milestoneRoleUserSourceFromObject(user map[string]any) string { return "ad" } +func milestoneRoleUserDomainIsBasic(domain string) bool { + domain = strings.TrimSpace(domain) + domain = strings.TrimPrefix(domain, "[") + domain = strings.TrimSuffix(domain, "]") + return strings.EqualFold(domain, "basic") +} + func milestoneRoleUserSource(source string) string { source = strings.TrimSpace(strings.ToLower(source)) switch source { @@ -802,6 +987,7 @@ func milestoneProtectedUserNameIsHidden(value string) bool { switch value { case "nt-autorität\\netzwerkdienst", "nt authority\\network service", + "tegadmin", "tegdssd\\tegadmin", "vordefiniert\\administratoren", "builtin\\administrators", diff --git a/backend/calendar_events.go b/backend/calendar_events.go new file mode 100644 index 0000000..aa0f2b7 --- /dev/null +++ b/backend/calendar_events.go @@ -0,0 +1,775 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type CalendarEvent struct { + ID string `json:"id"` + Title string `json:"title"` + Start string `json:"start"` + End string `json:"end"` + AllDay bool `json:"allDay"` + Location string `json:"location"` + Description string `json:"description"` + Color string `json:"color"` + Recurrence string `json:"recurrence"` + // recurrenceWeekday verwendet time.Weekday/JavaScript-Date.getDay(): 0=Sonntag. + RecurrenceWeekday *int `json:"recurrenceWeekday,omitempty"` + RecurrenceOrdinal *int `json:"recurrenceOrdinal,omitempty"` + RecurrenceExceptions []string `json:"recurrenceExceptions"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CalendarEventRequest struct { + Title string `json:"title"` + Start string `json:"start"` + End string `json:"end"` + AllDay bool `json:"allDay"` + Location string `json:"location"` + Description string `json:"description"` + Color string `json:"color"` + Recurrence string `json:"recurrence"` + RecurrenceWeekday *int `json:"recurrenceWeekday"` + RecurrenceOrdinal *int `json:"recurrenceOrdinal"` + RecurrenceExceptions []string `json:"recurrenceExceptions"` +} + +type CalendarEventOccurrenceRequest struct { + OccurrenceStart string `json:"occurrenceStart"` + Event CalendarEventRequest `json:"event"` +} + +const calendarEventDateTimeLayout = "2006-01-02T15:04" + +var allowedCalendarEventColors = map[string]bool{ + "indigo": true, + "blue": true, + "emerald": true, + "amber": true, + "rose": true, + "slate": true, +} + +var allowedCalendarEventRecurrences = map[string]bool{ + "none": true, + "daily": true, + "weekly": true, + "monthly": true, + "monthlyNthWeekday": true, + "yearly": true, +} + +func (s *Server) ensureCalendarEventsTable(ctx context.Context) error { + queries := []string{ + `CREATE EXTENSION IF NOT EXISTS pgcrypto`, + ` + CREATE TABLE IF NOT EXISTS calendar_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + start_at TIMESTAMP NOT NULL, + end_at TIMESTAMP NOT NULL, + all_day BOOLEAN NOT NULL DEFAULT false, + location TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT 'indigo' + CHECK (color IN ('indigo', 'blue', 'emerald', 'amber', 'rose', 'slate')), + recurrence TEXT NOT NULL DEFAULT 'none', + recurrence_weekday INTEGER, + recurrence_ordinal INTEGER, + recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + updated_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT calendar_events_valid_range CHECK (end_at > start_at) + ) + `, + ` + DO $$ + DECLARE + constraint_name TEXT; + BEGIN + FOR constraint_name IN + SELECT c.conname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'calendar_events' + AND c.contype = 'c' + AND pg_get_constraintdef(c.oid) ILIKE '%recurrence%' + LOOP + EXECUTE format('ALTER TABLE calendar_events DROP CONSTRAINT %I', constraint_name); + END LOOP; + END + $$ + `, + `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_weekday INTEGER`, + `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_ordinal INTEGER`, + `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB`, + `UPDATE calendar_events SET recurrence_exceptions = '[]'::JSONB WHERE recurrence_exceptions IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_calendar_events_start_at ON calendar_events(start_at)`, + } + + for _, query := range queries { + if _, err := s.db.Exec(ctx, query); err != nil { + return err + } + } + + return nil +} + +func parseCalendarEventDateTime(value string) (time.Time, error) { + value = strings.TrimSpace(value) + layouts := []string{ + calendarEventDateTimeLayout, + "2006-01-02T15:04:05", + time.RFC3339, + } + + for _, layout := range layouts { + parsed, err := time.ParseInLocation(layout, value, time.Local) + if err == nil { + return parsed, nil + } + } + + return time.Time{}, errors.New("invalid datetime") +} + +func cleanCalendarEventExceptions(values []string) ([]string, error) { + result := []string{} + seen := map[string]bool{} + + for _, value := range values { + parsed, err := parseCalendarEventDateTime(value) + if err != nil { + return nil, errors.New("Eine Wiederholungs-Ausnahme ist ungültig") + } + + normalized := formatCalendarEventDateTime(parsed) + if seen[normalized] { + continue + } + + seen[normalized] = true + result = append(result, normalized) + } + + return result, nil +} + +func appendCalendarEventException(values []string, occurrenceStart string) ([]string, error) { + next := append([]string{}, values...) + next = append(next, occurrenceStart) + return cleanCalendarEventExceptions(next) +} + +func normalizeCalendarEventRequest(input CalendarEventRequest) (CalendarEventRequest, time.Time, time.Time, error) { + input.Title = strings.TrimSpace(input.Title) + input.Location = strings.TrimSpace(input.Location) + input.Description = strings.TrimSpace(input.Description) + input.Color = strings.TrimSpace(input.Color) + input.Recurrence = strings.TrimSpace(input.Recurrence) + + if input.Title == "" { + return input, time.Time{}, time.Time{}, errors.New("Bitte gib einen Titel ein") + } + + if len([]rune(input.Title)) > 120 { + return input, time.Time{}, time.Time{}, errors.New("Der Titel darf maximal 120 Zeichen lang sein") + } + + if len([]rune(input.Location)) > 160 { + return input, time.Time{}, time.Time{}, errors.New("Der Ort darf maximal 160 Zeichen lang sein") + } + + if len([]rune(input.Description)) > 1000 { + return input, time.Time{}, time.Time{}, errors.New("Die Beschreibung darf maximal 1000 Zeichen lang sein") + } + + if input.Color == "" { + input.Color = "indigo" + } + if !allowedCalendarEventColors[input.Color] { + return input, time.Time{}, time.Time{}, errors.New("Ungültige Terminfarbe") + } + + if input.Recurrence == "" { + input.Recurrence = "none" + } + if !allowedCalendarEventRecurrences[input.Recurrence] { + return input, time.Time{}, time.Time{}, errors.New("Ungültige Wiederholung") + } + + if input.Recurrence == "monthlyNthWeekday" { + if input.RecurrenceWeekday == nil || + *input.RecurrenceWeekday < 0 || + *input.RecurrenceWeekday > 6 { + return input, time.Time{}, time.Time{}, errors.New("Bitte wähle einen gültigen Wochentag für die Wiederholung") + } + + if input.RecurrenceOrdinal == nil || + *input.RecurrenceOrdinal < 1 || + *input.RecurrenceOrdinal > 5 { + return input, time.Time{}, time.Time{}, errors.New("Bitte wähle eine gültige Woche im Monat") + } + } else { + input.RecurrenceWeekday = nil + input.RecurrenceOrdinal = nil + } + + exceptions, err := cleanCalendarEventExceptions(input.RecurrenceExceptions) + if err != nil { + return input, time.Time{}, time.Time{}, err + } + input.RecurrenceExceptions = exceptions + + start, err := parseCalendarEventDateTime(input.Start) + if err != nil { + return input, time.Time{}, time.Time{}, errors.New("Der Startzeitpunkt ist ungültig") + } + + end, err := parseCalendarEventDateTime(input.End) + if err != nil { + return input, time.Time{}, time.Time{}, errors.New("Der Endzeitpunkt ist ungültig") + } + + if !end.After(start) { + return input, time.Time{}, time.Time{}, errors.New("Das Ende muss nach dem Beginn liegen") + } + + return input, start, end, nil +} + +func formatCalendarEventDateTime(value time.Time) string { + return value.Format(calendarEventDateTimeLayout) +} + +func scanCalendarEvent(row pgx.Row) (CalendarEvent, error) { + var event CalendarEvent + var start time.Time + var end time.Time + var rawExceptions []byte + + err := row.Scan( + &event.ID, + &event.Title, + &start, + &end, + &event.AllDay, + &event.Location, + &event.Description, + &event.Color, + &event.Recurrence, + &event.RecurrenceWeekday, + &event.RecurrenceOrdinal, + &rawExceptions, + &event.CreatedAt, + &event.UpdatedAt, + ) + if err != nil { + return CalendarEvent{}, err + } + + event.Start = formatCalendarEventDateTime(start) + event.End = formatCalendarEventDateTime(end) + event.RecurrenceExceptions = []string{} + if len(rawExceptions) > 0 { + _ = json.Unmarshal(rawExceptions, &event.RecurrenceExceptions) + } + + return event, nil +} + +const calendarEventReturningColumns = ` + id::TEXT, + title, + start_at, + end_at, + all_day, + COALESCE(location, ''), + COALESCE(description, ''), + color, + recurrence, + recurrence_weekday, + recurrence_ordinal, + recurrence_exceptions, + created_at, + updated_at +` + +type calendarEventRowQueryer interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +func loadCalendarEventByID(ctx context.Context, queryer calendarEventRowQueryer, eventID string) (CalendarEvent, error) { + return scanCalendarEvent(queryer.QueryRow( + ctx, + ` + SELECT + `+calendarEventReturningColumns+` + FROM calendar_events + WHERE id = $1 + `, + eventID, + )) +} + +func (s *Server) handleListCalendarEvents(w http.ResponseWriter, r *http.Request) { + if err := s.ensureCalendarEventsTable(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + `+calendarEventReturningColumns+` + FROM calendar_events + ORDER BY start_at ASC, created_at ASC + `, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht geladen werden") + return + } + defer rows.Close() + + events := []CalendarEvent{} + for rows.Next() { + event, err := scanCalendarEvent(rows) + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht gelesen werden") + return + } + + events = append(events, event) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vollständig gelesen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "events": events, + }) +} + +func (s *Server) handleCreateCalendarEvent(w http.ResponseWriter, r *http.Request) { + if err := s.ensureCalendarEventsTable(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") + return + } + + var input CalendarEventRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + input, start, end, err := normalizeCalendarEventRequest(input) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + userID, _ := s.currentUserIDFromRequest(r) + exceptionsRaw, err := json.Marshal(input.RecurrenceExceptions) + if err != nil { + writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig") + return + } + + event, err := scanCalendarEvent(s.db.QueryRow( + r.Context(), + ` + INSERT INTO calendar_events ( + title, + start_at, + end_at, + all_day, + location, + description, + color, + recurrence, + recurrence_weekday, + recurrence_ordinal, + recurrence_exceptions, + created_by, + updated_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::JSONB, NULLIF($12, '')::UUID, NULLIF($12, '')::UUID) + RETURNING + `+calendarEventReturningColumns, + input.Title, + start, + end, + input.AllDay, + input.Location, + input.Description, + input.Color, + input.Recurrence, + input.RecurrenceWeekday, + input.RecurrenceOrdinal, + string(exceptionsRaw), + userID, + )) + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht erstellt werden") + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "event": event, + }) +} + +func (s *Server) handleUpdateCalendarEvent(w http.ResponseWriter, r *http.Request) { + eventID := strings.TrimSpace(r.PathValue("id")) + if eventID == "" { + writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt") + return + } + + if err := s.ensureCalendarEventsTable(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") + return + } + + var input CalendarEventRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + input, start, end, err := normalizeCalendarEventRequest(input) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + userID, _ := s.currentUserIDFromRequest(r) + exceptionsRaw, err := json.Marshal(input.RecurrenceExceptions) + if err != nil { + writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig") + return + } + + event, err := scanCalendarEvent(s.db.QueryRow( + r.Context(), + ` + UPDATE calendar_events + SET + title = $2, + start_at = $3, + end_at = $4, + all_day = $5, + location = $6, + description = $7, + color = $8, + recurrence = $9, + recurrence_weekday = $10, + recurrence_ordinal = $11, + recurrence_exceptions = $12::JSONB, + updated_by = NULLIF($13, '')::UUID, + updated_at = now() + WHERE id = $1 + RETURNING + `+calendarEventReturningColumns, + eventID, + input.Title, + start, + end, + input.AllDay, + input.Location, + input.Description, + input.Color, + input.Recurrence, + input.RecurrenceWeekday, + input.RecurrenceOrdinal, + string(exceptionsRaw), + userID, + )) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "event": event, + }) +} + +func (s *Server) handleUpdateCalendarEventOccurrence(w http.ResponseWriter, r *http.Request) { + eventID := strings.TrimSpace(r.PathValue("id")) + if eventID == "" { + writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt") + return + } + + if err := s.ensureCalendarEventsTable(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") + return + } + + var payload CalendarEventOccurrenceRequest + if err := readJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + occurrenceStart, err := parseCalendarEventDateTime(payload.OccurrenceStart) + if err != nil { + writeError(w, http.StatusBadRequest, "Der gewählte Serientermin ist ungültig") + return + } + occurrenceStartValue := formatCalendarEventDateTime(occurrenceStart) + + payload.Event.Recurrence = "none" + payload.Event.RecurrenceWeekday = nil + payload.Event.RecurrenceOrdinal = nil + payload.Event.RecurrenceExceptions = nil + + input, start, end, err := normalizeCalendarEventRequest(payload.Event) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht vorbereitet werden") + return + } + defer tx.Rollback(r.Context()) + + series, err := loadCalendarEventByID(r.Context(), tx, eventID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht geladen werden") + return + } + if series.Recurrence == "none" { + writeError(w, http.StatusBadRequest, "Der Kalendereintrag ist keine Serie") + return + } + + nextExceptions, err := appendCalendarEventException(series.RecurrenceExceptions, occurrenceStartValue) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + exceptionsRaw, err := json.Marshal(nextExceptions) + if err != nil { + writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig") + return + } + + userID, _ := s.currentUserIDFromRequest(r) + + updatedSeries, err := scanCalendarEvent(tx.QueryRow( + r.Context(), + ` + UPDATE calendar_events + SET + recurrence_exceptions = $2::JSONB, + updated_by = NULLIF($3, '')::UUID, + updated_at = now() + WHERE id = $1 + RETURNING + `+calendarEventReturningColumns, + eventID, + string(exceptionsRaw), + userID, + )) + if err != nil { + writeError(w, http.StatusInternalServerError, "Serientermin konnte nicht aktualisiert werden") + return + } + + event, err := scanCalendarEvent(tx.QueryRow( + r.Context(), + ` + INSERT INTO calendar_events ( + title, + start_at, + end_at, + all_day, + location, + description, + color, + recurrence, + recurrence_weekday, + recurrence_ordinal, + recurrence_exceptions, + created_by, + updated_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'none', NULL, NULL, '[]'::JSONB, NULLIF($8, '')::UUID, NULLIF($8, '')::UUID) + RETURNING + `+calendarEventReturningColumns, + input.Title, + start, + end, + input.AllDay, + input.Location, + input.Description, + input.Color, + userID, + )) + if err != nil { + writeError(w, http.StatusInternalServerError, "Ausgewählter Termin konnte nicht gespeichert werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "series": updatedSeries, + "event": event, + }) +} + +func (s *Server) handleDeleteCalendarEventOccurrence(w http.ResponseWriter, r *http.Request) { + eventID := strings.TrimSpace(r.PathValue("id")) + if eventID == "" { + writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt") + return + } + + if err := s.ensureCalendarEventsTable(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") + return + } + + var payload struct { + OccurrenceStart string `json:"occurrenceStart"` + } + if err := readJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + occurrenceStart, err := parseCalendarEventDateTime(payload.OccurrenceStart) + if err != nil { + writeError(w, http.StatusBadRequest, "Der gewählte Serientermin ist ungültig") + return + } + occurrenceStartValue := formatCalendarEventDateTime(occurrenceStart) + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht vorbereitet werden") + return + } + defer tx.Rollback(r.Context()) + + series, err := loadCalendarEventByID(r.Context(), tx, eventID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht geladen werden") + return + } + if series.Recurrence == "none" { + writeError(w, http.StatusBadRequest, "Der Kalendereintrag ist keine Serie") + return + } + + nextExceptions, err := appendCalendarEventException(series.RecurrenceExceptions, occurrenceStartValue) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + exceptionsRaw, err := json.Marshal(nextExceptions) + if err != nil { + writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig") + return + } + + userID, _ := s.currentUserIDFromRequest(r) + + updatedSeries, err := scanCalendarEvent(tx.QueryRow( + r.Context(), + ` + UPDATE calendar_events + SET + recurrence_exceptions = $2::JSONB, + updated_by = NULLIF($3, '')::UUID, + updated_at = now() + WHERE id = $1 + RETURNING + `+calendarEventReturningColumns, + eventID, + string(exceptionsRaw), + userID, + )) + if err != nil { + writeError(w, http.StatusInternalServerError, "Serientermin konnte nicht aktualisiert werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gelöscht werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "event": updatedSeries, + }) +} + +func (s *Server) handleDeleteCalendarEvent(w http.ResponseWriter, r *http.Request) { + eventID := strings.TrimSpace(r.PathValue("id")) + if eventID == "" { + writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt") + return + } + + if err := s.ensureCalendarEventsTable(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") + return + } + + result, err := s.db.Exec( + r.Context(), + `DELETE FROM calendar_events WHERE id = $1`, + eventID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gelöscht werden") + return + } + + if result.RowsAffected() == 0 { + writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "message": "Kalendereintrag gelöscht", + }) +} diff --git a/backend/operations_milestone_setup.go b/backend/operations_milestone_setup.go index 62add58..d2bd19a 100644 --- a/backend/operations_milestone_setup.go +++ b/backend/operations_milestone_setup.go @@ -850,9 +850,16 @@ func (s *Server) ensureOperationMilestoneRoleUser( } if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { - roleUsers := appendOperationMilestoneObjectByID(operationMilestoneObjectListFromValue(role.Data["users"]), selectedUser) - if err := putOperationMilestoneRole(ctx, config, *role, roleUsers); err != nil { - return nil, err + roleUsers, listErr := listOperationMilestoneRoleUsers(ctx, config, role.ID) + if listErr != nil { + roleUsers = operationMilestoneObjectListFromValue(role.Data["users"]) + } + + if operationMilestoneObjectByID(roleUsers, milestoneItemID(selectedUser)) == nil { + if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, []map[string]any{selectedUser}); err != nil { + return nil, err + } + roleUsers = appendOperationMilestoneObjectByID(roleUsers, selectedUser) } role.Data["users"] = roleUsers } @@ -892,21 +899,28 @@ func (s *Server) ensureOperationMilestoneRoleUser( } } - roleUsers := operationMilestoneObjectListFromValue(role.Data["users"]) - if len(roleUsers) == 0 && !hasOperationMilestoneUserSelection(input) { - roleUsers = appendOperationMilestoneObjectsByID(roleUsers, sourceUsers...) + roleUsers := sourceUsers + if len(roleUsers) == 0 { + roleUsers = operationMilestoneObjectListFromValue(role.Data["users"]) } + usersToAdd := []map[string]any{} for _, targetUser := range usersToAssign { if milestoneItemID(targetUser) == "" { return nil, fmt.Errorf("AD-Benutzer %q hat keine Milestone-ID", targetUserName) } + if operationMilestoneObjectByID(roleUsers, milestoneItemID(targetUser)) != nil { + continue + } + usersToAdd = appendOperationMilestoneObjectByID(usersToAdd, targetUser) roleUsers = appendOperationMilestoneObjectByID(roleUsers, targetUser) } - if err := putOperationMilestoneRole(ctx, config, *role, roleUsers); err != nil { - return nil, err + if len(usersToAdd) > 0 { + if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, usersToAdd); err != nil { + return nil, err + } } role.Data["users"] = roleUsers diff --git a/backend/routes.go b/backend/routes.go index ebf1bcc..ffd26df 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -113,6 +113,12 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings)) mux.HandleFunc("PUT /settings/presence/status", s.requireAuth(s.handleUpdatePresenceStatus)) mux.HandleFunc("GET /calendar/settings", s.requireAuth(s.requireRight("calendar:read", s.handleGetCalendarSettings))) + mux.HandleFunc("GET /calendar/events", s.requireAuth(s.requireRight("calendar:read", s.handleListCalendarEvents))) + mux.HandleFunc("POST /calendar/events", s.requireAuth(s.requireRight("calendar:read", s.handleCreateCalendarEvent))) + mux.HandleFunc("PUT /calendar/events/{id}", s.requireAuth(s.requireRight("calendar:read", s.handleUpdateCalendarEvent))) + mux.HandleFunc("POST /calendar/events/{id}/occurrences", s.requireAuth(s.requireRight("calendar:read", s.handleUpdateCalendarEventOccurrence))) + mux.HandleFunc("POST /calendar/events/{id}/occurrences/delete", s.requireAuth(s.requireRight("calendar:read", s.handleDeleteCalendarEventOccurrence))) + mux.HandleFunc("DELETE /calendar/events/{id}", s.requireAuth(s.requireRight("calendar:read", s.handleDeleteCalendarEvent))) mux.HandleFunc("GET /chat/conversations", s.requireAuth(s.handleListChatConversations)) mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages)) diff --git a/backend/runtime_schema.go b/backend/runtime_schema.go index a775ee8..63a0e01 100644 --- a/backend/runtime_schema.go +++ b/backend/runtime_schema.go @@ -53,6 +53,51 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error { `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_password_encrypted BYTEA`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_user_group TEXT NOT NULL DEFAULT 'SE_Sachbearbeitung'`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_skip_tls_verify BOOLEAN NOT NULL DEFAULT false`, + ` + CREATE TABLE IF NOT EXISTS calendar_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + start_at TIMESTAMP NOT NULL, + end_at TIMESTAMP NOT NULL, + all_day BOOLEAN NOT NULL DEFAULT false, + location TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT 'indigo' + CHECK (color IN ('indigo', 'blue', 'emerald', 'amber', 'rose', 'slate')), + recurrence TEXT NOT NULL DEFAULT 'none', + recurrence_weekday INTEGER, + recurrence_ordinal INTEGER, + recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + updated_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT calendar_events_valid_range CHECK (end_at > start_at) + ) + `, + ` + DO $$ + DECLARE + constraint_name TEXT; + BEGIN + FOR constraint_name IN + SELECT c.conname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'calendar_events' + AND c.contype = 'c' + AND pg_get_constraintdef(c.oid) ILIKE '%recurrence%' + LOOP + EXECUTE format('ALTER TABLE calendar_events DROP CONSTRAINT %I', constraint_name); + END LOOP; + END + $$ + `, + `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_weekday INTEGER`, + `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_ordinal INTEGER`, + `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB`, + `UPDATE calendar_events SET recurrence_exceptions = '[]'::JSONB WHERE recurrence_exceptions IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_calendar_events_start_at ON calendar_events(start_at)`, } for _, query := range queries { diff --git a/backend/setup/main.go b/backend/setup/main.go index dc3c7c8..789c88b 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -407,6 +407,52 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ON CONFLICT (id) DO NOTHING `, + ` + CREATE TABLE IF NOT EXISTS calendar_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + start_at TIMESTAMP NOT NULL, + end_at TIMESTAMP NOT NULL, + all_day BOOLEAN NOT NULL DEFAULT false, + location TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT 'indigo' + CHECK (color IN ('indigo', 'blue', 'emerald', 'amber', 'rose', 'slate')), + recurrence TEXT NOT NULL DEFAULT 'none', + recurrence_weekday INTEGER, + recurrence_ordinal INTEGER, + recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + updated_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT calendar_events_valid_range CHECK (end_at > start_at) + ) + `, + ` + DO $$ + DECLARE + constraint_name TEXT; + BEGIN + FOR constraint_name IN + SELECT c.conname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'calendar_events' + AND c.contype = 'c' + AND pg_get_constraintdef(c.oid) ILIKE '%recurrence%' + LOOP + EXECUTE format('ALTER TABLE calendar_events DROP CONSTRAINT %I', constraint_name); + END LOOP; + END + $$ + `, + `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_weekday INTEGER`, + `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_ordinal INTEGER`, + `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB`, + `UPDATE calendar_events SET recurrence_exceptions = '[]'::JSONB WHERE recurrence_exceptions IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_calendar_events_start_at ON calendar_events(start_at)`, + ` CREATE TABLE IF NOT EXISTS devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/frontend/src/components/calendar/Calendar.tsx b/frontend/src/components/calendar/Calendar.tsx index 2194a4e..81d29c3 100644 --- a/frontend/src/components/calendar/Calendar.tsx +++ b/frontend/src/components/calendar/Calendar.tsx @@ -22,6 +22,13 @@ export type CalendarEventColor = | 'amber' | 'rose' | 'slate' +export type CalendarEventRecurrence = + | 'none' + | 'daily' + | 'weekly' + | 'monthly' + | 'monthlyNthWeekday' + | 'yearly' export type CalendarEvent = { id: string @@ -32,8 +39,17 @@ export type CalendarEvent = { location: string description: string color: CalendarEventColor + recurrence?: CalendarEventRecurrence + recurrenceWeekday?: number | null + recurrenceOrdinal?: number | null + recurrenceExceptions?: string[] + occurrenceStart?: string + occurrenceEnd?: string + occurrenceKey?: string } +type CalendarEventOccurrence = CalendarEvent + type CalendarProps = { events: CalendarEvent[] coreWorkingHours: CalendarCoreWorkingHours @@ -124,6 +140,101 @@ function addYears(date: Date, amount: number) { return new Date(date.getFullYear() + amount, date.getMonth(), date.getDate()) } +function daysInMonth(year: number, month: number) { + return new Date(year, month + 1, 0).getDate() +} + +function weekdayOrdinalInMonth(date: Date) { + return Math.floor((date.getDate() - 1) / 7) + 1 +} + +function nthWeekdayInMonth( + year: number, + month: number, + weekday: number, + ordinal: number, + template: Date, +) { + const firstOfMonth = new Date( + year, + month, + 1, + template.getHours(), + template.getMinutes(), + template.getSeconds(), + template.getMilliseconds(), + ) + const firstOffset = (weekday - firstOfMonth.getDay() + 7) % 7 + const day = 1 + firstOffset + (ordinal - 1) * 7 + + if (day > daysInMonth(year, month)) { + return null + } + + return new Date( + year, + month, + day, + template.getHours(), + template.getMinutes(), + template.getSeconds(), + template.getMilliseconds(), + ) +} + +function addPeriodClamped( + date: Date, + recurrence: CalendarEventRecurrence, + amount: number, + event?: CalendarEvent, +) { + if (recurrence === 'daily') { + const next = new Date(date) + next.setDate(next.getDate() + amount) + return next + } + + if (recurrence === 'weekly') { + const next = new Date(date) + next.setDate(next.getDate() + amount * 7) + return next + } + + if (recurrence === 'monthly') { + const next = new Date(date) + const day = date.getDate() + next.setDate(1) + next.setMonth(next.getMonth() + amount) + next.setDate(Math.min(day, daysInMonth(next.getFullYear(), next.getMonth()))) + return next + } + + if (recurrence === 'monthlyNthWeekday') { + const next = new Date(date) + next.setDate(1) + next.setMonth(next.getMonth() + amount) + + return nthWeekdayInMonth( + next.getFullYear(), + next.getMonth(), + event?.recurrenceWeekday ?? date.getDay(), + event?.recurrenceOrdinal ?? weekdayOrdinalInMonth(date), + date, + ) + } + + if (recurrence === 'yearly') { + const next = new Date(date) + const day = date.getDate() + next.setDate(1) + next.setFullYear(next.getFullYear() + amount) + next.setDate(Math.min(day, daysInMonth(next.getFullYear(), next.getMonth()))) + return next + } + + return new Date(date) +} + function startOfWeek(date: Date) { const normalized = startOfDay(date) const mondayOffset = (normalized.getDay() + 6) % 7 @@ -138,20 +249,168 @@ function isToday(date: Date) { return isSameDay(date, new Date()) } -function eventsForDay(events: CalendarEvent[], date: Date) { +function toLocalDateTimeValue(date: Date) { + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${toDateKey(date)}T${hours}:${minutes}` +} + +function eventStartValue(event: CalendarEventOccurrence) { + return event.occurrenceStart ?? event.start +} + +function eventEndValue(event: CalendarEventOccurrence) { + return event.occurrenceEnd ?? event.end +} + +function eventKey(event: CalendarEventOccurrence) { + return event.occurrenceKey ?? event.id +} + +function normalizeEventRecurrence( + recurrence: CalendarEvent['recurrence'], +): CalendarEventRecurrence { + return recurrence === 'daily' || + recurrence === 'weekly' || + recurrence === 'monthly' || + recurrence === 'monthlyNthWeekday' || + recurrence === 'yearly' + ? recurrence + : 'none' +} + +function recurrenceBaseIndex( + eventStart: Date, + dayStart: Date, + recurrence: CalendarEventRecurrence, +) { + const eventDay = startOfDay(eventStart) + const dayDistance = + (dayStart.getTime() - eventDay.getTime()) / (24 * 60 * 60 * 1000) + + if (recurrence === 'daily') { + return Math.floor(dayDistance) + } + + if (recurrence === 'weekly') { + return Math.floor(dayDistance / 7) + } + + if (recurrence === 'monthly' || recurrence === 'monthlyNthWeekday') { + return ( + (dayStart.getFullYear() - eventStart.getFullYear()) * 12 + + dayStart.getMonth() - + eventStart.getMonth() + ) + } + + if (recurrence === 'yearly') { + return dayStart.getFullYear() - eventStart.getFullYear() + } + + return 0 +} + +function periodApproximationDays(recurrence: CalendarEventRecurrence) { + if (recurrence === 'weekly') { + return 7 + } + if (recurrence === 'monthly' || recurrence === 'monthlyNthWeekday') { + return 28 + } + if (recurrence === 'yearly') { + return 365 + } + return 1 +} + +function occurrencesForDay( + event: CalendarEvent, + date: Date, +): CalendarEventOccurrence[] { const dayStart = startOfDay(date) const dayEnd = addDays(dayStart, 1) + const eventStart = new Date(event.start) + const eventEnd = new Date(event.end) + if (Number.isNaN(eventStart.getTime()) || Number.isNaN(eventEnd.getTime())) { + return [] + } + + const recurrence = normalizeEventRecurrence(event.recurrence) + const durationMs = eventEnd.getTime() - eventStart.getTime() + + if (durationMs <= 0) { + return [] + } + + if (recurrence === 'none') { + return eventStart < dayEnd && eventEnd > dayStart ? [event] : [] + } + + const baseIndex = recurrenceBaseIndex(eventStart, dayStart, recurrence) + const durationDays = Math.max( + 1, + Math.ceil(durationMs / (24 * 60 * 60 * 1000)), + ) + const radius = Math.min( + 400, + Math.ceil(durationDays / periodApproximationDays(recurrence)) + 2, + ) + const occurrences: CalendarEventOccurrence[] = [] + const seen = new Set() + const excludedStarts = new Set(event.recurrenceExceptions ?? []) + + for (let offset = -radius; offset <= 2; offset += 1) { + const index = baseIndex + offset + if (index < 0) { + continue + } + + const occurrenceStart = addPeriodClamped(eventStart, recurrence, index, event) + if (!occurrenceStart) { + continue + } + + const occurrenceEnd = new Date(occurrenceStart.getTime() + durationMs) + const occurrenceStartValue = toLocalDateTimeValue(occurrenceStart) + + if ( + occurrenceStart < eventStart && + occurrenceEnd.getTime() <= eventStart.getTime() + ) { + continue + } + + if (excludedStarts.has(occurrenceStartValue)) { + continue + } + + if (occurrenceStart < dayEnd && occurrenceEnd > dayStart) { + const key = `${event.id}:${recurrence}:${index}` + if (!seen.has(key)) { + seen.add(key) + occurrences.push({ + ...event, + occurrenceStart: occurrenceStartValue, + occurrenceEnd: toLocalDateTimeValue(occurrenceEnd), + occurrenceKey: key, + }) + } + } + } + + return occurrences +} + +function eventsForDay(events: CalendarEvent[], date: Date) { return events - .filter((event) => { - const eventStart = new Date(event.start) - const eventEnd = new Date(event.end) - return eventStart < dayEnd && eventEnd > dayStart - }) + .flatMap((event) => occurrencesForDay(event, date)) .sort( (left, right) => Number(right.allDay) - Number(left.allDay) || - new Date(left.start).getTime() - new Date(right.start).getTime(), + new Date(eventStartValue(left)).getTime() - + new Date(eventStartValue(right)).getTime(), ) } @@ -508,7 +767,7 @@ function MonthView({
{dayEvents.slice(0, 3).map((event) => ( @@ -552,7 +811,7 @@ function MonthView({
{dayEvents.slice(0, 4).map((event) => ( {selectedEvents.map((event) => ( -
  • +
  • ))} @@ -605,7 +864,7 @@ function EventListButton({ event, onClick, }: { - event: CalendarEvent + event: CalendarEventOccurrence onClick: (event: CalendarEvent) => void }) { return ( @@ -632,7 +891,9 @@ function EventListButton({ {event.allDay ? 'Ganztägig' - : `${formatTime(event.start)} - ${formatTime(event.end)}`} + : `${formatTime(eventStartValue(event))} - ${formatTime( + eventEndValue(event), + )}`}
    {event.location && ( @@ -864,7 +1125,7 @@ function TimeGridView({ .filter((event) => event.allDay) .map((event) => (
    )} {timedEvents.map((event) => { - const eventStart = new Date(event.start) - const eventEnd = new Date(event.end) + const eventStart = new Date(eventStartValue(event)) + const eventEnd = new Date(eventEndValue(event)) const segmentStart = new Date( Math.max(eventStart.getTime(), dayStart.getTime()), ) @@ -1042,7 +1303,7 @@ function TimeGridView({ return (
    + + { + if (!open && !saving) { + setPendingSeriesAction(null) + } + }} + zIndexClassName="z-[10010]" + backdropClassName="bg-gray-950/60 backdrop-blur-sm dark:bg-gray-950/60" + panelClassName="max-w-md rounded-2xl bg-white dark:bg-gray-900" + > +
    + + {pendingSeriesAction?.type === 'delete' + ? 'Serientermin löschen' + : 'Serientermin bearbeiten'} + +
    +
    +

    + Soll die Änderung nur für diesen Termin oder für die ganze Serie + übernommen werden? +

    + {formError && ( +

    + {formError} +

    + )} +
    +
    + + + +
    +
    ) } diff --git a/frontend/src/pages/milestone/rollen/MilestoneRolesPage.tsx b/frontend/src/pages/milestone/rollen/MilestoneRolesPage.tsx index 67fda4f..7129b46 100644 --- a/frontend/src/pages/milestone/rollen/MilestoneRolesPage.tsx +++ b/frontend/src/pages/milestone/rollen/MilestoneRolesPage.tsx @@ -108,6 +108,9 @@ function classNames(...classes: Array) { async function readApiError(response: Response, fallback: string) { const data = await response.json().catch(() => null) + if (data?.error && data?.details) { + return `${data.error}: ${data.details}` + } return data?.error ?? fallback } @@ -122,19 +125,31 @@ function roleIsAdministrators(role?: MilestoneRole | null) { .some((value) => value?.trim().toLowerCase() === 'administrators') } +function userDomainIsBasic(domain?: string) { + const normalized = (domain ?? '').trim().replace(/^\[/, '').replace(/\]$/, '') + return normalized.toLowerCase() === 'basic' +} + function userSource(user: MilestoneRoleUser | AssignableUser): UserSource { - return user.source === 'basic' ? 'basic' : 'ad' + return user.source === 'basic' || userDomainIsBasic(user.domain) + ? 'basic' + : 'ad' +} + +function withoutDomainPrefix(value: string) { + const [, accountName] = value.split('\\') + return accountName || value } function userAccountName(user: MilestoneRoleUser | AssignableUser) { if ('username' in user && user.username) { - return user.username + return withoutDomainPrefix(user.username) } if ('userName' in user && user.userName) { - return user.userName + return withoutDomainPrefix(user.userName) } if ('name' in user && user.name) { - return user.name + return withoutDomainPrefix(user.name) } return user.id @@ -142,11 +157,6 @@ function userAccountName(user: MilestoneRoleUser | AssignableUser) { function userTitle(user: MilestoneRoleUser | AssignableUser) { const accountName = userAccountName(user) - const domain = user.domain?.trim() - - if (domain && accountName) { - return `${domain}\\${accountName}` - } return accountName || user.displayName || user.id } @@ -155,25 +165,17 @@ function adUserSubtitle(user: ActiveDirectoryUser) { return [user.username, user.email].filter(Boolean).join(' / ') } -function milestoneUserSubtitle(user: MilestoneRoleUser) { - return [user.userName || user.name, user.email].filter(Boolean).join(' / ') -} - function userSubtitle(user: MilestoneRoleUser | AssignableUser) { - const fallbackSubtitle = - userSource(user) === 'ad' - ? adUserSubtitle(user as ActiveDirectoryUser) - : milestoneUserSubtitle(user as MilestoneRoleUser) + if (userSource(user) === 'basic') { + return 'Basisbenutzer' + } - return [ - user.displayName && user.displayName !== userTitle(user) - ? user.displayName - : '', - userSource(user) === 'basic' ? 'Basisbenutzer' : '', - user.email || fallbackSubtitle, - ] - .filter(Boolean) - .join(' / ') + const domain = user.domain?.trim() + if (domain && !userDomainIsBasic(domain)) { + return domain + } + + return user.email || adUserSubtitle(user as ActiveDirectoryUser) } function userSelectionKey(user: AssignableUser) { @@ -205,7 +207,6 @@ export default function MilestoneRolesPage({ const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false) const [isAddingUsers, setIsAddingUsers] = useState(false) const [adSearch, setAdSearch] = useState('') - const [userPickerSource, setUserPickerSource] = useState('ad') const [selectedUserKeys, setSelectedUserKeys] = useState>( () => new Set(), ) @@ -426,7 +427,6 @@ export default function MilestoneRolesPage({ setAdModalOpen(true) setSelectedUserKeys(new Set()) setAdSearch('') - setUserPickerSource('ad') await Promise.all([loadAdUsers(), loadBasicUsers()]) } @@ -621,6 +621,13 @@ export default function MilestoneRolesPage({ }, [isAdministrator, roleSearch, roles]) const roleUsers = selectedRole?.users ?? [] + const roleUsersBySource = useMemo>( + () => ({ + ad: roleUsers.filter((user) => userSource(user) === 'ad'), + basic: roleUsers.filter((user) => userSource(user) === 'basic'), + }), + [roleUsers], + ) const visibleAssignableUsersBySource = useMemo>(() => { const existingUserIds = new Set(roleUsers.map((user) => user.id)) const query = adSearch.trim().toLowerCase() @@ -654,11 +661,6 @@ export default function MilestoneRolesPage({ } }, [adSearch, adUsers, basicUsers, roleUsers]) - const filteredAssignableUsers = visibleAssignableUsersBySource[userPickerSource] - - const isLoadingPickerUsers = - userPickerSource === 'ad' ? isLoadingAdUsers : isLoadingBasicUsers - return (
    @@ -884,39 +886,71 @@ export default function MilestoneRolesPage({ Diese Rolle hat noch keine Benutzer.
    ) : ( -
      - {roleUsers.map((user) => ( -
    • -
      -
      -
      -

      - {userTitle(user)} -

      - {userSubtitle(user) && ( -

      - {userSubtitle(user)} -

      - )} -
      - {canWrite && ( - - )} -
    • - ))} -
    +
    + {([ + ['ad', 'Windows-Benutzer'], + ['basic', 'Basisbenutzer'], + ] as Array<[UserSource, string]>).map(([source, label]) => { + const users = roleUsersBySource[source] + if (users.length === 0) { + return null + } + + return ( +
    +
    +

    + {label} +

    + + {users.length} + +
    +
      + {users.map((user) => ( +
    • +
      +
      +
      +

      + {userTitle(user)} +

      + {userSubtitle(user) && ( +

      + {userSubtitle(user)} +

      + )} +
      + {canWrite && ( + + )} +
    • + ))} +
    +
    + ) + })} +
    )}
    @@ -1060,74 +1094,69 @@ export default function MilestoneRolesPage({
    Benutzer hinzufügen

    - {userPickerSource === 'ad' - ? adBaseDn - ? `Benutzer aus dem Suchbereich ${adBaseDn}.` - : 'Benutzer aus dem konfigurierten Suchbereich.' - : 'Basisbenutzer aus Milestone.'} + {adBaseDn + ? `Windows-Benutzer aus ${adBaseDn} und Basisbenutzer aus Milestone.` + : 'Windows-Benutzer aus dem konfigurierten Suchbereich und Basisbenutzer aus Milestone.'}

    -
    + setAdSearch(event.target.value)} + placeholder="Benutzer suchen" + className="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500" + /> + +
    {([ - ['ad', `AD-Benutzer (${visibleAssignableUsersBySource.ad.length})`], - ['basic', `Basisbenutzer (${visibleAssignableUsersBySource.basic.length})`], - ] as Array<[UserSource, string]>).map(([source, label]) => ( - - ))} -
    + { + source: 'ad' as const, + title: 'Windows-Benutzer', + users: visibleAssignableUsersBySource.ad, + loading: isLoadingAdUsers, + unavailable: !adConfigured, + emptyText: 'Keine passenden Windows-Benutzer gefunden.', + }, + { + source: 'basic' as const, + title: 'Basisbenutzer', + users: visibleAssignableUsersBySource.basic, + loading: isLoadingBasicUsers, + unavailable: false, + emptyText: 'Keine passenden Basisbenutzer gefunden.', + }, + ]).map((group) => ( +
    +
    +

    + {group.title} +

    + + {group.users.length} + +
    - {userPickerSource === 'ad' && !adConfigured ? ( -
    - Active Directory ist noch nicht konfiguriert. -
    - ) : isLoadingPickerUsers ? ( -
    - -
    - ) : ( - <> - setAdSearch(event.target.value)} - placeholder={ - userPickerSource === 'ad' - ? 'AD-Benutzer suchen' - : 'Basisbenutzer suchen' - } - className="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500" - /> - -
    - {filteredAssignableUsers.length === 0 ? ( -
    - Keine passenden Benutzer gefunden. + {group.unavailable ? ( +
    + Active Directory ist noch nicht konfiguriert. +
    + ) : group.loading ? ( +
    + +
    + ) : group.users.length === 0 ? ( +
    + {group.emptyText}
    ) : ( -
      - {filteredAssignableUsers.map((user) => ( +
        + {group.users.map((user) => (
    - - )} +
    + ))} +