added calendar + bugfixes

This commit is contained in:
Linrador 2026-06-15 20:59:44 +02:00
parent 04ab4523a6
commit cd26d67252
52 changed files with 7134 additions and 536 deletions

View File

@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\backend\"; go vet ./... 2>&1 | Select-Object -First 20; \"VET: $LASTEXITCODE\"; gofmt -w chat.go chat_groups.go chat_websocket.go chat_unread.go chat_search.go main.go routes.go setup\\\\main.go; gofmt -l chat.go chat_groups.go chat_websocket.go chat_unread.go chat_search.go; \"fmt done\")",
"PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\frontend\"; npx tsc --noEmit 2>&1 | Select-Object -First 40; \"TSC EXIT: $LASTEXITCODE\")",
"PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\frontend\"; npx tsc --noEmit 2>&1 | Select-Object -First 30; \"TSC EXIT: $LASTEXITCODE\")",
"Bash(npx tsc *)",
"Bash(echo \"EXIT:$?\")"
]
}
}

1
.gitignore vendored
View File

@ -75,3 +75,4 @@ $RECYCLE.BIN/
# Windows shortcuts
*.lnk
.gocache

View File

@ -1,7 +1,7 @@
PORT=8080
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
JWT_SECRET=tegvideo7010!
FRONTEND_ORIGIN=http://localhost:5173
FRONTEND_ORIGIN=https://localhost:5173
ADDRESS_SEARCH_PROVIDER=photon
ADDRESS_SEARCH_URL=https://photon.komoot.io/api
ADDRESS_SEARCH_LANGUAGE=de
@ -12,9 +12,13 @@ ADDRESS_SEARCH_USER_AGENT=TEG-App/1.0
ADMIN_USERNAME=admin
ADMIN_DISPLAY_NAME=Administrator
ADMIN_EMAIL=admin@tegdssd.de
ADMIN_UNIT=Administration
ADMIN_UNIT=TEG
ADMIN_GROUP=admin
ADMIN_RIGHTS=admin,users:read,users:write
ADMIN_TEAM_NAME=Administration
ADMIN_TEAM_INITIAL=A
ADMIN_TEAM_HREF=#administration
VAPID_PUBLIC_KEY=BEFezMtT5PwJBOm9VIpP_1Y9Pwn8XAZfOHY2aQI_D9MaWsD3wn87OEdOMLlOa87fmDeB5DMVlpVrRnwBZNbXZ4E
VAPID_PRIVATE_KEY=3QSvqwejg62ttALunCiib3CXGos2ylvDCDoRGl_OPrQ
VAPID_SUBSCRIBER=mailto:admin@tegdssd.de

View File

@ -15,12 +15,13 @@ import (
)
type AdminTeam struct {
ID string `json:"id"`
Name string `json:"name"`
Initial string `json:"initial"`
Href string `json:"href"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
Name string `json:"name"`
Initial string `json:"initial"`
Href string `json:"href"`
ConversationID string `json:"conversationId"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type AdminUser struct {
@ -472,16 +473,89 @@ func replaceUserTeams(ctx context.Context, tx pgx.Tx, userID string, teamIDs []s
}
}
if _, err := tx.Exec(
ctx,
`
INSERT INTO conversations (type, name, team_id)
SELECT 'group', t.name, t.id
FROM teams t
JOIN user_teams ut
ON ut.team_id = t.id
AND ut.user_id = $1
ON CONFLICT (team_id) WHERE team_id IS NOT NULL
DO UPDATE SET
type = 'group',
name = EXCLUDED.name,
created_by = NULL,
updated_at = now()
`,
userID,
); err != nil {
return err
}
if _, err := tx.Exec(
ctx,
`
DELETE FROM conversation_members cm
USING conversations c
WHERE cm.conversation_id = c.id
AND cm.user_id = $1
AND c.team_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.user_id = cm.user_id
AND ut.team_id = c.team_id
)
`,
userID,
); err != nil {
return err
}
if _, err := tx.Exec(
ctx,
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT c.id, $1
FROM conversations c
JOIN user_teams ut
ON ut.team_id = c.team_id
AND ut.user_id = $1
WHERE c.team_id IS NOT NULL
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
userID,
); err != nil {
return err
}
return nil
}
func (s *Server) handleAdminListTeams(w http.ResponseWriter, r *http.Request) {
if err := s.ensureTeamGroupConversations(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Gruppenchats konnten nicht vorbereitet werden")
return
}
rows, err := s.db.Query(
r.Context(),
`
SELECT id::TEXT, name, initial, href, created_at, updated_at
FROM teams
ORDER BY name ASC
SELECT
t.id::TEXT,
t.name,
t.initial,
t.href,
COALESCE(c.id::TEXT, ''),
t.created_at,
t.updated_at
FROM teams t
LEFT JOIN conversations c
ON c.team_id = t.id
AND c.type = 'group'
ORDER BY t.name ASC
`,
)
@ -501,6 +575,7 @@ func (s *Server) handleAdminListTeams(w http.ResponseWriter, r *http.Request) {
&team.Name,
&team.Initial,
&team.Href,
&team.ConversationID,
&team.CreatedAt,
&team.UpdatedAt,
); err != nil {
@ -531,24 +606,53 @@ func (s *Server) handleAdminCreateTeam(w http.ResponseWriter, r *http.Request) {
return
}
_, err := s.db.Exec(
r.Context(),
`
INSERT INTO teams (name, initial, href)
VALUES ($1, $2, $3)
`,
input.Name,
input.Initial,
input.Href,
)
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden")
return
}
defer tx.Rollback(r.Context())
var teamID string
if err := tx.QueryRow(
r.Context(),
`
INSERT INTO teams (name, initial, href)
VALUES ($1, $2, $3)
RETURNING id::TEXT
`,
input.Name,
input.Initial,
input.Href,
).Scan(&teamID); err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden")
return
}
var conversationID string
if err := tx.QueryRow(
r.Context(),
`
INSERT INTO conversations (type, name, team_id)
VALUES ('group', $1, $2)
RETURNING id::TEXT
`,
input.Name,
teamID,
).Scan(&conversationID); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Gruppenchat konnte nicht erstellt werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"ok": true,
"ok": true,
"id": teamID,
"conversationId": conversationID,
})
}
@ -574,7 +678,14 @@ func (s *Server) handleAdminUpdateTeam(w http.ResponseWriter, r *http.Request) {
return
}
_, err := s.db.Exec(
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden")
return
}
defer tx.Rollback(r.Context())
commandTag, err := tx.Exec(
r.Context(),
`
UPDATE teams
@ -589,13 +700,45 @@ func (s *Server) handleAdminUpdateTeam(w http.ResponseWriter, r *http.Request) {
input.Initial,
input.Href,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden")
return
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "Team wurde nicht gefunden")
return
}
var conversationID string
if err := tx.QueryRow(
r.Context(),
`
INSERT INTO conversations (type, name, team_id)
VALUES ('group', $1, $2)
ON CONFLICT (team_id) WHERE team_id IS NOT NULL
DO UPDATE SET
type = 'group',
name = EXCLUDED.name,
created_by = NULL,
updated_at = now()
RETURNING id::TEXT
`,
input.Name,
teamID,
).Scan(&conversationID); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Gruppenchat konnte nicht aktualisiert werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht gespeichert werden")
return
}
s.publishConversationUpdate(r.Context(), conversationID)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"ok": true,
"conversationId": conversationID,
})
}

View File

@ -0,0 +1,360 @@
package main
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type AdminGroupChat struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
OwnerID string `json:"ownerId"`
UserIDs []string `json:"userIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type saveAdminGroupChatRequest struct {
Name string `json:"name"`
Image string `json:"image"`
UserIDs []string `json:"userIds"`
}
func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(
r.Context(),
`
SELECT
c.id::TEXT,
c.name,
c.channel_image,
COALESCE(c.created_by::TEXT, ''),
COALESCE(
array_agg(cm.user_id::TEXT ORDER BY cm.created_at)
FILTER (WHERE cm.user_id IS NOT NULL),
'{}'::TEXT[]
),
c.created_at,
c.updated_at
FROM conversations c
LEFT JOIN conversation_members cm ON cm.conversation_id = c.id
WHERE c.type = 'group'
AND c.team_id IS NULL
GROUP BY c.id
ORDER BY lower(c.name), c.created_at
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchats konnten nicht geladen werden")
return
}
defer rows.Close()
groups := []AdminGroupChat{}
for rows.Next() {
var group AdminGroupChat
if err := rows.Scan(
&group.ID,
&group.Name,
&group.Image,
&group.OwnerID,
&group.UserIDs,
&group.CreatedAt,
&group.UpdatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchats konnten nicht gelesen werden")
return
}
groups = append(groups, group)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchats konnten nicht vollständig geladen werden")
return
}
rows.Close()
users, err := s.listAdminChannelUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"groups": groups,
"users": users,
})
}
func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
if conversationID == "" {
writeError(w, http.StatusBadRequest, "Gruppenchat-ID fehlt")
return
}
var input saveAdminGroupChatRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
input.Name = strings.TrimSpace(input.Name)
input.Image = strings.TrimSpace(input.Image)
input.UserIDs = cleanStringList(input.UserIDs)
if input.Name == "" || len([]rune(input.Name)) > maxGroupNameLength {
writeError(w, http.StatusBadRequest, "Gruppenname muss 180 Zeichen lang sein")
return
}
if len(input.Image) > maxGroupImageBytes {
writeError(w, http.StatusBadRequest, "Das Gruppenbild ist zu groß")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden")
return
}
defer tx.Rollback(r.Context())
var currentName, currentImage, ownerID string
if err := tx.QueryRow(
r.Context(),
`
SELECT name, channel_image, COALESCE(created_by::TEXT, '')
FROM conversations
WHERE id = $1
AND type = 'group'
AND team_id IS NULL
FOR UPDATE
`,
conversationID,
).Scan(&currentName, &currentImage, &ownerID); err != nil {
if err == pgx.ErrNoRows {
writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geladen werden")
return
}
if ownerID != "" {
input.UserIDs = cleanStringList(append(input.UserIDs, ownerID))
}
if len(input.UserIDs) == 0 {
writeError(w, http.StatusBadRequest, "Mindestens ein Gruppenmitglied ist erforderlich")
return
}
oldMemberIDs, err := adminGroupMemberIDs(r.Context(), tx, conversationID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht geladen werden")
return
}
if _, err := tx.Exec(
r.Context(),
`
UPDATE conversations
SET
name = $2,
channel_image = $3,
updated_at = now()
WHERE id = $1
AND type = 'group'
AND team_id IS NULL
`,
conversationID,
input.Name,
input.Image,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden")
return
}
if _, err := tx.Exec(
r.Context(),
`
DELETE FROM conversation_members
WHERE conversation_id = $1
AND NOT (user_id::TEXT = ANY($2::TEXT[]))
`,
conversationID,
input.UserIDs,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht gespeichert werden")
return
}
if _, err := tx.Exec(
r.Context(),
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT $1, id
FROM users
WHERE id::TEXT = ANY($2::TEXT[])
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
conversationID,
input.UserIDs,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht gespeichert werden")
return
}
newMemberIDs, err := adminGroupMemberIDs(r.Context(), tx, conversationID)
if err != nil || len(newMemberIDs) == 0 {
writeError(w, http.StatusBadRequest, "Mindestens ein gültiges Gruppenmitglied ist erforderlich")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht gespeichert werden")
return
}
changed := currentName != input.Name ||
currentImage != input.Image ||
!sameStringSet(oldMemberIDs, newMemberIDs)
if changed {
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"group_admin_updated",
fmt.Sprintf("%s hat den Gruppenchat über die Administration aktualisiert.", conversationActorName(user)),
nil,
)
s.publishConversationUpdate(r.Context(), conversationID)
}
newMembers := make(map[string]struct{}, len(newMemberIDs))
for _, memberID := range newMemberIDs {
newMembers[memberID] = struct{}{}
}
for _, memberID := range oldMemberIDs {
if _, remains := newMembers[memberID]; remains {
continue
}
chatSockets.publish(memberID, chatSocketEvent{
Type: "conversation.removed",
ConversationID: conversationID,
})
}
writeJSON(w, http.StatusOK, map[string]any{"id": conversationID})
}
func (s *Server) handleAdminDeleteGroupChat(w http.ResponseWriter, r *http.Request) {
conversationID := strings.TrimSpace(r.PathValue("id"))
if conversationID == "" {
writeError(w, http.StatusBadRequest, "Gruppenchat-ID fehlt")
return
}
memberIDs, err := s.chatConversationMemberIDs(r.Context(), conversationID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht geladen werden")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht gelöscht werden")
return
}
defer tx.Rollback(r.Context())
if _, err := tx.Exec(
r.Context(),
`DELETE FROM notifications WHERE entity_type = 'chat' AND entity_id = $1`,
conversationID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Chat-Benachrichtigungen konnten nicht gelöscht werden")
return
}
commandTag, err := tx.Exec(
r.Context(),
`DELETE FROM conversations WHERE id = $1 AND type = 'group' AND team_id IS NULL`,
conversationID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht gelöscht werden")
return
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht gelöscht werden")
return
}
for _, memberID := range memberIDs {
chatSockets.publish(memberID, chatSocketEvent{
Type: "conversation.removed",
ConversationID: conversationID,
})
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func adminGroupMemberIDs(ctx context.Context, tx pgx.Tx, conversationID string) ([]string, error) {
rows, err := tx.Query(
ctx,
`
SELECT user_id::TEXT
FROM conversation_members
WHERE conversation_id = $1
ORDER BY created_at, user_id
`,
conversationID,
)
if err != nil {
return nil, err
}
defer rows.Close()
memberIDs := []string{}
for rows.Next() {
var memberID string
if err := rows.Scan(&memberID); err != nil {
return nil, err
}
memberIDs = append(memberIDs, memberID)
}
return memberIDs, rows.Err()
}
func sameStringSet(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
values := make(map[string]struct{}, len(left))
for _, value := range left {
values[value] = struct{}{}
}
for _, value := range right {
if _, exists := values[value]; !exists {
return false
}
}
return true
}

View File

@ -0,0 +1,217 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"time"
)
type CalendarWorkingHoursDay struct {
Enabled bool `json:"enabled"`
Start string `json:"start"`
End string `json:"end"`
}
type CalendarCoreWorkingHours struct {
Monday CalendarWorkingHoursDay `json:"monday"`
Tuesday CalendarWorkingHoursDay `json:"tuesday"`
Wednesday CalendarWorkingHoursDay `json:"wednesday"`
Thursday CalendarWorkingHoursDay `json:"thursday"`
Friday CalendarWorkingHoursDay `json:"friday"`
Saturday CalendarWorkingHoursDay `json:"saturday"`
Sunday CalendarWorkingHoursDay `json:"sunday"`
}
type CalendarSettings struct {
CoreWorkingHours CalendarCoreWorkingHours `json:"coreWorkingHours"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
var calendarTimePattern = regexp.MustCompile(`^\d{2}:\d{2}$`)
func defaultCalendarCoreWorkingHours() CalendarCoreWorkingHours {
workingDay := CalendarWorkingHoursDay{
Enabled: true,
Start: "08:00",
End: "17:00",
}
dayOff := CalendarWorkingHoursDay{
Enabled: false,
Start: "08:00",
End: "17:00",
}
return CalendarCoreWorkingHours{
Monday: workingDay,
Tuesday: workingDay,
Wednesday: workingDay,
Thursday: workingDay,
Friday: workingDay,
Saturday: dayOff,
Sunday: dayOff,
}
}
func (s *Server) ensureCalendarSettingsTable(r *http.Request) error {
defaultHours, err := json.Marshal(defaultCalendarCoreWorkingHours())
if err != nil {
return err
}
_, err = s.db.Exec(
r.Context(),
`
CREATE TABLE IF NOT EXISTS calendar_settings (
id SMALLINT PRIMARY KEY CHECK (id = 1),
core_working_hours JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
)
if err != nil {
return err
}
_, err = s.db.Exec(
r.Context(),
`
INSERT INTO calendar_settings (id, core_working_hours)
VALUES (1, $1::JSONB)
ON CONFLICT (id) DO NOTHING
`,
string(defaultHours),
)
return err
}
func (s *Server) loadCalendarSettings(r *http.Request) (CalendarSettings, error) {
if err := s.ensureCalendarSettingsTable(r); err != nil {
return CalendarSettings{}, err
}
var rawHours []byte
var settings CalendarSettings
var updatedAt time.Time
err := s.db.QueryRow(
r.Context(),
`
SELECT core_working_hours, updated_at
FROM calendar_settings
WHERE id = 1
`,
).Scan(&rawHours, &updatedAt)
if err != nil {
return CalendarSettings{}, err
}
if err := json.Unmarshal(rawHours, &settings.CoreWorkingHours); err != nil {
return CalendarSettings{}, err
}
settings.UpdatedAt = &updatedAt
return settings, nil
}
func validateCalendarWorkingHours(hours CalendarCoreWorkingHours) error {
days := []struct {
name string
hours CalendarWorkingHoursDay
}{
{name: "Montag", hours: hours.Monday},
{name: "Dienstag", hours: hours.Tuesday},
{name: "Mittwoch", hours: hours.Wednesday},
{name: "Donnerstag", hours: hours.Thursday},
{name: "Freitag", hours: hours.Friday},
{name: "Samstag", hours: hours.Saturday},
{name: "Sonntag", hours: hours.Sunday},
}
for _, day := range days {
if !calendarTimePattern.MatchString(day.hours.Start) ||
!calendarTimePattern.MatchString(day.hours.End) {
return fmt.Errorf("%s: Beginn und Ende müssen im Format HH:MM angegeben werden", day.name)
}
start, startErr := time.Parse("15:04", day.hours.Start)
end, endErr := time.Parse("15:04", day.hours.End)
if startErr != nil || endErr != nil {
return fmt.Errorf("%s: ungültige Uhrzeit", day.name)
}
if day.hours.Enabled && !end.After(start) {
return fmt.Errorf("%s: das Ende muss nach dem Beginn liegen", day.name)
}
}
return nil
}
func (s *Server) handleGetCalendarSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.loadCalendarSettings(r)
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalender-Einstellungen konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": settings,
})
}
func (s *Server) handleUpdateCalendarSettings(w http.ResponseWriter, r *http.Request) {
var payload struct {
CoreWorkingHours CalendarCoreWorkingHours `json:"coreWorkingHours"`
}
if err := readJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if err := validateCalendarWorkingHours(payload.CoreWorkingHours); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.ensureCalendarSettingsTable(r); err != nil {
writeError(w, http.StatusInternalServerError, "Kalender-Einstellungen konnten nicht vorbereitet werden")
return
}
rawHours, err := json.Marshal(payload.CoreWorkingHours)
if err != nil {
writeError(w, http.StatusBadRequest, "Kalender-Einstellungen sind ungültig")
return
}
_, err = s.db.Exec(
r.Context(),
`
INSERT INTO calendar_settings (id, core_working_hours)
VALUES (1, $1::JSONB)
ON CONFLICT (id) DO UPDATE SET
core_working_hours = EXCLUDED.core_working_hours,
updated_at = now()
`,
string(rawHours),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalender-Einstellungen konnten nicht gespeichert werden")
return
}
settings, err := s.loadCalendarSettings(r)
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalender-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"message": "Kalender-Einstellungen gespeichert",
"settings": settings,
})
}

View File

@ -579,8 +579,15 @@ func (s *Server) createChannelMessage(
err = tx.QueryRow(
ctx,
`
INSERT INTO messages (conversation_id, body)
SELECT id, $2
INSERT INTO messages (conversation_id, body, expires_at)
SELECT
id,
$2,
CASE
WHEN disappearing_seconds > 0
THEN now() + make_interval(secs => disappearing_seconds)
ELSE NULL
END
FROM conversations
WHERE id = $1
AND type = 'channel'

View File

@ -3,6 +3,7 @@ package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"math"
"net/http"
@ -30,9 +31,12 @@ type ChatUser struct {
type ChatMessage struct {
ID string `json:"id"`
ConversationID string `json:"conversationId"`
Type string `json:"type"`
Body string `json:"body"`
SystemEvent json.RawMessage `json:"systemEvent,omitempty"`
CreatedAt time.Time `json:"createdAt"`
EditedAt *time.Time `json:"editedAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
Sender ChatUser `json:"sender"`
Attachments []ChatAttachment `json:"attachments"`
ReplyTo *ChatMessageReference `json:"replyTo"`
@ -73,17 +77,19 @@ type ChatMessageReference struct {
}
type ChatConversation struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Image string `json:"image"`
TeamID string `json:"teamId"`
Participants []ChatUser `json:"participants"`
LastMessage *ChatMessage `json:"lastMessage"`
LastMessageAt *time.Time `json:"lastMessageAt"`
Muted bool `json:"muted"`
UnreadCount int `json:"unreadCount"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Image string `json:"image"`
TeamID string `json:"teamId"`
OwnerID string `json:"ownerId"`
DisappearingSeconds int `json:"disappearingSeconds"`
Participants []ChatUser `json:"participants"`
LastMessage *ChatMessage `json:"lastMessage"`
LastMessageAt *time.Time `json:"lastMessageAt"`
Muted bool `json:"muted"`
UnreadCount int `json:"unreadCount"`
CreatedAt time.Time `json:"createdAt"`
}
type createDirectConversationRequest struct {
@ -199,30 +205,40 @@ func (s *Server) handleListChatUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
func (s *Server) ensureTeamConversations(ctx context.Context, userID string) error {
func (s *Server) ensureTeamGroupConversations(ctx context.Context) error {
_, err := s.db.Exec(
ctx,
`
WITH user_team_conversations AS (
WITH team_groups AS (
INSERT INTO conversations (type, name, team_id, created_by)
SELECT 'team', t.name, t.id, $1
SELECT 'group', t.name, t.id, NULL
FROM teams t
JOIN user_teams current_membership
ON current_membership.team_id = t.id
AND current_membership.user_id = $1
ON CONFLICT (team_id) WHERE team_id IS NOT NULL
DO UPDATE SET
type = 'group',
name = EXCLUDED.name,
created_by = NULL,
updated_at = now()
RETURNING id, team_id
),
removed_members AS (
DELETE FROM conversation_members cm
USING conversations c
WHERE cm.conversation_id = c.id
AND c.team_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = cm.user_id
)
)
INSERT INTO conversation_members (conversation_id, user_id)
SELECT utc.id, ut.user_id
FROM user_team_conversations utc
JOIN user_teams ut ON ut.team_id = utc.team_id
SELECT tg.id, ut.user_id
FROM team_groups tg
JOIN user_teams ut ON ut.team_id = tg.team_id
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
userID,
)
return err
@ -235,8 +251,8 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
return
}
if err := s.ensureTeamConversations(r.Context(), user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Chats konnten nicht vorbereitet werden")
if err := s.ensureTeamGroupConversations(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Gruppenchats konnten nicht vorbereitet werden")
return
}
if err := s.ensureAllUserChannels(r.Context(), user.ID); err != nil {
@ -269,29 +285,21 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
WHERE unread_message.conversation_id = c.id
AND unread_message.deleted_at IS NULL
AND unread_message.sender_id IS DISTINCT FROM $1::UUID
AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now())
AND unread_message.created_at > COALESCE(
current_membership.last_read_at,
'-infinity'::TIMESTAMPTZ
)
), 0),
c.created_at
c.created_at,
COALESCE(c.created_by::TEXT, ''),
c.disappearing_seconds
FROM conversations c
WHERE (
c.type = 'team'
AND EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = $1
)
) OR (
c.type <> 'team'
AND EXISTS (
SELECT 1
FROM conversation_members cm
WHERE cm.conversation_id = c.id
AND cm.user_id = $1
)
WHERE EXISTS (
SELECT 1
FROM conversation_members cm
WHERE cm.conversation_id = c.id
AND cm.user_id = $1
)
ORDER BY COALESCE(c.last_message_at, c.created_at) DESC
`,
@ -316,6 +324,8 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
&conversation.Muted,
&conversation.UnreadCount,
&conversation.CreatedAt,
&conversation.OwnerID,
&conversation.DisappearingSeconds,
); err != nil {
writeError(w, http.StatusInternalServerError, "Chats konnten nicht gelesen werden")
return
@ -498,12 +508,16 @@ func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request)
ELSE u.last_seen_at
END
ELSE NULL
END
END,
m.message_type,
m.system_event,
m.expires_at
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = $1
AND m.deleted_at IS NULL
AND (m.expires_at IS NULL OR m.expires_at > now())
ORDER BY m.created_at ASC, m.id ASC
LIMIT 200
`,
@ -686,18 +700,25 @@ func (s *Server) createChatMessage(
forwarded_from_message_id,
location_lat,
location_lng,
location_label
location_label,
expires_at
)
VALUES (
$1,
SELECT
c.id,
$2,
$3,
NULLIF($4, '')::UUID,
NULLIF($5, '')::UUID,
$6,
$7,
$8
)
$8,
CASE
WHEN c.disappearing_seconds > 0
THEN now() + make_interval(secs => c.disappearing_seconds)
ELSE NULL
END
FROM conversations c
WHERE c.id = $1
RETURNING id::TEXT
`,
conversationID,
@ -835,25 +856,11 @@ func (s *Server) canAccessConversation(ctx context.Context, conversationID strin
SELECT 1
FROM conversations c
WHERE c.id = $1
AND (
(
c.type = 'team'
AND EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = $2
)
)
OR (
c.type <> 'team'
AND EXISTS (
SELECT 1
FROM conversation_members cm
WHERE cm.conversation_id = c.id
AND cm.user_id = $2
)
)
AND EXISTS (
SELECT 1
FROM conversation_members cm
WHERE cm.conversation_id = c.id
AND cm.user_id = $2
)
)
`,
@ -923,26 +930,8 @@ func (s *Server) listChatParticipants(ctx context.Context, conversationID string
ELSE NULL
END
FROM conversations c
JOIN users u ON (
(
c.type = 'team'
AND EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = u.id
)
)
OR (
c.type <> 'team'
AND EXISTS (
SELECT 1
FROM conversation_members cm
WHERE cm.conversation_id = c.id
AND cm.user_id = u.id
)
)
)
JOIN conversation_members cm ON cm.conversation_id = c.id
JOIN users u ON u.id = cm.user_id
WHERE c.id = $1
ORDER BY lower(COALESCE(NULLIF(u.display_name, ''), u.username, u.email))
`,
@ -1014,12 +1003,15 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
WHERE unread_message.conversation_id = c.id
AND unread_message.deleted_at IS NULL
AND unread_message.sender_id IS DISTINCT FROM $2::UUID
AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now())
AND unread_message.created_at > COALESCE(
current_membership.last_read_at,
'-infinity'::TIMESTAMPTZ
)
), 0),
c.created_at
c.created_at,
COALESCE(c.created_by::TEXT, ''),
c.disappearing_seconds
FROM conversations c
WHERE c.id = $1
`,
@ -1035,6 +1027,8 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
&conversation.Muted,
&conversation.UnreadCount,
&conversation.CreatedAt,
&conversation.OwnerID,
&conversation.DisappearingSeconds,
)
if err != nil {
return conversation, err
@ -1082,6 +1076,9 @@ func scanChatMessage(scanner chatMessageScanner) (ChatMessage, error) {
&message.Sender.LastSeenAt,
&message.Sender.AwaySince,
&message.Sender.LastOnlineAt,
&message.Type,
&message.SystemEvent,
&message.ExpiresAt,
)
message.Sender.Online = message.Sender.PresenceStatus == "online"
return message, err
@ -1137,7 +1134,10 @@ func (s *Server) getChatMessage(ctx context.Context, messageID string) (ChatMess
ELSE u.last_seen_at
END
ELSE NULL
END
END,
m.message_type,
m.system_event,
m.expires_at
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN users u ON u.id = m.sender_id
@ -1203,12 +1203,16 @@ func (s *Server) getLastChatMessage(ctx context.Context, conversationID string)
ELSE u.last_seen_at
END
ELSE NULL
END
END,
m.message_type,
m.system_event,
m.expires_at
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = $1
AND m.deleted_at IS NULL
AND (m.expires_at IS NULL OR m.expires_at > now())
ORDER BY m.created_at DESC, m.id DESC
LIMIT 1
`,

850
backend/chat_groups.go Normal file
View File

@ -0,0 +1,850 @@
// backend/chat_groups.go
//
// Gruppenchats (Typ 'group') sowie selbstlöschende Nachrichten und die daraus
// resultierenden System-Nachrichten. Gruppen werden ausschließlich vom
// Ersteller/Owner (conversations.created_by) verwaltet.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const (
maxGroupNameLength = 80
maxGroupImageBytes = 3 * 1024 * 1024
)
// Erlaubte Selbstlösch-Zeiträume in Sekunden: Aus, 1 Stunde, 1 Tag, 1 Woche.
var allowedDisappearingSeconds = map[int]bool{
0: true,
3600: true,
86400: true,
604800: true,
}
type createGroupRequest struct {
Name string `json:"name"`
Image string `json:"image"`
MemberIDs []string `json:"memberIds"`
}
type updateGroupRequest struct {
Name *string `json:"name"`
Image *string `json:"image"`
}
type addGroupMembersRequest struct {
UserIDs []string `json:"userIds"`
}
type updateDisappearingRequest struct {
Seconds int `json:"seconds"`
}
func conversationActorName(u User) string {
if name := strings.TrimSpace(u.DisplayName); name != "" {
return name
}
if name := strings.TrimSpace(u.Username); name != "" {
return name
}
return u.Email
}
func disappearingDurationLabel(seconds int) string {
switch seconds {
case 3600:
return "1 Stunde"
case 86400:
return "1 Tag"
case 604800:
return "1 Woche"
default:
return ""
}
}
// startDisappearingMessagesMonitor löscht periodisch abgelaufene
// selbstlöschende Nachrichten (Anhänge werden per FK ON DELETE CASCADE entfernt).
func (s *Server) startDisappearingMessagesMonitor(ctx context.Context) {
s.reconcileDisappearingMessages(ctx)
go func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.reconcileDisappearingMessages(ctx)
}
}
}()
}
func (s *Server) reconcileDisappearingMessages(ctx context.Context) {
if _, err := s.db.Exec(
ctx,
`UPDATE messages AS m
SET expires_at = m.created_at + make_interval(secs => c.disappearing_seconds)
FROM conversations AS c
WHERE m.conversation_id = c.id
AND c.disappearing_seconds > 0
AND m.message_type = 'user'
AND m.deleted_at IS NULL
AND m.expires_at IS NULL`,
); err != nil {
logError("Ablaufzeiten selbstlöschender Nachrichten konnten nicht ergänzt werden", err, nil)
return
}
s.deleteExpiredMessages(ctx)
}
func (s *Server) deleteExpiredMessages(ctx context.Context) {
if _, err := s.db.Exec(
ctx,
`DELETE FROM messages WHERE expires_at IS NOT NULL AND expires_at <= now()`,
); err != nil {
logError("Abgelaufene Nachrichten konnten nicht gelöscht werden", err, nil)
}
}
func canManageGroup(user User, ownerID string) bool {
return ownerID == user.ID || userHasRight(user, "admin")
}
func canManageTeamGroup(user User) bool {
return userHasRight(user, "teams:write")
}
func (s *Server) canManageGroupConversation(ctx context.Context, conversationID string, user User) bool {
if strings.TrimSpace(conversationID) == "" || strings.TrimSpace(user.ID) == "" {
return false
}
var ownerID string
err := s.db.QueryRow(
ctx,
`
SELECT COALESCE(created_by::TEXT, '')
FROM conversations
WHERE id = $1
AND type = 'group'
AND team_id IS NULL
`,
conversationID,
).Scan(&ownerID)
return err == nil && canManageGroup(user, ownerID)
}
func (s *Server) userDisplayName(ctx context.Context, userID string) string {
var name string
err := s.db.QueryRow(
ctx,
`SELECT COALESCE(NULLIF(display_name, ''), NULLIF(username, ''), email, 'Unbekannt') FROM users WHERE id = $1`,
userID,
).Scan(&name)
if err != nil || strings.TrimSpace(name) == "" {
return "Unbekannt"
}
return name
}
// createSystemMessage legt eine nicht ablaufende System-Nachricht im Verlauf an.
func (s *Server) createSystemMessage(
ctx context.Context,
conversationID string,
actorID string,
event string,
body string,
data map[string]any,
) (ChatMessage, error) {
payload := map[string]any{"event": event}
for key, value := range data {
payload[key] = value
}
eventJSON, err := json.Marshal(payload)
if err != nil {
return ChatMessage{}, err
}
var messageID string
err = s.db.QueryRow(
ctx,
`
INSERT INTO messages (conversation_id, sender_id, body, message_type, system_event)
VALUES ($1, NULLIF($2, '')::UUID, $3, 'system', $4::JSONB)
RETURNING id::TEXT
`,
conversationID,
actorID,
body,
string(eventJSON),
).Scan(&messageID)
if err != nil {
return ChatMessage{}, err
}
if _, err := s.db.Exec(
ctx,
`UPDATE conversations SET last_message_at = now(), updated_at = now() WHERE id = $1`,
conversationID,
); err != nil {
return ChatMessage{}, err
}
return s.getChatMessage(ctx, messageID)
}
// emitSystemMessage erzeugt eine System-Nachricht und verteilt sie an alle
// Mitglieder (ohne Web-Push, siehe publishChatMessage).
func (s *Server) emitSystemMessage(
ctx context.Context,
conversationID string,
actorID string,
event string,
body string,
data map[string]any,
) {
message, err := s.createSystemMessage(ctx, conversationID, actorID, event, body, data)
if err != nil {
return
}
s.publishChatMessage(ctx, message, "")
}
func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input createGroupRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
name := strings.TrimSpace(input.Name)
if name == "" || len([]rune(name)) > maxGroupNameLength {
writeError(w, http.StatusBadRequest, "Gruppenname muss 180 Zeichen lang sein")
return
}
image := strings.TrimSpace(input.Image)
if len(image) > maxGroupImageBytes {
writeError(w, http.StatusBadRequest, "Das Gruppenbild ist zu groß")
return
}
memberIDs := map[string]struct{}{user.ID: {}}
for _, id := range input.MemberIDs {
if id = strings.TrimSpace(id); id != "" {
memberIDs[id] = struct{}{}
}
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht erstellt werden")
return
}
defer tx.Rollback(r.Context())
var conversationID string
err = tx.QueryRow(
r.Context(),
`
INSERT INTO conversations (type, name, channel_image, created_by)
VALUES ('group', $1, $2, $3)
RETURNING id::TEXT
`,
name,
image,
user.ID,
).Scan(&conversationID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht erstellt werden")
return
}
for id := range memberIDs {
if _, err := tx.Exec(
r.Context(),
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT $1, id FROM users WHERE id = $2
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
conversationID,
id,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht gespeichert werden")
return
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gespeichert werden")
return
}
// Erst die Konversation an alle Mitglieder verteilen (damit der Chat in der
// Seitenleiste erscheint), dann die "erstellt"-System-Nachricht senden.
s.publishConversationUpdate(r.Context(), conversationID)
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"group_created",
fmt.Sprintf("%s hat die Gruppe erstellt.", conversationActorName(user)),
nil,
)
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"conversation": conversation})
}
func (s *Server) handleUpdateGroup(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
var input updateGroupRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
var currentName, currentImage, ownerID, teamID string
if err := s.db.QueryRow(
r.Context(),
`
SELECT
name,
channel_image,
COALESCE(created_by::TEXT, ''),
COALESCE(team_id::TEXT, '')
FROM conversations
WHERE id = $1
AND type = 'group'
`,
conversationID,
).Scan(&currentName, &currentImage, &ownerID, &teamID); err != nil {
writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden")
return
}
if teamID != "" {
if !canManageTeamGroup(user) {
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Verwalten des Team-Gruppenchats")
return
}
if input.Name != nil {
writeError(w, http.StatusBadRequest, "Der Name des Team-Gruppenchats wird über das Team verwaltet")
return
}
} else if !canManageGroup(user, ownerID) {
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe verwalten")
return
}
changed := false
if input.Name != nil {
newName := strings.TrimSpace(*input.Name)
if newName == "" || len([]rune(newName)) > maxGroupNameLength {
writeError(w, http.StatusBadRequest, "Gruppenname muss 180 Zeichen lang sein")
return
}
if newName != currentName {
if _, err := s.db.Exec(
r.Context(),
`UPDATE conversations SET name = $2, updated_at = now() WHERE id = $1`,
conversationID,
newName,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht aktualisiert werden")
return
}
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"group_renamed",
fmt.Sprintf("%s hat die Gruppe in „%s“ umbenannt.", conversationActorName(user), newName),
map[string]any{"name": newName},
)
changed = true
}
}
if input.Image != nil {
newImage := strings.TrimSpace(*input.Image)
if len(newImage) > maxGroupImageBytes {
writeError(w, http.StatusBadRequest, "Das Gruppenbild ist zu groß")
return
}
if newImage != currentImage {
if _, err := s.db.Exec(
r.Context(),
`UPDATE conversations SET channel_image = $2, updated_at = now() WHERE id = $1`,
conversationID,
newImage,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht aktualisiert werden")
return
}
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"group_image_changed",
fmt.Sprintf("%s hat das Gruppenbild geändert.", conversationActorName(user)),
nil,
)
changed = true
}
}
if changed {
s.publishConversationUpdate(r.Context(), conversationID)
}
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
}
func (s *Server) handleAddGroupMembers(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
if !s.canManageGroupConversation(r.Context(), conversationID, user) {
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe verwalten")
return
}
var input addGroupMembersRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
added := false
seen := map[string]struct{}{}
for _, rawID := range input.UserIDs {
id := strings.TrimSpace(rawID)
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
commandTag, err := s.db.Exec(
r.Context(),
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT $1, id FROM users WHERE id = $2
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
conversationID,
id,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Mitglied konnte nicht hinzugefügt werden")
return
}
if commandTag.RowsAffected() == 0 {
continue
}
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"member_added",
fmt.Sprintf("%s hat %s hinzugefügt.", conversationActorName(user), s.userDisplayName(r.Context(), id)),
map[string]any{"targetUserId": id},
)
added = true
}
if added {
s.publishConversationUpdate(r.Context(), conversationID)
}
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
}
func (s *Server) handleRemoveGroupMember(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
targetID := strings.TrimSpace(r.PathValue("userId"))
var convType, ownerID, teamID string
err := s.db.QueryRow(
r.Context(),
`
SELECT type, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, '')
FROM conversations
WHERE id = $1
`,
conversationID,
).Scan(&convType, &ownerID, &teamID)
if errors.Is(err, pgx.ErrNoRows) || convType != "group" || teamID != "" {
writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
return
}
isManager := canManageGroup(user, ownerID)
isSelf := targetID == user.ID
if !isManager && !isSelf {
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf Mitglieder entfernen")
return
}
if targetID == ownerID {
writeError(w, http.StatusBadRequest, "Der Ersteller kann die Gruppe nicht verlassen")
return
}
commandTag, err := s.db.Exec(
r.Context(),
`DELETE FROM conversation_members WHERE conversation_id = $1 AND user_id = $2`,
conversationID,
targetID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Mitglied konnte nicht entfernt werden")
return
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "Mitglied wurde nicht gefunden")
return
}
if isSelf {
s.emitSystemMessage(
r.Context(),
conversationID,
targetID,
"member_left",
fmt.Sprintf("%s hat die Gruppe verlassen.", conversationActorName(user)),
nil,
)
} else {
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"member_removed",
fmt.Sprintf("%s hat %s entfernt.", conversationActorName(user), s.userDisplayName(r.Context(), targetID)),
map[string]any{"targetUserId": targetID},
)
}
// Verbleibende Mitglieder erhalten die aktualisierte Mitgliederliste, der
// entfernte Nutzer erhält einen Hinweis, den Chat zu entfernen.
s.publishConversationUpdate(r.Context(), conversationID)
chatSockets.publish(targetID, chatSocketEvent{
Type: "conversation.removed",
ConversationID: conversationID,
})
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
var conversationType, ownerID, teamID string
err := s.db.QueryRow(
r.Context(),
`
SELECT type, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, '')
FROM conversations
WHERE id = $1
`,
conversationID,
).Scan(&conversationType, &ownerID, &teamID)
if errors.Is(err, pgx.ErrNoRows) || conversationType != "group" || teamID != "" {
writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
return
}
if !canManageGroup(user, ownerID) {
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe löschen")
return
}
memberIDs, err := s.chatConversationMemberIDs(r.Context(), conversationID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht geladen werden")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gelöscht werden")
return
}
defer tx.Rollback(r.Context())
if _, err := tx.Exec(
r.Context(),
`DELETE FROM notifications WHERE entity_type = 'chat' AND entity_id = $1`,
conversationID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Chat-Benachrichtigungen konnten nicht gelöscht werden")
return
}
commandTag, err := tx.Exec(
r.Context(),
`DELETE FROM conversations WHERE id = $1 AND type = 'group' AND team_id IS NULL`,
conversationID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gelöscht werden")
return
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gelöscht werden")
return
}
for _, memberID := range memberIDs {
chatSockets.publish(memberID, chatSocketEvent{
Type: "conversation.removed",
ConversationID: conversationID,
})
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleUpdateDisappearing(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
var input updateDisappearingRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if !allowedDisappearingSeconds[input.Seconds] {
writeError(w, http.StatusBadRequest, "Ungültiger Zeitraum")
return
}
var convType, ownerID, teamID string
err := s.db.QueryRow(
r.Context(),
`
SELECT type, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, '')
FROM conversations
WHERE id = $1
`,
conversationID,
).Scan(&convType, &ownerID, &teamID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
return
}
switch convType {
case "direct":
if !s.canAccessConversation(r.Context(), conversationID, user.ID) {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
return
}
case "group":
if teamID != "" {
if !canManageTeamGroup(user) {
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Ändern selbstlöschender Nachrichten im Team-Gruppenchat")
return
}
} else if !canManageGroup(user, ownerID) {
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf das Selbstlöschen ändern")
return
}
case "channel":
if !userHasRight(user, "admin") {
writeError(w, http.StatusForbidden, "Nur Administratoren dürfen das Selbstlöschen für Channels ändern")
return
}
default:
writeError(w, http.StatusBadRequest, "Selbstlöschen wird für diesen Chat nicht unterstützt")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
defer tx.Rollback(r.Context())
if _, err := tx.Exec(
r.Context(),
`UPDATE conversations SET disappearing_seconds = $2, updated_at = now() WHERE id = $1`,
conversationID,
input.Seconds,
); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
if input.Seconds == 0 {
if _, err := tx.Exec(
r.Context(),
`UPDATE messages
SET expires_at = NULL
WHERE conversation_id = $1
AND message_type = 'user'
AND deleted_at IS NULL`,
conversationID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
} else {
if _, err := tx.Exec(
r.Context(),
`UPDATE messages
SET expires_at = created_at + make_interval(secs => $2)
WHERE conversation_id = $1
AND message_type = 'user'
AND deleted_at IS NULL`,
conversationID,
input.Seconds,
); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
if _, err := tx.Exec(
r.Context(),
`DELETE FROM messages
WHERE conversation_id = $1
AND message_type = 'user'
AND deleted_at IS NULL
AND expires_at <= now()`,
conversationID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
if input.Seconds == 0 {
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"self_delete_disabled",
fmt.Sprintf("%s hat selbstlöschende Nachrichten deaktiviert.", conversationActorName(user)),
map[string]any{"seconds": 0},
)
} else {
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"self_delete_enabled",
fmt.Sprintf(
"%s hat selbstlöschende Nachrichten aktiviert (%s).",
conversationActorName(user),
disappearingDurationLabel(input.Seconds),
),
map[string]any{"seconds": input.Seconds},
)
}
s.publishConversationUpdate(r.Context(), conversationID)
s.publishChatMessagesReload(r.Context(), conversationID)
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
if err != nil {
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
}

View File

@ -49,26 +49,14 @@ func (s *Server) handleSearchChatMessages(w http.ResponseWriter, r *http.Request
JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN users u ON u.id = m.sender_id
WHERE m.deleted_at IS NULL
AND m.message_type = 'user'
AND (m.expires_at IS NULL OR m.expires_at > now())
AND strpos(lower(m.body), lower($2)) > 0
AND (
(
c.type = 'team'
AND EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = $1
)
)
OR (
c.type <> 'team'
AND EXISTS (
SELECT 1
FROM conversation_members cm
WHERE cm.conversation_id = c.id
AND cm.user_id = $1
)
)
AND EXISTS (
SELECT 1
FROM conversation_members cm
WHERE cm.conversation_id = c.id
AND cm.user_id = $1
)
ORDER BY m.conversation_id, m.created_at DESC, m.id DESC
LIMIT 50

View File

@ -41,23 +41,10 @@ func (s *Server) getChatUnreadCount(ctx context.Context, userID string) (int, er
ON cm.conversation_id = c.id
AND cm.user_id = $1
WHERE m.deleted_at IS NULL
AND (m.expires_at IS NULL OR m.expires_at > now())
AND m.sender_id IS DISTINCT FROM $1::UUID
AND m.created_at > COALESCE(cm.last_read_at, '-infinity'::TIMESTAMPTZ)
AND (
(
c.type = 'team'
AND EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = $1
)
)
OR (
c.type <> 'team'
AND cm.user_id IS NOT NULL
)
)
AND cm.user_id IS NOT NULL
`,
userID,
).Scan(&unreadCount)
@ -71,8 +58,8 @@ func (s *Server) handleGetChatUnreadCount(w http.ResponseWriter, r *http.Request
return
}
if err := s.ensureTeamConversations(r.Context(), user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Chats konnten nicht vorbereitet werden")
if err := s.ensureTeamGroupConversations(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Gruppenchats konnten nicht vorbereitet werden")
return
}
if err := s.ensureAllUserChannels(r.Context(), user.ID); err != nil {

View File

@ -25,14 +25,15 @@ type chatSocketCommand struct {
}
type chatSocketEvent struct {
Type string `json:"type"`
ClientID string `json:"clientId,omitempty"`
ConversationID string `json:"conversationId,omitempty"`
UserID string `json:"userId,omitempty"`
UserDisplayName string `json:"userDisplayName,omitempty"`
Typing bool `json:"typing,omitempty"`
Message *ChatMessage `json:"message,omitempty"`
Error string `json:"error,omitempty"`
Type string `json:"type"`
ClientID string `json:"clientId,omitempty"`
ConversationID string `json:"conversationId,omitempty"`
UserID string `json:"userId,omitempty"`
UserDisplayName string `json:"userDisplayName,omitempty"`
Typing bool `json:"typing,omitempty"`
Message *ChatMessage `json:"message,omitempty"`
Conversation *ChatConversation `json:"conversation,omitempty"`
Error string `json:"error,omitempty"`
}
type chatSocketClient struct {
@ -284,17 +285,7 @@ func (s *Server) publishChatMessage(
`
SELECT cm.user_id::TEXT, cm.muted
FROM conversation_members cm
JOIN conversations c ON c.id = cm.conversation_id
WHERE cm.conversation_id = $1
AND (
c.type <> 'team'
OR EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = cm.user_id
)
)
`,
message.ConversationID,
)
@ -325,7 +316,13 @@ func (s *Server) publishChatMessage(
rows.Close()
title, conversationType, err := s.chatNotificationTitle(ctx, message)
// System-Nachrichten (Gruppen-Ereignisse, Selbstlöschen) erscheinen im
// Verlauf, lösen aber keine Benachrichtigung / Web-Push aus.
if message.Type == "system" {
return
}
title, conversationType, conversationImage, err := s.chatNotificationTitle(ctx, message)
if err != nil {
return
}
@ -340,12 +337,13 @@ func (s *Server) publishChatMessage(
}
data, err := json.Marshal(map[string]any{
"conversationId": message.ConversationID,
"messageId": message.ID,
"senderId": message.Sender.ID,
"senderAvatar": message.Sender.Avatar,
"senderName": chatUserDisplayName(message.Sender),
"conversationType": conversationType,
"conversationId": message.ConversationID,
"messageId": message.ID,
"senderId": message.Sender.ID,
"senderAvatar": message.Sender.Avatar,
"senderName": chatUserDisplayName(message.Sender),
"conversationType": conversationType,
"conversationImage": conversationImage,
})
if err != nil {
return
@ -379,17 +377,7 @@ func (s *Server) chatConversationMemberIDs(ctx context.Context, conversationID s
`
SELECT cm.user_id::TEXT
FROM conversation_members cm
JOIN conversations c ON c.id = cm.conversation_id
WHERE cm.conversation_id = $1
AND (
c.type <> 'team'
OR EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = cm.user_id
)
)
`,
conversationID,
)
@ -426,6 +414,65 @@ func (s *Server) publishChatMessageUpdate(ctx context.Context, message ChatMessa
}
}
// publishConversationUpdate sendet jedem Mitglied die aktualisierte
// Konversation (Name/Bild/Selbstlöschen/Mitglieder), damit Seitenleiste und
// geöffneter Chat live aktualisiert werden.
func (s *Server) publishConversationUpdate(ctx context.Context, conversationID string) {
rows, err := s.db.Query(
ctx,
`
SELECT cm.user_id::TEXT, u.show_last_seen
FROM conversation_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.conversation_id = $1
`,
conversationID,
)
if err != nil {
return
}
defer rows.Close()
type member struct {
id string
showLastSeen bool
}
members := []member{}
for rows.Next() {
var m member
if err := rows.Scan(&m.id, &m.showLastSeen); err != nil {
return
}
members = append(members, m)
}
rows.Close()
for _, m := range members {
conversation, err := s.getChatConversation(ctx, conversationID, m.id, m.showLastSeen)
if err != nil {
continue
}
chatSockets.publish(m.id, chatSocketEvent{
Type: "conversation.updated",
Conversation: &conversation,
})
}
}
func (s *Server) publishChatMessagesReload(ctx context.Context, conversationID string) {
memberIDs, err := s.chatConversationMemberIDs(ctx, conversationID)
if err != nil {
return
}
for _, memberID := range memberIDs {
chatSockets.publish(memberID, chatSocketEvent{
Type: "messages.reload",
ConversationID: conversationID,
})
}
}
func chatUserDisplayName(user ChatUser) string {
if user.DisplayName != "" {
return user.DisplayName
@ -439,31 +486,32 @@ func chatUserDisplayName(user ChatUser) string {
func (s *Server) chatNotificationTitle(
ctx context.Context,
message ChatMessage,
) (string, string, error) {
) (string, string, string, error) {
var conversationType string
var conversationName string
var conversationImage string
err := s.db.QueryRow(
ctx,
`
SELECT type, name
SELECT type, name, channel_image
FROM conversations
WHERE id = $1
`,
message.ConversationID,
).Scan(&conversationType, &conversationName)
).Scan(&conversationType, &conversationName, &conversationImage)
if err != nil {
return "", "", err
return "", "", "", err
}
senderName := chatUserDisplayName(message.Sender)
if conversationType == "team" && conversationName != "" {
return senderName + " in " + conversationName, conversationType, nil
if conversationType == "group" && conversationName != "" {
return senderName + " in " + conversationName, conversationType, conversationImage, nil
}
if conversationType == "channel" && conversationName != "" {
return "Neue Meldung in " + conversationName, conversationType, nil
return "Neue Meldung in " + conversationName, conversationType, conversationImage, nil
}
return "Neue Nachricht von " + senderName, conversationType, nil
return "Neue Nachricht von " + senderName, conversationType, conversationImage, nil
}

96
backend/cmd/vapid/main.go Normal file
View File

@ -0,0 +1,96 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"strings"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/joho/godotenv"
)
func main() {
writeEnv := flag.Bool("write-env", false, "VAPID-Schlüssel direkt in eine .env-Datei schreiben")
envPath := flag.String("env", ".env", "Pfad der zu aktualisierenden .env-Datei")
flag.Parse()
_ = godotenv.Load()
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
log.Fatal(err)
}
email := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
if email == "" {
email = "admin@example.com"
}
values := map[string]string{
"VAPID_PUBLIC_KEY": publicKey,
"VAPID_PRIVATE_KEY": privateKey,
"VAPID_SUBSCRIBER": "mailto:" + email,
}
if *writeEnv {
if err := writeVAPIDEnv(*envPath, values); err != nil {
log.Fatal(err)
}
fmt.Printf("VAPID-Konfiguration wurde in %s geschrieben.\n", *envPath)
return
}
fmt.Printf("VAPID_PUBLIC_KEY=%s\n", values["VAPID_PUBLIC_KEY"])
fmt.Printf("VAPID_PRIVATE_KEY=%s\n", values["VAPID_PRIVATE_KEY"])
fmt.Printf("VAPID_SUBSCRIBER=%s\n", values["VAPID_SUBSCRIBER"])
}
func writeVAPIDEnv(path string, values map[string]string) error {
content, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
lines := []string{}
if len(content) > 0 {
lines = strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n")
}
for _, line := range lines {
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
if (key == "VAPID_PUBLIC_KEY" || key == "VAPID_PRIVATE_KEY") &&
strings.TrimSpace(value) != "" {
return fmt.Errorf("in %s sind bereits VAPID-Schlüssel vorhanden", path)
}
}
updated := map[string]bool{}
for index, line := range lines {
key, _, found := strings.Cut(line, "=")
if !found {
continue
}
if value, ok := values[key]; ok {
lines[index] = key + "=" + value
updated[key] = true
}
}
for _, key := range []string{
"VAPID_PUBLIC_KEY",
"VAPID_PRIVATE_KEY",
"VAPID_SUBSCRIBER",
} {
if !updated[key] {
lines = append(lines, key+"="+values[key])
}
}
output := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n"
return os.WriteFile(path, []byte(output), 0o600)
}

View File

@ -5,6 +5,7 @@ module main
go 1.26.2
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coder/websocket v1.8.14
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.9.2

View File

@ -1,10 +1,14 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -22,12 +26,75 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -38,6 +38,10 @@ func main() {
server := NewServer(db, jwtSecret, frontendOrigin)
server.startPresenceMonitor(ctx)
server.startDisappearingMessagesMonitor(ctx)
if !server.webPushConfigured() {
log.Println("Web Push ist nicht konfiguriert. VAPID_PUBLIC_KEY und VAPID_PRIVATE_KEY sowie eine Absenderadresse fehlen.")
}
log.Printf("Backend läuft auf http://localhost:%s", port)

View File

@ -12,34 +12,48 @@ import (
)
type notificationPreferencesResponse struct {
InAppEnabled bool `json:"inAppEnabled"`
SoundEnabled bool `json:"soundEnabled"`
SoundName string `json:"soundName"`
EmailEnabled bool `json:"emailEnabled"`
OperationUpdates bool `json:"operationUpdates"`
OperationJournals bool `json:"operationJournals"`
DeviceUpdates bool `json:"deviceUpdates"`
DeviceJournals bool `json:"deviceJournals"`
SystemMessages bool `json:"systemMessages"`
ChatMessages bool `json:"chatMessages"`
ChannelMessages bool `json:"channelMessages"`
DesktopEnabled bool `json:"desktopEnabled"`
SoundEnabled bool `json:"soundEnabled"`
SoundName string `json:"soundName"`
EmailEnabled bool `json:"emailEnabled"`
DeviceUpdates bool `json:"deviceUpdates"`
DeviceJournals bool `json:"deviceJournals"`
ChatMessages bool `json:"chatMessages"`
ChannelMessages bool `json:"channelMessages"`
DesktopEnabled bool `json:"desktopEnabled"`
PushChatMessages bool `json:"pushChatMessages"`
PushChannelMessages bool `json:"pushChannelMessages"`
}
func defaultNotificationPreferences() notificationPreferencesResponse {
return notificationPreferencesResponse{
InAppEnabled: true,
SoundEnabled: false,
SoundName: "default",
EmailEnabled: false,
OperationUpdates: true,
OperationJournals: true,
DeviceUpdates: true,
DeviceJournals: true,
SystemMessages: true,
ChatMessages: true,
ChannelMessages: true,
DesktopEnabled: false,
SoundEnabled: false,
SoundName: "default",
EmailEnabled: false,
DeviceUpdates: true,
DeviceJournals: true,
ChatMessages: true,
ChannelMessages: true,
DesktopEnabled: false,
PushChatMessages: true,
PushChannelMessages: true,
}
}
// shouldPushNotification entscheidet anhand der granularen Push-Einstellungen,
// ob für den jeweiligen Benachrichtigungstyp ein Web-Push gesendet werden soll.
// Es deckt aktuell die Nachrichtentypen ab, für die der Web-Push-Payload korrekt
// aufgebaut werden kann (Chat- und Channel-Nachrichten).
func shouldPushNotification(
preferences notificationPreferencesResponse,
notificationType string,
) bool {
switch notificationType {
case "channel.message":
return preferences.PushChannelMessages
case "chat.message":
return preferences.PushChatMessages
default:
return false
}
}
@ -100,49 +114,43 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
`
INSERT INTO notification_preferences (
user_id,
in_app_enabled,
sound_enabled,
sound_name,
email_enabled,
operation_updates,
operation_journals,
device_updates,
device_journals,
system_messages,
chat_messages,
channel_messages,
desktop_enabled
desktop_enabled,
push_chat_messages,
push_channel_messages
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (user_id)
DO UPDATE SET
in_app_enabled = EXCLUDED.in_app_enabled,
sound_enabled = EXCLUDED.sound_enabled,
sound_name = EXCLUDED.sound_name,
email_enabled = EXCLUDED.email_enabled,
operation_updates = EXCLUDED.operation_updates,
operation_journals = EXCLUDED.operation_journals,
device_updates = EXCLUDED.device_updates,
device_journals = EXCLUDED.device_journals,
system_messages = EXCLUDED.system_messages,
chat_messages = EXCLUDED.chat_messages,
channel_messages = EXCLUDED.channel_messages,
desktop_enabled = EXCLUDED.desktop_enabled,
push_chat_messages = EXCLUDED.push_chat_messages,
push_channel_messages = EXCLUDED.push_channel_messages,
updated_at = now()
`,
user.ID,
input.InAppEnabled,
input.SoundEnabled,
input.SoundName,
input.EmailEnabled,
input.OperationUpdates,
input.OperationJournals,
input.DeviceUpdates,
input.DeviceJournals,
input.SystemMessages,
input.ChatMessages,
input.ChannelMessages,
input.DesktopEnabled,
input.PushChatMessages,
input.PushChannelMessages,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden")
@ -167,36 +175,32 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string)
ctx,
`
SELECT
in_app_enabled,
sound_enabled,
COALESCE(sound_name, 'default'),
email_enabled,
operation_updates,
operation_journals,
device_updates,
device_journals,
system_messages,
chat_messages,
channel_messages,
desktop_enabled
desktop_enabled,
push_chat_messages,
push_channel_messages
FROM notification_preferences
WHERE user_id = $1
LIMIT 1
`,
userID,
).Scan(
&settings.InAppEnabled,
&settings.SoundEnabled,
&settings.SoundName,
&settings.EmailEnabled,
&settings.OperationUpdates,
&settings.OperationJournals,
&settings.DeviceUpdates,
&settings.DeviceJournals,
&settings.SystemMessages,
&settings.ChatMessages,
&settings.ChannelMessages,
&settings.DesktopEnabled,
&settings.PushChatMessages,
&settings.PushChannelMessages,
)
settings.SoundName = normalizeNotificationSoundName(settings.SoundName)

View File

@ -104,24 +104,14 @@ func shouldDeliverNotification(
case strings.HasPrefix(notificationType, "chat.") || entityType == "chat":
return preferences.ChatMessages
case !preferences.InAppEnabled:
return false
case notificationType == "operation.journal":
return preferences.OperationJournals
case strings.HasPrefix(notificationType, "operation.") || entityType == "operation":
return preferences.OperationUpdates
case notificationType == "device.journal":
return preferences.DeviceJournals
case strings.HasPrefix(notificationType, "device.") || entityType == "device":
return preferences.DeviceUpdates
case strings.HasPrefix(notificationType, "system.") || entityType == "system":
return preferences.SystemMessages
// Systemmeldungen werden immer ausgeliefert; Einsatz-Benachrichtigungen
// werden ausschließlich als stille Events gesendet (siehe createAndPublish).
default:
return true
}
@ -157,7 +147,7 @@ func (s *Server) createAndPublishNotification(
return nil
}
inAppVisible := silent || (preferences.InAppEnabled && entityType != "chat")
inAppVisible := silent || entityType != "chat"
var notification Notification
var rawData []byte
@ -229,7 +219,7 @@ func (s *Server) createAndPublishNotification(
if !notification.Silent {
notification.SoundName = notificationSoundName(preferences)
notification.Desktop = preferences.DesktopEnabled
notification.InApp = preferences.InAppEnabled
notification.InApp = true
if notification.EntityType == "chat" {
if notification.Type == "channel.message" {
notification.InApp = preferences.ChannelMessages
@ -240,6 +230,11 @@ func (s *Server) createAndPublishNotification(
}
notificationsBroker.publish(notification)
if !notification.Silent &&
preferences.DesktopEnabled &&
shouldPushNotification(preferences, notification.Type) {
s.publishWebPushAsync(notification)
}
return nil
}
@ -478,6 +473,8 @@ func (s *Server) createOperationNotification(
return err
}
// Einsatz-Benachrichtigungen werden ausschließlich als stille Events
// gesendet (kein sichtbarer Toast / keine Notification-Center-Einträge).
if err := s.createAndPublishNotification(
ctx,
recipientID,
@ -487,7 +484,7 @@ func (s *Server) createOperationNotification(
"operation",
operation.ID,
dataJSON,
false,
true,
); err != nil {
return err
}

View File

@ -0,0 +1,508 @@
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"html"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
)
const (
maxPushSubscriptionBodyBytes = 16 * 1024
maxPushIconImageBytes = 3 * 1024 * 1024
)
type pushSubscriptionRequest struct {
Endpoint string `json:"endpoint"`
APIBaseURL string `json:"apiBaseUrl"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}
type storedPushSubscription struct {
Endpoint string
P256dh string
Auth string
APIBaseURL string
}
type webPushPayload struct {
Title string `json:"title"`
Body string `json:"body"`
Icon string `json:"icon,omitempty"`
Badge string `json:"badge,omitempty"`
Tag string `json:"tag,omitempty"`
URL string `json:"url"`
}
func (s *Server) webPushConfigured() bool {
return s.vapidPublicKey != "" &&
s.vapidPrivateKey != "" &&
s.vapidSubscriber != ""
}
func (s *Server) handleGetPushConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.webPushConfigured(),
"publicKey": s.vapidPublicKey,
})
}
func (s *Server) handleSavePushSubscription(w http.ResponseWriter, r *http.Request) {
if !s.webPushConfigured() {
writeError(w, http.StatusServiceUnavailable, "Web Push ist auf dem Server noch nicht konfiguriert")
return
}
user, userOK := userFromContext(r.Context())
sessionID, sessionOK := s.currentSessionIDFromRequest(r)
if !userOK || !sessionOK {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxPushSubscriptionBodyBytes)
var input pushSubscriptionRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Push-Abonnement")
return
}
input.Endpoint = strings.TrimSpace(input.Endpoint)
input.Keys.P256dh = strings.TrimSpace(input.Keys.P256dh)
input.Keys.Auth = strings.TrimSpace(input.Keys.Auth)
apiBaseURL, err := normalizePushAPIBaseURL(input.APIBaseURL)
if err != nil ||
input.Endpoint == "" ||
input.Keys.P256dh == "" ||
input.Keys.Auth == "" {
writeError(w, http.StatusBadRequest, "Ungültiges Push-Abonnement")
return
}
endpointURL, err := url.Parse(input.Endpoint)
if err != nil ||
endpointURL.Host == "" ||
(endpointURL.Scheme != "http" && endpointURL.Scheme != "https") {
writeError(w, http.StatusBadRequest, "Ungültiger Push-Endpunkt")
return
}
_, err = s.db.Exec(
r.Context(),
`
INSERT INTO push_subscriptions (
endpoint,
user_id,
session_id,
p256dh,
auth,
api_base_url,
user_agent,
expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, now() + interval '24 hours')
ON CONFLICT (endpoint)
DO UPDATE SET
user_id = EXCLUDED.user_id,
session_id = EXCLUDED.session_id,
p256dh = EXCLUDED.p256dh,
auth = EXCLUDED.auth,
api_base_url = EXCLUDED.api_base_url,
user_agent = EXCLUDED.user_agent,
expires_at = EXCLUDED.expires_at,
updated_at = now()
`,
input.Endpoint,
user.ID,
sessionID,
input.Keys.P256dh,
input.Keys.Auth,
apiBaseURL,
strings.TrimSpace(r.UserAgent()),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Push-Abonnement konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"subscribed": true})
}
func (s *Server) handleDeletePushSubscription(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxPushSubscriptionBodyBytes)
var input struct {
Endpoint string `json:"endpoint"`
}
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Push-Abonnement")
return
}
_, err := s.db.Exec(
r.Context(),
`DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2`,
strings.TrimSpace(input.Endpoint),
user.ID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Push-Abonnement konnte nicht entfernt werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"subscribed": false})
}
func normalizePushAPIBaseURL(value string) (string, error) {
value = strings.TrimRight(strings.TrimSpace(value), "/")
if value == "" || len(value) > 500 {
return "", errors.New("ungültige API-URL")
}
parsed, err := url.Parse(value)
if err != nil ||
(parsed.Scheme != "http" && parsed.Scheme != "https") ||
parsed.Host == "" {
return "", errors.New("ungültige API-URL")
}
return value, nil
}
func (s *Server) publishWebPushAsync(notification Notification) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.publishWebPush(ctx, notification); err != nil {
log.Printf("Web Push für User %s fehlgeschlagen: %v", notification.UserID, err)
}
}()
}
func (s *Server) publishWebPush(ctx context.Context, notification Notification) error {
rows, err := s.db.Query(
ctx,
`
SELECT
ps.endpoint,
ps.p256dh,
ps.auth,
ps.api_base_url
FROM push_subscriptions ps
JOIN user_sessions us ON us.id = ps.session_id
WHERE ps.user_id = $1
AND us.revoked_at IS NULL
AND ps.expires_at > now()
`,
notification.UserID,
)
if err != nil {
return err
}
defer rows.Close()
subscriptions := []storedPushSubscription{}
for rows.Next() {
var subscription storedPushSubscription
if err := rows.Scan(
&subscription.Endpoint,
&subscription.P256dh,
&subscription.Auth,
&subscription.APIBaseURL,
); err != nil {
return err
}
subscriptions = append(subscriptions, subscription)
}
if err := rows.Err(); err != nil {
return err
}
for _, subscription := range subscriptions {
payload, err := s.buildWebPushPayload(notification, subscription.APIBaseURL)
if err != nil {
continue
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
continue
}
response, err := webpush.SendNotificationWithContext(
ctx,
payloadJSON,
&webpush.Subscription{
Endpoint: subscription.Endpoint,
Keys: webpush.Keys{
P256dh: subscription.P256dh,
Auth: subscription.Auth,
},
},
&webpush.Options{
Subscriber: s.vapidSubscriber,
VAPIDPublicKey: s.vapidPublicKey,
VAPIDPrivateKey: s.vapidPrivateKey,
TTL: 60 * 60,
HTTPClient: &http.Client{Timeout: 12 * time.Second},
},
)
if err != nil {
continue
}
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
if response.StatusCode == http.StatusGone ||
response.StatusCode == http.StatusNotFound {
cleanupCtx, cleanupCancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
_, _ = s.db.Exec(
cleanupCtx,
`DELETE FROM push_subscriptions WHERE endpoint = $1`,
subscription.Endpoint,
)
cleanupCancel()
}
}
return nil
}
func (s *Server) buildWebPushPayload(
notification Notification,
apiBaseURL string,
) (webPushPayload, error) {
var data struct {
SenderID string `json:"senderId"`
SenderAvatar string `json:"senderAvatar"`
ConversationType string `json:"conversationType"`
ConversationImage string `json:"conversationImage"`
}
if err := json.Unmarshal(notification.Data, &data); err != nil {
return webPushPayload{}, err
}
path := "/chat/" + notification.EntityID
if data.ConversationType == "channel" {
path = "/chat/channel/" + notification.EntityID
}
iconKind := "user"
iconID := data.SenderID
iconSource := strings.TrimSpace(data.SenderAvatar)
if data.ConversationType == "group" {
iconKind = "group"
iconID = notification.EntityID
iconSource = strings.TrimSpace(data.ConversationImage)
} else if data.ConversationType == "channel" || iconID == "" {
iconKind = "channel"
iconID = notification.EntityID
iconSource = strings.TrimSpace(data.ConversationImage)
}
// Externe Bild-URLs direkt verwenden. Für lokale Avatare (data:-URLs) oder
// fehlende Avatare auf den signierten Icon-Endpunkt verweisen, der entweder
// das hinterlegte Bild ausliefert oder ein Initialen-Icon erzeugt.
iconURL := iconSource
isRemote := strings.HasPrefix(iconURL, "https://") ||
strings.HasPrefix(iconURL, "http://")
if !isRemote {
iconURL = ""
if apiBaseURL != "" && iconID != "" {
signature := s.signPushIcon(iconKind, iconID)
iconURL = apiBaseURL + "/push/icons/" + iconKind + "/" + iconID + "/" + signature
iconURL += "?v=" + pushIconVersion(iconSource)
}
}
return webPushPayload{
Title: notification.Title,
Body: notification.Message,
Icon: iconURL,
Tag: "chat-" + notification.EntityID,
URL: path,
}, nil
}
func pushIconVersion(image string) string {
sum := sha256.Sum256([]byte(image))
return hex.EncodeToString(sum[:8])
}
func (s *Server) signPushIcon(kind string, id string) string {
mac := hmac.New(sha256.New, s.jwtSecret)
_, _ = mac.Write([]byte(kind + ":" + id))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Server) handlePushIcon(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSpace(r.PathValue("kind"))
id := strings.TrimSpace(r.PathValue("id"))
signature := strings.TrimSpace(r.PathValue("signature"))
expectedSignature := s.signPushIcon(kind, id)
if subtle.ConstantTimeCompare(
[]byte(signature),
[]byte(expectedSignature),
) != 1 {
http.NotFound(w, r)
return
}
var avatar string
var label string
var err error
switch kind {
case "user":
err = s.db.QueryRow(
r.Context(),
`SELECT COALESCE(avatar, ''), COALESCE(NULLIF(display_name, ''), username, '') FROM users WHERE id = $1`,
id,
).Scan(&avatar, &label)
case "channel":
err = s.db.QueryRow(
r.Context(),
`
SELECT COALESCE(channel_image, ''), COALESCE(name, '')
FROM conversations
WHERE id = $1 AND type = 'channel'
`,
id,
).Scan(&avatar, &label)
case "group":
err = s.db.QueryRow(
r.Context(),
`
SELECT COALESCE(channel_image, ''), COALESCE(name, '')
FROM conversations
WHERE id = $1 AND type = 'group'
`,
id,
).Scan(&avatar, &label)
default:
http.NotFound(w, r)
return
}
if err != nil {
http.NotFound(w, r)
return
}
if avatar != "" {
if contentType, image, decodeErr := decodeAvatarDataURL(avatar); decodeErr == nil {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(image)
return
}
}
// Kein (dekodierbares) Bild hinterlegt: deterministisches Initialen-Icon
// erzeugen, damit die Benachrichtigung trotzdem den Absender zeigt.
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(initialsIconSVG(label, kind+":"+id))
}
func initialsIconSVG(label string, seed string) []byte {
initials := deriveInitials(label)
hue := hueFromSeed(seed)
background := fmt.Sprintf("hsl(%d, 60%%, 45%%)", hue)
svg := fmt.Sprintf(
`<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">`+
`<rect width="192" height="192" rx="40" fill="%s"/>`+
`<text x="96" y="96" dy="0.35em" text-anchor="middle" `+
`font-family="Segoe UI,Roboto,Helvetica,Arial,sans-serif" `+
`font-size="86" font-weight="600" fill="#ffffff">%s</text>`+
`</svg>`,
background,
html.EscapeString(initials),
)
return []byte(svg)
}
func deriveInitials(label string) string {
fields := strings.Fields(label)
if len(fields) == 0 {
return "?"
}
firstRune := func(value string) string {
for _, r := range value {
return strings.ToUpper(string(r))
}
return ""
}
if len(fields) == 1 {
runes := []rune(fields[0])
if len(runes) >= 2 {
return strings.ToUpper(string(runes[:2]))
}
return strings.ToUpper(string(runes))
}
return firstRune(fields[0]) + firstRune(fields[len(fields)-1])
}
func hueFromSeed(seed string) int {
hash := fnv.New32a()
_, _ = hash.Write([]byte(seed))
return int(hash.Sum32() % 360)
}
func decodeAvatarDataURL(value string) (string, []byte, error) {
metadata, encoded, found := strings.Cut(value, ",")
if !found || !strings.HasSuffix(metadata, ";base64") {
return "", nil, errors.New("ungültiges Avatarformat")
}
contentType := strings.TrimSuffix(strings.TrimPrefix(metadata, "data:"), ";base64")
switch contentType {
case "image/png", "image/jpeg", "image/webp", "image/gif":
default:
return "", nil, errors.New("nicht unterstütztes Avatarformat")
}
image, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || len(image) == 0 || len(image) > maxPushIconImageBytes {
return "", nil, errors.New("ungültige Avatardaten")
}
return contentType, image, nil
}

View File

@ -11,6 +11,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("POST /webhooks/channels/{slug}", s.handleChannelWebhook)
mux.HandleFunc("GET /push/icons/{kind}/{id}/{signature}", s.handlePushIcon)
mux.HandleFunc("POST /auth/register", s.handleRegister)
mux.HandleFunc("POST /auth/login", s.handleLogin)
@ -26,6 +27,9 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /settings/notifications/preferences", s.requireAuth(s.handleGetNotificationPreferences))
mux.HandleFunc("PUT /settings/notifications/preferences", s.requireAuth(s.handleUpdateNotificationPreferences))
mux.HandleFunc("GET /push/config", s.requireAuth(s.handleGetPushConfig))
mux.HandleFunc("POST /push/subscriptions", s.requireAuth(s.handleSavePushSubscription))
mux.HandleFunc("DELETE /push/subscriptions", s.requireAuth(s.handleDeletePushSubscription))
mux.HandleFunc("POST /feedback", s.requireAuth(s.handleCreateFeedback))
@ -86,6 +90,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /settings/presence", s.requireAuth(s.handleGetPresenceSettings))
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 /chat/conversations", s.requireAuth(s.handleListChatConversations))
mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages))
@ -100,6 +105,12 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /chat/conversations/{id}/messages", s.requireAuth(s.handleCreateChatMessage))
mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead))
mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute))
mux.HandleFunc("PUT /chat/conversations/{id}/disappearing", s.requireAuth(s.handleUpdateDisappearing))
mux.HandleFunc("POST /chat/groups", s.requireAuth(s.handleCreateGroup))
mux.HandleFunc("PUT /chat/groups/{id}", s.requireAuth(s.handleUpdateGroup))
mux.HandleFunc("DELETE /chat/groups/{id}", s.requireAuth(s.handleDeleteGroup))
mux.HandleFunc("POST /chat/groups/{id}/members", s.requireAuth(s.handleAddGroupMembers))
mux.HandleFunc("DELETE /chat/groups/{id}/members/{userId}", s.requireAuth(s.handleRemoveGroupMember))
mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges)))
@ -130,6 +141,11 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /admin/channels", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateChannel)))
mux.HandleFunc("PUT /admin/channels/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminUpdateChannel)))
mux.HandleFunc("POST /admin/channels/{id}/token", s.requireAuth(s.requireRight("administration:write", s.handleAdminRotateChannelToken)))
mux.HandleFunc("GET /admin/group-chats", s.requireAuth(s.requireRight("administration:read", s.handleAdminListGroupChats)))
mux.HandleFunc("PUT /admin/group-chats/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminUpdateGroupChat)))
mux.HandleFunc("DELETE /admin/group-chats/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminDeleteGroupChat)))
mux.HandleFunc("GET /admin/calendar/settings", s.requireAuth(s.requireRight("administration:read", s.handleGetCalendarSettings)))
mux.HandleFunc("PUT /admin/calendar/settings", s.requireAuth(s.requireRight("administration:write", s.handleUpdateCalendarSettings)))
/* milestone settings */

View File

@ -7,6 +7,7 @@ import (
"crypto/subtle"
"errors"
"net/http"
"os"
"strings"
"sync"
"time"
@ -25,11 +26,14 @@ const (
)
type Server struct {
db *pgxpool.Pool
jwtSecret []byte
frontendOrigin string
presenceMu sync.Mutex
presenceStatus map[string]string
db *pgxpool.Pool
jwtSecret []byte
frontendOrigin string
vapidPublicKey string
vapidPrivateKey string
vapidSubscriber string
presenceMu sync.Mutex
presenceStatus map[string]string
}
type User struct {
@ -92,11 +96,21 @@ type Claims struct {
}
func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Server {
vapidSubscriber := strings.TrimSpace(os.Getenv("VAPID_SUBSCRIBER"))
if vapidSubscriber == "" {
if adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")); adminEmail != "" {
vapidSubscriber = "mailto:" + adminEmail
}
}
return &Server{
db: db,
jwtSecret: []byte(jwtSecret),
frontendOrigin: frontendOrigin,
presenceStatus: map[string]string{},
db: db,
jwtSecret: []byte(jwtSecret),
frontendOrigin: frontendOrigin,
vapidPublicKey: strings.TrimSpace(os.Getenv("VAPID_PUBLIC_KEY")),
vapidPrivateKey: strings.TrimSpace(os.Getenv("VAPID_PRIVATE_KEY")),
vapidSubscriber: vapidSubscriber,
presenceStatus: map[string]string{},
}
}
@ -252,6 +266,17 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(input.Password)); err != nil {
// Fehlgeschlagene Logins werden protokolliert. Diese Einträge bleiben
// erhalten, da das Protokoll nur bei erfolgreichem Login geleert wird.
s.logUserEvent(r.Context(), UserLogEntry{
User: &user,
Request: r,
Level: UserLogLevelWarning,
Category: "auth",
Action: "login",
Message: "Login fehlgeschlagen: falsches Passwort",
})
writeError(w, http.StatusUnauthorized, "Invalid username/email or password")
return
}
@ -273,10 +298,23 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
user.LastSeenAt = &now
if err := s.setSessionCookie(w, user, sessionID); err != nil {
s.logUserError(
r,
user,
"auth",
"login",
"Sitzungs-Cookie konnte nicht erstellt werden",
err,
nil,
)
writeError(w, http.StatusInternalServerError, "Could not create session")
return
}
// Erfolgreicher Login: Protokoll des Users vollständig leeren (inklusive des
// soeben erzeugten Sitzungs-Eintrags), damit es danach komplett leer ist.
s.deleteUserLogs(r.Context(), user.ID)
writeJSON(w, http.StatusOK, map[string]any{
"user": user,
})
@ -284,6 +322,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if sessionID, ok := s.currentSessionIDFromCookie(r); ok {
_, _ = s.db.Exec(
r.Context(),
`DELETE FROM push_subscriptions WHERE session_id = $1`,
sessionID,
)
_, _ = s.db.Exec(
r.Context(),
`

View File

@ -385,6 +385,28 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
`
CREATE TABLE IF NOT EXISTS calendar_settings (
id SMALLINT PRIMARY KEY CHECK (id = 1),
core_working_hours JSONB NOT NULL DEFAULT '{
"monday": {"enabled": true, "start": "08:00", "end": "17:00"},
"tuesday": {"enabled": true, "start": "08:00", "end": "17:00"},
"wednesday": {"enabled": true, "start": "08:00", "end": "17:00"},
"thursday": {"enabled": true, "start": "08:00", "end": "17:00"},
"friday": {"enabled": true, "start": "08:00", "end": "17:00"},
"saturday": {"enabled": false, "start": "08:00", "end": "17:00"},
"sunday": {"enabled": false, "start": "08:00", "end": "17:00"}
}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
INSERT INTO calendar_settings (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING
`,
`
CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -625,6 +647,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
chat_messages BOOLEAN NOT NULL DEFAULT true,
channel_messages BOOLEAN NOT NULL DEFAULT true,
desktop_enabled BOOLEAN NOT NULL DEFAULT false,
push_chat_messages BOOLEAN NOT NULL DEFAULT true,
push_channel_messages BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@ -634,6 +658,24 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS chat_messages BOOLEAN NOT NULL DEFAULT true`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS channel_messages BOOLEAN NOT NULL DEFAULT true`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS desktop_enabled BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS push_chat_messages BOOLEAN NOT NULL DEFAULT true`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS push_channel_messages BOOLEAN NOT NULL DEFAULT true`,
`
CREATE TABLE IF NOT EXISTS push_subscriptions (
endpoint TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id UUID NOT NULL REFERENCES user_sessions(id) ON DELETE CASCADE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
api_base_url TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '24 hours',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS push_subscriptions ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '24 hours'`,
`
CREATE TABLE IF NOT EXISTS feedback (
@ -707,7 +749,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL DEFAULT 'direct'
CHECK (type IN ('team', 'direct', 'group', 'channel')),
CHECK (type IN ('direct', 'group', 'channel')),
name TEXT NOT NULL DEFAULT '',
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
@ -727,12 +769,16 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
last_message_at TIMESTAMPTZ,
disappearing_seconds INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS conversations DROP CONSTRAINT IF EXISTS conversations_type_check`,
`ALTER TABLE IF EXISTS conversations ADD CONSTRAINT conversations_type_check CHECK (type IN ('team', 'direct', 'group', 'channel'))`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS disappearing_seconds INTEGER NOT NULL DEFAULT 0`,
`UPDATE conversations SET type = 'group', created_by = NULL WHERE type = 'team'`,
`ALTER TABLE IF EXISTS conversations ADD CONSTRAINT conversations_type_check CHECK (type IN ('direct', 'group', 'channel'))`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS slug TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_source_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_image TEXT NOT NULL DEFAULT ''`,
@ -768,6 +814,10 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
message_type TEXT NOT NULL DEFAULT 'user',
system_event JSONB,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
@ -776,6 +826,9 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT 'user'`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS system_event JSONB`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ`,
// Standort-Nachrichten: lat/lng sind NULL, wenn die Nachricht keinen Standort enthält.
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_lat DOUBLE PRECISION`,
@ -877,6 +930,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_notifications_entity ON notifications(entity_type, entity_id)`,
`CREATE INDEX IF NOT EXISTS idx_notification_preferences_user_id ON notification_preferences(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_session_id ON push_subscriptions(session_id)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON feedback(created_at)`,
@ -896,6 +951,38 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type)`,
`CREATE INDEX IF NOT EXISTS idx_conversations_last_message_at ON conversations(last_message_at DESC)`,
`
INSERT INTO conversations (type, name, team_id)
SELECT 'group', name, id
FROM teams
ON CONFLICT (team_id) WHERE team_id IS NOT NULL
DO UPDATE SET
type = 'group',
name = EXCLUDED.name,
created_by = NULL,
updated_at = now()
`,
`
DELETE FROM conversation_members cm
USING conversations c
WHERE cm.conversation_id = c.id
AND c.team_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = cm.user_id
)
`,
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT c.id, ut.user_id
FROM conversations c
JOIN user_teams ut ON ut.team_id = c.team_id
WHERE c.type = 'group'
AND c.team_id IS NOT NULL
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
`
INSERT INTO conversations (
type,
name,
@ -925,6 +1012,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_reply_to_message_id ON messages(reply_to_message_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_forwarded_from_message_id ON messages(forwarded_from_message_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_expires_at ON messages(expires_at) WHERE expires_at IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`,
}

View File

@ -128,6 +128,27 @@ func (s *Server) logUserEvent(ctx context.Context, entry UserLogEntry) {
s.pruneAnonymousUserLogs(ctx)
}
// deleteUserLogs entfernt sämtliche Protokolleinträge eines Users. Wird nach
// einem erfolgreichen Login aufgerufen, damit das Protokoll danach leer ist.
func (s *Server) deleteUserLogs(ctx context.Context, userID string) {
userID = strings.TrimSpace(userID)
if userID == "" {
return
}
_, err := s.db.Exec(
ctx,
`DELETE FROM user_logs WHERE user_id = $1`,
userID,
)
if err != nil {
logError("User-Logs konnten beim Login nicht gelöscht werden", err, LogFields{
"userId": userID,
})
}
}
func (s *Server) pruneUserLogs(ctx context.Context, user *User) {
if user == nil || user.ID == "" {
return

View File

@ -397,6 +397,12 @@ func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request)
return
}
_, _ = s.db.Exec(
r.Context(),
`DELETE FROM push_subscriptions WHERE session_id = $1`,
sessionID,
)
s.logRequestInfo(
r,
"session",

View File

@ -245,6 +245,17 @@ func (s *Server) handleLogoutOtherSessions(w http.ResponseWriter, r *http.Reques
return
}
_, _ = s.db.Exec(
r.Context(),
`
DELETE FROM push_subscriptions
WHERE user_id = $1
AND session_id <> $2
`,
userID,
currentSessionID,
)
writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Andere Sitzungen wurden abgemeldet",
})

View File

@ -0,0 +1 @@
VITE_API_URL="/api"

3
frontend/.gitignore vendored
View File

@ -12,6 +12,9 @@ dist
dist-ssr
*.local
# Local dev TLS certificates (mkcert)
certs
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

@ -3,8 +3,11 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>teg</title>
<meta name="theme-color" content="#4f46e5" />
<meta name="application-name" content="TEG" />
<title>TEG</title>
</head>
<body>
<div id="root"></div>

View File

@ -27,6 +27,7 @@
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
@ -2250,6 +2251,19 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",

View File

@ -29,6 +29,7 @@
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",

View File

@ -0,0 +1,18 @@
{
"name": "TEG",
"short_name": "TEG",
"description": "TEG Einsatz-, Geräte- und Chatverwaltung",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#030712",
"theme_color": "#4f46e5",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

64
frontend/public/sw.js Normal file
View File

@ -0,0 +1,64 @@
const DEFAULT_ICON = '/favicon.svg'
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('push', (event) => {
let payload = {}
try {
payload = event.data ? event.data.json() : {}
} catch {
payload = {
title: 'Neue TEG-Nachricht',
body: event.data ? event.data.text() : '',
url: '/chat',
}
}
const title = payload.title || 'Neue TEG-Nachricht'
const icon = payload.icon || DEFAULT_ICON
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',
icon,
badge: payload.badge || DEFAULT_ICON,
tag: payload.tag || 'teg-message',
renotify: true,
requireInteraction: true,
data: {
url: payload.url || '/chat',
},
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const targetURL = new URL(
event.notification.data?.url || '/chat',
self.location.origin,
).href
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then(async (windowClients) => {
for (const client of windowClients) {
if ('navigate' in client) {
await client.navigate(targetURL)
}
return client.focus()
}
return self.clients.openWindow(targetURL)
}),
)
})

View File

@ -8,7 +8,7 @@ import AppLayout from './components/AppLayout'
import DashboardPage from './pages/DashboardPage'
import DevicesPage from './pages/devices/DevicesPage'
import OperationsPage from './pages/operations/OperationsPage'
import CalendarPage from './pages/CalendarPage'
import CalendarPage from './pages/calendar/CalendarPage'
import DocumentsPage from './pages/DocumentsPage'
import ReportsPage from './pages/ReportsPage'
import SettingsPage from './pages/settings/SettingsPage'
@ -23,6 +23,8 @@ import MilestoneDevicesPage from './pages/operations/milestone-devices/Milestone
import { hasRight } from './components/permissions'
import ChatPage from './pages/chat/ChatPage'
import { recordNavigation } from './utils/activityLog'
import { syncExistingPushSubscription } from './utils/pushNotifications'
import { notifyNewMessage } from './utils/titleNotifier'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -78,7 +80,11 @@ function resolveNotificationImage(value: unknown) {
}
try {
return new URL(image, API_URL).toString()
const apiBaseURL = new URL(
`${API_URL.replace(/\/$/, '')}/`,
window.location.origin,
)
return new URL(image.replace(/^\//, ''), apiBaseURL).toString()
} catch {
return new URL('/favicon.svg', window.location.origin).toString()
}
@ -157,6 +163,12 @@ function App() {
recordNavigation(location.pathname)
}, [location.pathname])
useEffect(() => {
if (currentUserId) {
void syncExistingPushSubscription().catch(() => undefined)
}
}, [currentUserId])
useEffect(() => {
if (!currentUserId) {
setNotifications([])
@ -180,9 +192,6 @@ function App() {
if (notification.entityType === 'chat') {
const data = getNotificationData(notification)
const senderAvatar = resolveNotificationImage(data.senderAvatar)
const desktopIcon =
senderAvatar ||
new URL('/favicon.svg', window.location.origin).toString()
const senderName =
typeof data.senderName === 'string' ? data.senderName : notification.title
const isChannel = data.conversationType === 'channel'
@ -190,30 +199,6 @@ function App() {
? `/chat/channel/${notification.entityId}`
: `/chat/${notification.entityId}`
if (
notification.desktop &&
'Notification' in window &&
Notification.permission === 'granted'
) {
try {
const desktopNotification = new Notification(notification.title, {
body: notification.message,
icon: desktopIcon,
badge: desktopIcon,
tag: `chat-${notification.entityId}`,
requireInteraction: true,
})
desktopNotification.onclick = () => {
window.focus()
navigate(chatPath)
desktopNotification.close()
}
} catch {
// Das sichtbare Browser-Popup darunter bleibt als Fallback erhalten.
}
}
if (
notification.inAppEnabled !== false &&
document.visibilityState === 'visible'
@ -252,6 +237,10 @@ function App() {
})
}
if (document.visibilityState !== 'visible') {
notifyNewMessage()
}
playNotificationSound(notification.soundName)
return
}

View File

@ -28,6 +28,7 @@ type ModalProps = {
children?: ReactNode
panelClassName?: string
backdropClassName?: string
zIndexClassName?: string
}
@ -35,6 +36,25 @@ function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
export function ModalTitle({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<DialogTitle
className={classNames(
'text-base font-semibold text-gray-950 dark:text-white',
className,
)}
>
{children}
</DialogTitle>
)
}
export default function Modal({
open,
setOpen,
@ -46,6 +66,7 @@ export default function Modal({
onConfirm,
children,
panelClassName,
backdropClassName,
zIndexClassName = 'z-[9999]',
}: ModalProps) {
const isSuccess = variant === 'success-single' || variant === 'success-wide'
@ -82,7 +103,10 @@ export default function Modal({
>
<DialogBackdrop
transition
className="fixed inset-0 z-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
className={classNames(
'fixed inset-0 z-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50',
backdropClassName,
)}
/>
<div className="fixed inset-0 z-10 w-screen overflow-hidden">

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,85 @@
export const calendarWeekdays = [
{ key: 'monday', label: 'Montag', jsDay: 1 },
{ key: 'tuesday', label: 'Dienstag', jsDay: 2 },
{ key: 'wednesday', label: 'Mittwoch', jsDay: 3 },
{ key: 'thursday', label: 'Donnerstag', jsDay: 4 },
{ key: 'friday', label: 'Freitag', jsDay: 5 },
{ key: 'saturday', label: 'Samstag', jsDay: 6 },
{ key: 'sunday', label: 'Sonntag', jsDay: 0 },
] as const
export type CalendarWeekdayKey = (typeof calendarWeekdays)[number]['key']
export type CalendarWorkingHoursDay = {
enabled: boolean
start: string
end: string
}
export type CalendarCoreWorkingHours = Record<
CalendarWeekdayKey,
CalendarWorkingHoursDay
>
export type CalendarSettings = {
coreWorkingHours: CalendarCoreWorkingHours
updatedAt?: string
}
const workingDay: CalendarWorkingHoursDay = {
enabled: true,
start: '08:00',
end: '17:00',
}
const dayOff: CalendarWorkingHoursDay = {
enabled: false,
start: '08:00',
end: '17:00',
}
export function createDefaultCalendarSettings(): CalendarSettings {
return {
coreWorkingHours: {
monday: { ...workingDay },
tuesday: { ...workingDay },
wednesday: { ...workingDay },
thursday: { ...workingDay },
friday: { ...workingDay },
saturday: { ...dayOff },
sunday: { ...dayOff },
},
}
}
export function normalizeCalendarSettings(
value: Partial<CalendarSettings> | null | undefined,
): CalendarSettings {
const defaults = createDefaultCalendarSettings()
return {
coreWorkingHours: Object.fromEntries(
calendarWeekdays.map(({ key }) => [
key,
{
...defaults.coreWorkingHours[key],
...value?.coreWorkingHours?.[key],
},
]),
) as CalendarCoreWorkingHours,
updatedAt: value?.updatedAt,
}
}
export function timeToMinutes(value: string) {
const [hours, minutes] = value.split(':').map(Number)
return hours * 60 + minutes
}
export function workingHoursForDate(
workingHours: CalendarCoreWorkingHours,
date: Date,
) {
const weekday = calendarWeekdays.find(({ jsDay }) => jsDay === date.getDay())
return weekday ? workingHours[weekday.key] : undefined
}

View File

@ -0,0 +1,6 @@
export function toDateKey(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}

View File

@ -7,8 +7,12 @@ import './index.css'
import App from './App.tsx'
import { NotificationProvider } from './components/Notifications.tsx'
import { initActivityLog } from './utils/activityLog.ts'
import { registerServiceWorker } from './utils/pushNotifications.ts'
initActivityLog()
void registerServiceWorker().catch(() => {
// Die App bleibt ohne Service Worker nutzbar, nur Hintergrund-Push entfällt.
})
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@ -1,11 +0,0 @@
// frontend/src/pages/CalendarPage.tsx
export default function CalendarPage() {
return (
<div className="px-4 py-10 sm:px-6 lg:px-8">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
Kalender
</h1>
</div>
)
}

View File

@ -7,6 +7,7 @@ import Card from '../components/Card'
import Checkbox from '../components/Checkbox'
import type { User } from '../components/types'
import { getCurrentSessionInfo } from '../utils/sessionInfo'
import { clearActivityLog } from '../utils/activityLog'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -90,6 +91,10 @@ export default function LoginPage({
const data = await response.json().catch(() => null)
const user = data?.user as User | undefined
// Erfolgreicher Login: clientseitiges Aktivitätsprotokoll leeren, damit es
// (wie das serverseitige User-Protokoll) nach dem Login leer startet.
clearActivityLog()
if (user) {
onLogin?.(user)
}

View File

@ -2,18 +2,18 @@
import { useLocation, Navigate } from 'react-router'
import {
CalendarDaysIcon,
ChatBubbleLeftRightIcon,
HashtagIcon,
UserGroupIcon,
UsersIcon,
VideoCameraIcon,
} from '@heroicons/react/20/solid'
import Tabs from '../../components/Tabs'
import UsersAdministration from './users/UsersAdministration'
import TeamsAdministration from './teams/TeamsAdministration'
import FeedbackAdministration from './feedback/FeedbackAdministration'
import Milestone from './milestone/Milestone'
import ChannelsAdministration from './channels/ChannelsAdministration'
import ChatsAdministration from './chats/ChatsAdministration'
import CalendarAdministration from './calendar/CalendarAdministration'
export default function AdministrationPage() {
const location = useLocation()
@ -30,16 +30,10 @@ export default function AdministrationPage() {
current: section === 'benutzer',
},
{
name: 'Teams',
href: '/administration/teams',
name: 'Teams & Chats',
href: '/administration/chats/teams',
icon: UserGroupIcon,
current: section === 'teams',
},
{
name: 'Channels',
href: '/administration/channels',
icon: HashtagIcon,
current: section === 'channels',
current: section === 'chats',
},
{
name: 'Milestone',
@ -47,6 +41,12 @@ export default function AdministrationPage() {
icon: VideoCameraIcon,
current: section === 'milestone',
},
{
name: 'Kalender',
href: '/administration/kalender',
icon: CalendarDaysIcon,
current: section === 'kalender',
},
{
name: 'Feedback',
href: '/administration/feedback',
@ -55,7 +55,19 @@ export default function AdministrationPage() {
},
]
if (!['benutzer', 'teams', 'channels', 'milestone', 'feedback'].includes(section)) {
if (section === 'teams') {
return <Navigate to="/administration/chats/teams" replace />
}
if (section === 'channels') {
return <Navigate to="/administration/chats/channels" replace />
}
if (
!['benutzer', 'chats', 'milestone', 'kalender', 'feedback'].includes(
section,
)
) {
return <Navigate to="/administration/benutzer" replace />
}
@ -80,14 +92,14 @@ export default function AdministrationPage() {
</h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Benutzer verwalten, Teams zuweisen und Integrationen konfigurieren.
Benutzer, Kommunikation, Kalender und Integrationen verwalten.
</p>
</div>
{section === 'benutzer' && <UsersAdministration />}
{section === 'teams' && <TeamsAdministration />}
{section === 'channels' && <ChannelsAdministration />}
{section === 'chats' && <ChatsAdministration />}
{section === 'milestone' && <Milestone />}
{section === 'kalender' && <CalendarAdministration />}
{section === 'feedback' && <FeedbackAdministration />}
</div>
</div>

View File

@ -0,0 +1,251 @@
import { ClockIcon } from '@heroicons/react/20/solid'
import { useEffect, useState, type FormEvent } from 'react'
import Button from '../../../components/Button'
import { useNotifications } from '../../../components/Notifications'
import Switch from '../../../components/Switch'
import {
calendarWeekdays,
createDefaultCalendarSettings,
normalizeCalendarSettings,
timeToMinutes,
type CalendarSettings,
} from '../../../components/calendar/calendarSettings'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const timeInputClassName =
'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500 dark:disabled:bg-white/[0.03] dark:disabled:text-gray-600'
export default function CalendarAdministration() {
const { showToast, showErrorToast } = useNotifications()
const [settings, setSettings] = useState<CalendarSettings>(
createDefaultCalendarSettings,
)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [formError, setFormError] = useState('')
useEffect(() => {
let ignore = false
void fetch(`${API_URL}/admin/calendar/settings`, {
credentials: 'include',
})
.then(async (response) => {
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(
data?.error ?? 'Kalender-Einstellungen konnten nicht geladen werden',
)
}
if (!ignore) {
setSettings(normalizeCalendarSettings(data?.settings))
}
})
.catch((error) => {
if (!ignore) {
showErrorToast({
title: 'Kalender-Einstellungen konnten nicht geladen werden',
error,
fallback: 'Kalender-Einstellungen konnten nicht geladen werden',
})
}
})
.finally(() => {
if (!ignore) {
setIsLoading(false)
}
})
return () => {
ignore = true
}
}, [showErrorToast])
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setFormError('')
for (const weekday of calendarWeekdays) {
const hours = settings.coreWorkingHours[weekday.key]
if (
hours.enabled &&
timeToMinutes(hours.end) <= timeToMinutes(hours.start)
) {
setFormError(
`${weekday.label}: Das Ende muss nach dem Beginn liegen.`,
)
return
}
}
setIsSaving(true)
try {
const response = await fetch(`${API_URL}/admin/calendar/settings`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
coreWorkingHours: settings.coreWorkingHours,
}),
})
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(
data?.error ?? 'Kalender-Einstellungen konnten nicht gespeichert werden',
)
}
setSettings(normalizeCalendarSettings(data?.settings))
showToast({
title: 'Kernarbeitszeiten gespeichert',
message:
'Die Tages- und Wochenansicht des Kalenders verwendet jetzt die neuen Zeiten.',
variant: 'success',
})
} catch (error) {
showErrorToast({
title: 'Kernarbeitszeiten konnten nicht gespeichert werden',
error,
fallback: 'Kernarbeitszeiten konnten nicht gespeichert werden',
})
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<div className="rounded-2xl bg-white p-6 text-sm text-gray-500 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:ring-white/10">
Kalender-Einstellungen werden geladen...
</div>
)
}
return (
<div className="grid grid-cols-1 gap-x-10 gap-y-8 xl:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
<div>
<div className="flex size-10 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
<ClockIcon className="size-5" />
</div>
<h2 className="mt-4 text-base font-semibold text-gray-900 dark:text-white">
Kernarbeitszeiten
</h2>
<p className="mt-2 text-sm/6 text-gray-500 dark:text-gray-400">
Lege für jeden Wochentag den hervorgehobenen Arbeitszeitraum fest.
Deaktivierte Tage erhalten im Kalender keine Markierung.
</p>
</div>
<form
onSubmit={handleSubmit}
className="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
>
<div className="hidden grid-cols-[minmax(9rem,1fr)_auto_8.5rem_8.5rem] gap-4 border-b border-gray-200 bg-gray-50 px-5 py-3 text-xs font-semibold uppercase tracking-wide text-gray-500 sm:grid dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
<span>Wochentag</span>
<span>Aktiv</span>
<span>Beginn</span>
<span>Ende</span>
</div>
<div className="divide-y divide-gray-200 dark:divide-white/10">
{calendarWeekdays.map((weekday) => {
const hours = settings.coreWorkingHours[weekday.key]
return (
<div
key={weekday.key}
className="grid grid-cols-2 items-center gap-4 px-5 py-4 sm:grid-cols-[minmax(9rem,1fr)_auto_8.5rem_8.5rem]"
>
<span className="font-medium text-gray-900 dark:text-white">
{weekday.label}
</span>
<Switch
variant="short"
checked={hours.enabled}
aria-label={`${weekday.label} aktivieren`}
onChange={(event) =>
setSettings((current) => ({
...current,
coreWorkingHours: {
...current.coreWorkingHours,
[weekday.key]: {
...hours,
enabled: event.target.checked,
},
},
}))
}
/>
<label className="space-y-1 sm:space-y-0">
<span className="block text-xs font-medium text-gray-500 sm:sr-only">
Beginn
</span>
<input
type="time"
step={1800}
value={hours.start}
disabled={!hours.enabled}
aria-label={`${weekday.label} Beginn`}
onChange={(event) =>
setSettings((current) => ({
...current,
coreWorkingHours: {
...current.coreWorkingHours,
[weekday.key]: {
...hours,
start: event.target.value,
},
},
}))
}
className={timeInputClassName}
/>
</label>
<label className="space-y-1 sm:space-y-0">
<span className="block text-xs font-medium text-gray-500 sm:sr-only">
Ende
</span>
<input
type="time"
step={1800}
value={hours.end}
disabled={!hours.enabled}
aria-label={`${weekday.label} Ende`}
onChange={(event) =>
setSettings((current) => ({
...current,
coreWorkingHours: {
...current.coreWorkingHours,
[weekday.key]: {
...hours,
end: event.target.value,
},
},
}))
}
className={timeInputClassName}
/>
</label>
</div>
)
})}
</div>
<div className="border-t border-gray-200 bg-gray-50 px-5 py-4 dark:border-white/10 dark:bg-white/5">
{formError && (
<p className="mb-3 text-sm font-medium text-red-600 dark:text-red-300">
{formError}
</p>
)}
<Button type="submit" isLoading={isSaving}>
Kernarbeitszeiten speichern
</Button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,50 @@
import { Navigate, useLocation } from 'react-router'
import Tabs from '../../../components/Tabs'
import ChannelsAdministration from '../channels/ChannelsAdministration'
import GroupChatsAdministration from '../groups/GroupChatsAdministration'
import TeamsAdministration from '../teams/TeamsAdministration'
const sections = ['teams', 'gruppen', 'channels'] as const
export default function ChatsAdministration() {
const location = useLocation()
const section = location.pathname.split('/').filter(Boolean)[2] ?? 'teams'
if (!sections.includes(section as (typeof sections)[number])) {
return <Navigate to="/administration/chats/teams" replace />
}
const tabs = [
{
name: 'Teams',
href: '/administration/chats/teams',
current: section === 'teams',
},
{
name: 'Gruppenchats',
href: '/administration/chats/gruppen',
current: section === 'gruppen',
},
{
name: 'Channels',
href: '/administration/chats/channels',
current: section === 'channels',
},
]
return (
<section>
<div className="mb-6">
<Tabs
tabs={tabs}
variant="bar-underline"
ariaLabel="Teams und Chats"
/>
</div>
{section === 'teams' && <TeamsAdministration />}
{section === 'gruppen' && <GroupChatsAdministration />}
{section === 'channels' && <ChannelsAdministration />}
</section>
)
}

View File

@ -0,0 +1,529 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
} from 'react'
import {
MagnifyingGlassIcon,
PhotoIcon,
TrashIcon,
UserGroupIcon,
} from '@heroicons/react/20/solid'
import Avatar from '../../../components/Avatar'
import Button from '../../../components/Button'
import Checkbox from '../../../components/Checkbox'
import LoadingSpinner from '../../../components/LoadingSpinner'
import { useNotifications } from '../../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type AdminGroupChat = {
id: string
name: string
image: string
ownerId: string
userIds: string[]
createdAt: string
updatedAt: string
}
type AdminGroupUser = {
id: string
username: string
displayName: string
email: string
unit: string
}
type GroupDraft = {
id: string
name: string
image: string
ownerId: string
userIds: string[]
}
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 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:focus:outline-indigo-500'
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
function userDisplayName(user: AdminGroupUser) {
return user.displayName || user.username || user.email
}
function groupToDraft(group: AdminGroupChat): GroupDraft {
return {
id: group.id,
name: group.name,
image: group.image,
ownerId: group.ownerId,
userIds: group.userIds ?? [],
}
}
export default function GroupChatsAdministration() {
const { showToast, showErrorToast } = useNotifications()
const [groups, setGroups] = useState<AdminGroupChat[]>([])
const [users, setUsers] = useState<AdminGroupUser[]>([])
const [draft, setDraft] = useState<GroupDraft | null>(null)
const [groupSearch, setGroupSearch] = useState('')
const [userSearch, setUserSearch] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const imageInputRef = useRef<HTMLInputElement | null>(null)
const loadGroups = useCallback(
async (preferredId?: string) => {
setIsLoading(true)
try {
const response = await fetch(`${API_URL}/admin/group-chats`, {
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Gruppenchats konnten nicht geladen werden',
),
)
}
const data = await response.json()
const nextGroups = (data.groups ?? []) as AdminGroupChat[]
setGroups(nextGroups)
setUsers(data.users ?? [])
const selected =
nextGroups.find((group) => group.id === preferredId) ??
nextGroups[0] ??
null
setDraft(selected ? groupToDraft(selected) : null)
} catch (error) {
showErrorToast({
title: 'Gruppenchats konnten nicht geladen werden',
error,
fallback: 'Gruppenchats konnten nicht geladen werden',
})
} finally {
setIsLoading(false)
}
},
[showErrorToast],
)
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void loadGroups()
}, 0)
return () => window.clearTimeout(timeoutId)
}, [loadGroups])
const filteredGroups = useMemo(() => {
const search = groupSearch.trim().toLowerCase()
if (!search) {
return groups
}
return groups.filter((group) => group.name.toLowerCase().includes(search))
}, [groupSearch, groups])
const filteredUsers = useMemo(() => {
const search = userSearch.trim().toLowerCase()
if (!search) {
return users
}
return users.filter((user) =>
[
user.displayName,
user.username,
user.email,
user.unit,
]
.join(' ')
.toLowerCase()
.includes(search),
)
}, [userSearch, users])
const owner = users.find((user) => user.id === draft?.ownerId)
function selectGroup(group: AdminGroupChat) {
setDraft(groupToDraft(group))
setUserSearch('')
}
function updateDraft<Key extends keyof GroupDraft>(
key: Key,
value: GroupDraft[Key],
) {
setDraft((current) => (current ? { ...current, [key]: value } : current))
}
function toggleUser(userId: string, checked: boolean) {
if (!draft || userId === draft.ownerId) {
return
}
updateDraft(
'userIds',
checked
? [...new Set([...draft.userIds, userId])]
: draft.userIds.filter((id) => id !== userId),
)
}
function handleImageFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) {
return
}
if (!file.type.startsWith('image/')) {
showToast({
title: 'Ungültige Bilddatei',
message: 'Bitte wähle eine gültige Bilddatei aus.',
variant: 'error',
})
return
}
if (file.size > 3 * 1024 * 1024) {
showToast({
title: 'Gruppenbild zu groß',
message: 'Das Gruppenbild darf maximal 3 MB groß sein.',
variant: 'error',
})
return
}
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
updateDraft('image', reader.result)
}
}
reader.readAsDataURL(file)
}
async function saveGroup(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!draft) {
return
}
setIsSaving(true)
try {
const response = await fetch(`${API_URL}/admin/group-chats/${draft.id}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: draft.name,
image: draft.image,
userIds: draft.userIds,
}),
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Gruppenchat konnte nicht gespeichert werden',
),
)
}
await loadGroups(draft.id)
showToast({
title: 'Gruppenchat gespeichert',
message: 'Name, Bild und Mitglieder wurden aktualisiert.',
variant: 'success',
})
} catch (error) {
showErrorToast({
title: 'Gruppenchat konnte nicht gespeichert werden',
error,
fallback: 'Gruppenchat konnte nicht gespeichert werden',
})
} finally {
setIsSaving(false)
}
}
async function deleteGroup() {
if (
!draft ||
!window.confirm(
`Möchtest du den Gruppenchat „${draft.name}“ wirklich löschen? Alle Nachrichten und Anhänge werden dauerhaft gelöscht.`,
)
) {
return
}
setIsDeleting(true)
try {
const response = await fetch(`${API_URL}/admin/group-chats/${draft.id}`, {
method: 'DELETE',
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Gruppenchat konnte nicht gelöscht werden',
),
)
}
await loadGroups()
showToast({
title: 'Gruppenchat gelöscht',
message: 'Der Gruppenchat und sein Verlauf wurden dauerhaft gelöscht.',
variant: 'success',
})
} catch (error) {
showErrorToast({
title: 'Gruppenchat konnte nicht gelöscht werden',
error,
fallback: 'Gruppenchat konnte nicht gelöscht werden',
})
} finally {
setIsDeleting(false)
}
}
if (isLoading) {
return (
<div className="rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<LoadingSpinner label="Gruppenchats werden geladen..." center />
</div>
)
}
if (!draft) {
return (
<div className="rounded-xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<UserGroupIcon className="mx-auto size-10 text-gray-300 dark:text-gray-600" />
<h2 className="mt-3 text-sm font-semibold text-gray-900 dark:text-white">
Keine freien Gruppenchats
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Neue Gruppenchats werden im Chat erstellt und können anschließend hier
administriert werden.
</p>
</div>
)
}
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="border-b border-gray-200 p-4 dark:border-white/10">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Gruppenchats
</h2>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{filteredGroups.length} von {groups.length} angezeigt
</p>
<div className="relative mt-4">
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
/>
<input
type="search"
value={groupSearch}
onChange={(event) => setGroupSearch(event.target.value)}
placeholder="Gruppenchats suchen..."
className={`${inputClassName} pl-9`}
/>
</div>
</div>
<div className="max-h-[calc(100dvh-22rem)] space-y-1 overflow-y-auto p-2">
{filteredGroups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => selectGroup(group)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm ${
draft.id === group.id
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5'
}`}
>
<Avatar
src={group.image}
name={group.name}
size={9}
shape="rounded"
/>
<span className="min-w-0">
<span className="block truncate font-medium">{group.name}</span>
<span className="block truncate text-xs opacity-70">
{group.userIds.length} Mitglieder
</span>
</span>
</button>
))}
</div>
</aside>
<form
onSubmit={saveGroup}
className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
Gruppenchat bearbeiten
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Der Ersteller bleibt Mitglied; weitere Mitglieder können zentral
verwaltet werden.
</p>
</div>
<div className="mt-5">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Gruppenname
<input
required
maxLength={80}
value={draft.name}
onChange={(event) => updateDraft('name', event.target.value)}
className={`${inputClassName} mt-2`}
/>
</label>
</div>
<div className="mt-5">
<p className="text-sm font-medium text-gray-900 dark:text-white">
Gruppenbild
</p>
<div className="mt-2 flex items-center gap-4">
<Avatar
src={draft.image}
name={draft.name || 'Gruppenchat'}
size={14}
shape="rounded"
/>
<div>
<input
ref={imageInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={handleImageFileChange}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
leadingIcon={<PhotoIcon className="size-4" />}
onClick={() => imageInputRef.current?.click()}
>
Bild hochladen
</Button>
{draft.image && (
<Button
type="button"
variant="secondary"
color="red"
size="sm"
onClick={() => updateDraft('image', '')}
>
Entfernen
</Button>
)}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Optional: PNG, JPG, WEBP oder GIF bis maximal 3 MB.
</p>
</div>
</div>
</div>
<div className="mt-6 border-t border-gray-200 pt-5 dark:border-white/10">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Mitglieder
</h3>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Ersteller: {owner ? userDisplayName(owner) : 'Unbekannt'}
</p>
</div>
<div className="relative w-full sm:w-64">
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
/>
<input
type="search"
value={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder="Mitglieder suchen..."
className={`${inputClassName} pl-9`}
/>
</div>
</div>
<div className="mt-3 grid max-h-72 gap-2 overflow-y-auto rounded-lg border border-gray-200 p-3 sm:grid-cols-2 dark:border-white/10">
{filteredUsers.map((user) => {
const isOwner = user.id === draft.ownerId
return (
<Checkbox
key={user.id}
checked={isOwner || draft.userIds.includes(user.id)}
disabled={isOwner}
onChange={(event) =>
toggleUser(user.id, event.target.checked)
}
label={
isOwner
? `${userDisplayName(user)} (Ersteller)`
: userDisplayName(user)
}
description={user.unit || user.email}
/>
)
})}
</div>
</div>
<div className="mt-6 flex flex-wrap justify-between gap-3">
<Button
type="button"
variant="secondary"
color="red"
disabled={isSaving}
isLoading={isDeleting}
leadingIcon={<TrashIcon className="size-4" />}
onClick={() => void deleteGroup()}
>
Gruppenchat löschen
</Button>
<Button
type="submit"
color="indigo"
disabled={isDeleting}
isLoading={isSaving}
>
Gruppenchat speichern
</Button>
</div>
</form>
</div>
)
}

View File

@ -16,6 +16,7 @@ type AdminTeam = {
name: string
initial: string
href: string
conversationId: string
}
const inputClassName =
@ -263,7 +264,9 @@ export default function TeamsAdministration() {
</span>
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
{team.href || '#'}
{team.conversationId
? 'Gruppenchat aktiv'
: 'Gruppenchat wird vorbereitet'}
</span>
</span>
</button>
@ -294,6 +297,11 @@ export default function TeamsAdministration() {
</div>
<div className="mt-4 space-y-4">
<div className="rounded-lg bg-indigo-50 p-4 text-sm text-indigo-800 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/20">
Für jedes Team wird automatisch ein Gruppenchat mit allen
Teammitgliedern geführt.
</div>
<div>
<label
htmlFor="team-name"

View File

@ -0,0 +1,612 @@
import { TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { useEffect, useState, type FormEvent } from 'react'
import Modal, { ModalTitle } from '../../components/Modal'
import Calendar, {
type CalendarEvent,
type CalendarEventColor,
type CalendarView,
} from '../../components/calendar/Calendar'
import {
createDefaultCalendarSettings,
normalizeCalendarSettings,
type CalendarSettings,
} from '../../components/calendar/calendarSettings'
import { toDateKey } from '../../components/calendar/dateUtils'
const STORAGE_KEY = 'teg.calendar.events.v1'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type EventDraft = {
title: string
startDate: string
endDate: string
startTime: string
endTime: string
allDay: boolean
location: string
description: string
color: CalendarEventColor
}
const colorOptions: Array<{
value: CalendarEventColor
label: string
className: string
}> = [
{ value: 'indigo', label: 'Indigo', className: 'bg-indigo-500' },
{ value: 'blue', label: 'Blau', className: 'bg-blue-500' },
{ value: 'emerald', label: 'Grün', className: 'bg-emerald-500' },
{ value: 'amber', label: 'Gelb', className: 'bg-amber-500' },
{ value: 'rose', label: 'Rot', className: 'bg-rose-500' },
{ value: 'slate', label: 'Grau', className: 'bg-slate-500' },
]
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function dateAt(date: Date, hour: number, minute = 0) {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hour,
minute,
)
}
function toLocalDateTime(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 createSeedEvents() {
const today = new Date()
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const later = new Date(today)
later.setDate(later.getDate() + 3)
return [
{
id: 'welcome-team',
title: 'Teambesprechung',
start: toLocalDateTime(dateAt(today, 10)),
end: toLocalDateTime(dateAt(today, 11)),
allDay: false,
location: 'Besprechungsraum 1',
description: 'Kurze Abstimmung zu den aktuellen Aufgaben.',
color: 'indigo',
},
{
id: 'welcome-planning',
title: 'Einsatzplanung',
start: toLocalDateTime(dateAt(tomorrow, 14)),
end: toLocalDateTime(dateAt(tomorrow, 15, 30)),
allDay: false,
location: 'Leitstelle',
description: '',
color: 'blue',
},
{
id: 'welcome-maintenance',
title: 'Wartungsfenster',
start: toLocalDateTime(dateAt(later, 0)),
end: toLocalDateTime(dateAt(later, 23, 59)),
allDay: true,
location: '',
description: 'Geplante Wartung der Infrastruktur.',
color: 'amber',
},
] satisfies CalendarEvent[]
}
function loadEvents() {
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
if (!stored) {
return createSeedEvents()
}
const parsed = JSON.parse(stored)
return Array.isArray(parsed) ? (parsed as CalendarEvent[]) : createSeedEvents()
} catch {
return createSeedEvents()
}
}
function timeFromMinutes(minutes: number) {
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(
minutes % 60,
).padStart(2, '0')}`
}
function emptyDraft(start: Date, end?: Date, allDay = false): EventDraft {
const now = new Date()
const isToday = toDateKey(start) === toDateKey(now)
const proposedHour = isToday
? Math.min(22, Math.max(8, now.getHours() + 1))
: 9
const startMinutes = end
? start.getHours() * 60 + start.getMinutes()
: proposedHour * 60
const resolvedEnd = end ?? new Date(start)
if (!end) {
resolvedEnd.setHours(0, startMinutes + 30, 0, 0)
}
const endMinutes = resolvedEnd.getHours() * 60 + resolvedEnd.getMinutes()
return {
title: '',
startDate: toDateKey(start),
endDate: toDateKey(resolvedEnd),
startTime: timeFromMinutes(startMinutes),
endTime: timeFromMinutes(endMinutes),
allDay,
location: '',
description: '',
color: 'indigo',
}
}
function eventToDraft(event: CalendarEvent): EventDraft {
const start = new Date(event.start)
const end = new Date(event.end)
return {
title: event.title,
startDate: toDateKey(start),
endDate: toDateKey(end),
startTime: `${String(start.getHours()).padStart(2, '0')}:${String(
start.getMinutes(),
).padStart(2, '0')}`,
endTime: `${String(end.getHours()).padStart(2, '0')}:${String(
end.getMinutes(),
).padStart(2, '0')}`,
allDay: event.allDay,
location: event.location,
description: event.description,
color: event.color,
}
}
function createEventId() {
return typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `event-${Date.now()}`
}
export default function CalendarPage() {
const [events, setEvents] = useState<CalendarEvent[]>(loadEvents)
const [calendarSettings, setCalendarSettings] = useState<CalendarSettings>(
createDefaultCalendarSettings,
)
const [selectedDate, setSelectedDate] = useState(() => new Date())
const [view, setView] = useState<CalendarView>('month')
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [draft, setDraft] = useState<EventDraft>(() => emptyDraft(new Date()))
const [formError, setFormError] = useState('')
useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
}, [events])
useEffect(() => {
let ignore = false
void fetch(`${API_URL}/calendar/settings`, {
credentials: 'include',
})
.then(async (response) => {
if (!response.ok) {
return null
}
return response.json()
})
.then((data) => {
if (!ignore && data?.settings) {
setCalendarSettings(normalizeCalendarSettings(data.settings))
}
})
.catch(() => undefined)
return () => {
ignore = true
}
}, [])
function openCreate(start: Date, end?: Date, allDay?: boolean) {
setEditingEvent(null)
setDraft(emptyDraft(start, end, allDay))
setFormError('')
setEditorOpen(true)
}
function openEdit(event: CalendarEvent) {
setEditingEvent(event)
setDraft(eventToDraft(event))
setFormError('')
setEditorOpen(true)
}
function closeEditor() {
setEditorOpen(false)
setEditingEvent(null)
setFormError('')
}
function saveEvent(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const title = draft.title.trim()
if (!title) {
setFormError('Bitte gib einen Titel ein.')
return
}
const start = draft.allDay
? `${draft.startDate}T00:00`
: `${draft.startDate}T${draft.startTime}`
let end = draft.allDay
? `${draft.endDate}T23:59`
: `${draft.endDate}T${draft.endTime}`
if (new Date(end).getTime() <= new Date(start).getTime()) {
if (draft.allDay) {
end = `${draft.endDate}T23:59`
} else {
setFormError('Das Ende muss nach dem Beginn liegen.')
return
}
}
const nextEvent: CalendarEvent = {
id: editingEvent?.id ?? createEventId(),
title,
start,
end,
allDay: draft.allDay,
location: draft.location.trim(),
description: draft.description.trim(),
color: draft.color,
}
setEvents((current) =>
editingEvent
? current.map((item) =>
item.id === editingEvent.id ? nextEvent : item,
)
: [...current, nextEvent],
)
setSelectedDate(new Date(start))
closeEditor()
}
function deleteEvent() {
if (
!editingEvent ||
!window.confirm(`Termin „${editingEvent.title}“ wirklich löschen?`)
) {
return
}
setEvents((current) =>
current.filter((event) => event.id !== editingEvent.id),
)
closeEditor()
}
const inputClassName =
'block w-full rounded-xl border-0 bg-gray-100 px-3 py-2.5 text-sm text-gray-900 outline-none ring-1 ring-transparent focus:bg-white focus:ring-indigo-500 dark:bg-white/5 dark:text-white dark:focus:bg-white/10'
return (
<>
<div className="h-full min-h-0">
<Calendar
events={events}
coreWorkingHours={calendarSettings.coreWorkingHours}
selectedDate={selectedDate}
view={view}
onSelectedDateChange={setSelectedDate}
onViewChange={setView}
onCreateEvent={openCreate}
onEditEvent={openEdit}
/>
</div>
<Modal
open={editorOpen}
setOpen={(open) => {
if (!open) {
closeEditor()
}
}}
zIndexClassName="z-[10000]"
backdropClassName="bg-gray-950/60 backdrop-blur-sm dark:bg-gray-950/60"
panelClassName="max-h-[min(48rem,90vh)] max-w-xl rounded-2xl bg-white dark:bg-gray-900"
>
<form onSubmit={saveEvent} className="max-h-[min(48rem,90vh)] overflow-y-auto">
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-white/10">
<ModalTitle>
{editingEvent ? 'Termin bearbeiten' : 'Neuer Termin'}
</ModalTitle>
<button
type="button"
onClick={closeEditor}
className="inline-flex size-8 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-white/10 dark:hover:text-white"
>
<span className="sr-only">Schließen</span>
<XMarkIcon className="size-5" />
</button>
</div>
<div className="space-y-5 p-5">
<div>
<label
htmlFor="calendar-title"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Titel
</label>
<input
id="calendar-title"
autoFocus
value={draft.title}
onChange={(event) =>
setDraft((current) => ({
...current,
title: event.target.value,
}))
}
className={classNames(inputClassName, 'mt-2')}
placeholder="Was steht an?"
maxLength={120}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="calendar-start-date"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Startdatum
</label>
<input
id="calendar-start-date"
type="date"
value={draft.startDate}
onChange={(event) =>
setDraft((current) => {
const startDate = event.target.value
return {
...current,
startDate,
endDate:
current.endDate < startDate
? startDate
: current.endDate,
}
})
}
className={classNames(inputClassName, 'mt-2')}
/>
</div>
<div>
<label
htmlFor="calendar-start"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Beginn
</label>
<input
id="calendar-start"
type="time"
value={draft.startTime}
disabled={draft.allDay}
onChange={(event) =>
setDraft((current) => ({
...current,
startTime: event.target.value,
}))
}
className={classNames(
inputClassName,
'mt-2 disabled:cursor-not-allowed disabled:opacity-50',
)}
/>
</div>
<div>
<label
htmlFor="calendar-end-date"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Enddatum
</label>
<input
id="calendar-end-date"
type="date"
min={draft.startDate}
value={draft.endDate}
onChange={(event) =>
setDraft((current) => ({
...current,
endDate: event.target.value,
}))
}
className={classNames(inputClassName, 'mt-2')}
/>
</div>
<div>
<label
htmlFor="calendar-end"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Ende
</label>
<input
id="calendar-end"
type="time"
value={draft.endTime}
disabled={draft.allDay}
onChange={(event) =>
setDraft((current) => ({
...current,
endTime: event.target.value,
}))
}
className={classNames(
inputClassName,
'mt-2 disabled:cursor-not-allowed disabled:opacity-50',
)}
/>
</div>
</div>
<label className="flex cursor-pointer items-center gap-3 rounded-xl bg-gray-50 px-3 py-2.5 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
<input
type="checkbox"
checked={draft.allDay}
onChange={(event) =>
setDraft((current) => ({
...current,
allDay: event.target.checked,
}))
}
className="size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
Ganztägiger Termin
</span>
</label>
<div>
<label
htmlFor="calendar-location"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Ort
</label>
<input
id="calendar-location"
value={draft.location}
onChange={(event) =>
setDraft((current) => ({
...current,
location: event.target.value,
}))
}
className={classNames(inputClassName, 'mt-2')}
placeholder="Optional"
maxLength={160}
/>
</div>
<div>
<label
htmlFor="calendar-description"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Beschreibung
</label>
<textarea
id="calendar-description"
rows={3}
value={draft.description}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value,
}))
}
className={classNames(inputClassName, 'mt-2 resize-none')}
placeholder="Notizen zum Termin"
maxLength={1000}
/>
</div>
<fieldset>
<legend className="text-sm font-medium text-gray-900 dark:text-white">
Farbe
</legend>
<div className="mt-2 flex flex-wrap gap-2">
{colorOptions.map((color) => (
<label
key={color.value}
title={color.label}
className={classNames(
'flex size-9 cursor-pointer items-center justify-center rounded-full ring-2 ring-offset-2 ring-offset-white transition dark:ring-offset-gray-900',
draft.color === color.value
? 'ring-gray-900 dark:ring-white'
: 'ring-transparent hover:ring-gray-300 dark:hover:ring-white/30',
)}
>
<input
type="radio"
name="color"
value={color.value}
checked={draft.color === color.value}
onChange={() =>
setDraft((current) => ({
...current,
color: color.value,
}))
}
className="sr-only"
/>
<span
className={classNames(
'size-7 rounded-full',
color.className,
)}
/>
</label>
))}
</div>
</fieldset>
{formError && (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700 ring-1 ring-red-200 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-500/20">
{formError}
</p>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-gray-200 bg-gray-50 px-5 py-4 dark:border-white/10 dark:bg-white/5">
<div>
{editingEvent && (
<button
type="button"
onClick={deleteEvent}
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-500/10"
>
<TrashIcon className="size-4" />
Löschen
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeEditor}
className="rounded-lg px-3 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-white/10"
>
Abbrechen
</button>
<button
type="submit"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{editingEvent ? 'Speichern' : 'Termin erstellen'}
</button>
</div>
</div>
</form>
</Modal>
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ import {
} from 'react'
import {
BellIcon,
EnvelopeIcon,
ComputerDesktopIcon,
PlayIcon,
SpeakerWaveIcon,
} from '@heroicons/react/20/solid'
@ -17,39 +17,41 @@ import LoadingSpinner from '../../../components/LoadingSpinner'
import Switch from '../../../components/Switch'
import Button from '../../../components/Button'
import { useNotifications } from '../../../components/Notifications'
import {
disablePushNotifications,
enablePushNotifications,
isPushNotificationSupported,
syncExistingPushSubscription,
} from '../../../utils/pushNotifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type NotificationSoundName = 'default' | 'chime' | 'soft' | 'alert' | 'success'
type NotificationSettings = {
inAppEnabled: boolean
soundEnabled: boolean
soundName: NotificationSoundName
emailEnabled: boolean
operationUpdates: boolean
operationJournals: boolean
deviceUpdates: boolean
deviceJournals: boolean
systemMessages: boolean
chatMessages: boolean
channelMessages: boolean
desktopEnabled: boolean
pushChatMessages: boolean
pushChannelMessages: boolean
}
const defaultSettings: NotificationSettings = {
inAppEnabled: true,
soundEnabled: false,
soundName: 'default',
emailEnabled: false,
operationUpdates: true,
operationJournals: true,
deviceUpdates: true,
deviceJournals: true,
systemMessages: true,
chatMessages: true,
channelMessages: true,
desktopEnabled: false,
pushChatMessages: true,
pushChannelMessages: true,
}
const notificationSounds: Array<{
@ -151,12 +153,17 @@ export default function Benachrichtigungen() {
useState<NotificationSettings>(defaultSettings)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isPushSubscribed, setIsPushSubscribed] = useState(false)
const pushSupported = isPushNotificationSupported()
const settingsRef = useRef<NotificationSettings>(defaultSettings)
const saveRequestIdRef = useRef(0)
useEffect(() => {
void loadSettings()
void syncExistingPushSubscription()
.then(setIsPushSubscribed)
.catch(() => setIsPushSubscribed(false))
}, [])
async function loadSettings() {
@ -274,29 +281,31 @@ export default function Benachrichtigungen() {
}
async function enableDesktopNotifications() {
if (!('Notification' in window)) {
showToast({
title: 'Nicht unterstützt',
message: 'Dieser Browser unterstützt keine Windows-Benachrichtigungen.',
variant: 'warning',
try {
const subscribed = await enablePushNotifications()
setIsPushSubscribed(subscribed)
updateSetting('desktopEnabled', true)
} catch (error) {
showErrorToast({
title: 'Windows-Benachrichtigungen konnten nicht aktiviert werden',
error,
fallback: 'Windows-Benachrichtigungen konnten nicht aktiviert werden.',
})
return
}
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
showToast({
title: 'Berechtigung fehlt',
message: 'Windows-Benachrichtigungen wurden nicht freigegeben.',
variant: 'warning',
async function disableDesktopNotifications() {
try {
await disablePushNotifications()
setIsPushSubscribed(false)
updateSetting('desktopEnabled', false)
} catch (error) {
showErrorToast({
title: 'Windows-Benachrichtigungen konnten nicht deaktiviert werden',
error,
fallback: 'Windows-Benachrichtigungen konnten nicht deaktiviert werden.',
})
return
}
updateSetting('desktopEnabled', true)
new Notification('TEG-Benachrichtigungen aktiviert', {
body: 'Neue Chat- und Channel-Nachrichten können jetzt auf Windows-Ebene angezeigt werden.',
})
}
if (isLoading) {
@ -339,21 +348,11 @@ export default function Benachrichtigungen() {
title="In-App-Benachrichtigungen"
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden."
>
<Switch
checked={settings.inAppEnabled}
onChange={(event) =>
updateSetting('inAppEnabled', event.target.checked)
}
label="In der Anwendung anzeigen"
description="Zeigt Hinweise im Benachrichtigungscenter an."
/>
<Switch
checked={settings.soundEnabled}
onChange={(event) =>
updateSetting('soundEnabled', event.target.checked)
}
disabled={!settings.inAppEnabled}
label="Signalton abspielen"
description="Spielt einen kurzen Ton bei neuen Benachrichtigungen ab."
/>
@ -370,7 +369,7 @@ export default function Benachrichtigungen() {
<select
id="notification-sound"
value={settings.soundName}
disabled={!settings.inAppEnabled || !settings.soundEnabled}
disabled={!settings.soundEnabled}
onChange={(event) =>
updateSetting(
'soundName',
@ -391,7 +390,7 @@ export default function Benachrichtigungen() {
variant="secondary"
color="gray"
size="sm"
disabled={!settings.inAppEnabled || !settings.soundEnabled}
disabled={!settings.soundEnabled}
leadingIcon={<PlayIcon aria-hidden="true" className="size-4" />}
onClick={() => playNotificationSound(settings.soundName)}
>
@ -405,38 +404,6 @@ export default function Benachrichtigungen() {
</div>
</NotificationCard>
<NotificationCard
icon={<EnvelopeIcon aria-hidden="true" className="size-5" />}
title="Windows-Benachrichtigungen"
description="Zeigt neue Chat- und Channel-Nachrichten außerhalb des Browserfensters an."
>
<Switch
checked={settings.desktopEnabled}
onChange={(event) => {
if (event.target.checked) {
void enableDesktopNotifications()
return
}
updateSetting('desktopEnabled', false)
}}
label="Auf Windows-Ebene anzeigen"
description="Benötigt die Benachrichtigungsfreigabe des Browsers."
/>
{typeof Notification !== 'undefined' &&
Notification.permission !== 'granted' && (
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
onClick={() => void enableDesktopNotifications()}
>
Berechtigung erteilen
</Button>
)}
</NotificationCard>
<NotificationCard
icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />}
title="Ereignisse"
@ -459,36 +426,85 @@ export default function Benachrichtigungen() {
label="Channel-Nachrichten"
description="Zeigt eine Browser-Benachrichtigung bei neuen Meldungen aus Channels."
/>
</NotificationCard>
<NotificationCard
icon={<ComputerDesktopIcon aria-hidden="true" className="size-5" />}
title="Push-Benachrichtigungen"
description="Erhalte neue Nachrichten als Windows-Push, auch wenn kein Tab geöffnet ist."
>
<Switch
checked={settings.operationUpdates}
onChange={(event) =>
updateSetting('operationUpdates', event.target.checked)
}
disabled={!settings.inAppEnabled && !settings.emailEnabled}
label="Einsatzänderungen"
description="Benachrichtigt dich, wenn Einsätze erstellt oder geändert werden."
checked={settings.desktopEnabled}
onChange={(event) => {
if (event.target.checked) {
void enableDesktopNotifications()
return
}
void disableDesktopNotifications()
}}
label="Auf diesem Gerät empfangen"
description="Registriert dieses Gerät für Hintergrund-Push, auch ohne geöffneten Tab."
/>
<Switch
checked={settings.operationJournals}
onChange={(event) =>
updateSetting('operationJournals', event.target.checked)
}
disabled={!settings.inAppEnabled && !settings.emailEnabled}
label="Einsatz-Journal"
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Einsätzen."
/>
{!pushSupported && (
<p className="text-sm text-amber-700 dark:text-amber-300">
Push-Benachrichtigungen benötigen HTTPS oder localhost
und einen Browser mit Web-Push-Unterstützung.
</p>
)}
<Switch
checked={settings.systemMessages}
onChange={(event) =>
updateSetting('systemMessages', event.target.checked)
}
disabled={!settings.inAppEnabled && !settings.emailEnabled}
label="Systemmeldungen"
description="Benachrichtigt dich bei wichtigen Systemhinweisen."
/>
{pushSupported &&
settings.desktopEnabled &&
!isPushSubscribed && (
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
onClick={() => void enableDesktopNotifications()}
>
Auf diesem Gerät aktivieren
</Button>
)}
{isPushSubscribed && (
<p className="text-sm text-emerald-700 dark:text-emerald-300">
Dieses Gerät empfängt Push-Benachrichtigungen.
</p>
)}
<div className="border-t border-gray-200 pt-5 dark:border-white/10">
<p className="text-sm font-medium text-gray-900 dark:text-white">
Welche Nachrichten als Push gesendet werden
</p>
<div className="mt-4 space-y-5">
<Switch
checked={settings.pushChatMessages}
onChange={(event) =>
updateSetting('pushChatMessages', event.target.checked)
}
disabled={!pushSupported || !settings.desktopEnabled}
label="Chatnachrichten"
description="Neue Direkt- und Teamnachrichten als Windows-Push."
/>
<Switch
checked={settings.pushChannelMessages}
onChange={(event) =>
updateSetting('pushChannelMessages', event.target.checked)
}
disabled={!pushSupported || !settings.desktopEnabled}
label="Channel-Nachrichten"
description="Neue Meldungen aus Channels als Windows-Push."
/>
</div>
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Push wird nur gesendet, wenn der jeweilige Nachrichtentyp
unter Ereignisse" ebenfalls aktiviert ist.
</p>
</div>
</NotificationCard>
</div>
</Section>

View File

@ -66,7 +66,7 @@ export default function PresenceSettings({
onUserUpdated?.(data.user)
}
showToast({
title: 'Datenschutz gespeichert',
title: 'Einstellungen gespeichert',
message: 'Deine Datenschutz- und Inaktivitätseinstellungen wurden aktualisiert.',
variant: 'success',
})
@ -142,7 +142,7 @@ export default function PresenceSettings({
</div>
<Button type="submit" isLoading={saving}>
Datenschutz speichern
Speichern
</Button>
</form>
</div>

View File

@ -0,0 +1,213 @@
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
function absoluteAPIBaseURL() {
return new URL(API_URL, window.location.origin).toString().replace(/\/$/, '')
}
type PushConfig = {
enabled: boolean
publicKey: string
}
function urlBase64ToUint8Array(value: string) {
const padding = '='.repeat((4 - (value.length % 4)) % 4)
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
const decoded = window.atob(base64)
return Uint8Array.from(decoded, (character) => character.charCodeAt(0))
}
function arrayBuffersEqual(
first: ArrayBuffer | null,
second: Uint8Array<ArrayBuffer>,
) {
if (!first || first.byteLength !== second.byteLength) {
return false
}
const firstBytes = new Uint8Array(first)
return firstBytes.every((value, index) => value === second[index])
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
async function loadPushConfig() {
const response = await fetch(`${API_URL}/push/config`, {
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Push-Konfiguration konnte nicht geladen werden.',
),
)
}
return (await response.json()) as PushConfig
}
async function saveSubscription(subscription: PushSubscription) {
const serialized = subscription.toJSON()
const response = await fetch(`${API_URL}/push/subscriptions`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: serialized.endpoint,
keys: serialized.keys,
apiBaseUrl: absoluteAPIBaseURL(),
}),
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Push-Abonnement konnte nicht gespeichert werden.',
),
)
}
}
export function isPushNotificationSupported() {
return (
window.isSecureContext &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
)
}
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
return null
}
return navigator.serviceWorker.register('/sw.js', { scope: '/' })
}
export async function syncExistingPushSubscription() {
if (!isPushNotificationSupported()) {
return false
}
const registration = await registerServiceWorker()
const subscription = await registration?.pushManager.getSubscription()
if (!subscription) {
return false
}
const config = await loadPushConfig()
if (!config.enabled || !config.publicKey) {
return false
}
const applicationServerKey = urlBase64ToUint8Array(config.publicKey)
if (
!arrayBuffersEqual(
subscription.options.applicationServerKey,
applicationServerKey,
)
) {
await subscription.unsubscribe()
return false
}
await saveSubscription(subscription)
return true
}
export async function enablePushNotifications() {
if (!isPushNotificationSupported()) {
throw new Error(
'Hintergrundbenachrichtigungen benötigen HTTPS oder localhost und einen unterstützten Browser.',
)
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
throw new Error('Windows-Benachrichtigungen wurden nicht freigegeben.')
}
const config = await loadPushConfig()
if (!config.enabled || !config.publicKey) {
throw new Error('Web Push ist auf dem Server noch nicht konfiguriert.')
}
const registration = await registerServiceWorker()
if (!registration) {
throw new Error('Der Service Worker konnte nicht registriert werden.')
}
const applicationServerKey = urlBase64ToUint8Array(config.publicKey)
let subscription = await registration.pushManager.getSubscription()
if (
subscription &&
!arrayBuffersEqual(
subscription.options.applicationServerKey,
applicationServerKey,
)
) {
await subscription.unsubscribe()
subscription = null
}
subscription ??= await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
})
try {
await saveSubscription(subscription)
} catch (error) {
await subscription.unsubscribe()
throw error
}
await registration
.showNotification('TEG-Benachrichtigungen aktiviert', {
body: 'Neue Chat- und Channel-Nachrichten werden auch im Hintergrund angezeigt.',
icon: '/favicon.svg',
badge: '/favicon.svg',
tag: 'teg-push-enabled',
})
.catch(() => undefined)
return true
}
export async function disablePushNotifications() {
if (!('serviceWorker' in navigator)) {
return false
}
const registration = await navigator.serviceWorker.getRegistration('/')
const subscription = await registration?.pushManager.getSubscription()
if (!subscription) {
return false
}
const response = await fetch(`${API_URL}/push/subscriptions`, {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Push-Abonnement konnte nicht entfernt werden.',
),
)
}
await subscription.unsubscribe()
return false
}

View File

@ -0,0 +1,79 @@
// Lässt den Browser-Tab-Titel blinken, solange neue Nachrichten ungelesen sind
// und der Tab im Hintergrund liegt. Sobald der Tab wieder sichtbar wird oder
// den Fokus erhält, wird der ursprüngliche Titel wiederhergestellt.
const BLINK_INTERVAL_MS = 1000
let blinkTimer: number | undefined
let baseTitle = typeof document !== 'undefined' ? document.title : ''
let unreadCount = 0
let active = false
let showingAlert = false
function alertTitle() {
const label = unreadCount === 1 ? 'Neue Nachricht' : 'Neue Nachrichten'
return `(${unreadCount}) ${label}`
}
function render(showAlert: boolean) {
showingAlert = showAlert
document.title = showAlert ? alertTitle() : baseTitle
}
/**
* Meldet eine neue Nachricht. Startet (bzw. verlängert) das Blinken nur, wenn
* der Tab gerade nicht sichtbar ist ist der Nutzer im Tab, gibt es nichts zu
* signalisieren.
*/
export function notifyNewMessage() {
if (typeof document === 'undefined') {
return
}
if (document.visibilityState === 'visible') {
return
}
if (!active) {
baseTitle = document.title
unreadCount = 0
active = true
}
unreadCount += 1
if (blinkTimer === undefined) {
render(true)
blinkTimer = window.setInterval(() => {
render(!showingAlert)
}, BLINK_INTERVAL_MS)
} else {
render(true)
}
}
/** Stoppt das Blinken und stellt den ursprünglichen Titel wieder her. */
export function clearMessageNotification() {
if (!active) {
return
}
active = false
unreadCount = 0
showingAlert = false
if (blinkTimer !== undefined) {
window.clearInterval(blinkTimer)
blinkTimer = undefined
}
document.title = baseTitle
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
clearMessageNotification()
}
})
window.addEventListener('focus', clearMessageNotification)
}

View File

@ -1,11 +1,31 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
const certDir = fileURLToPath(new URL('./certs/', import.meta.url))
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
server: {
host: true,
strictPort: true,
https: {
cert: readFileSync(`${certDir}localhost.pem`),
key: readFileSync(`${certDir}localhost-key.pem`),
},
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})