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:$?\")"
]
}
}

3
.gitignore vendored
View File

@ -74,4 +74,5 @@ Thumbs.db
$RECYCLE.BIN/ $RECYCLE.BIN/
# Windows shortcuts # Windows shortcuts
*.lnk *.lnk
.gocache

View File

@ -1,7 +1,7 @@
PORT=8080 PORT=8080
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
JWT_SECRET=tegvideo7010! JWT_SECRET=tegvideo7010!
FRONTEND_ORIGIN=http://localhost:5173 FRONTEND_ORIGIN=https://localhost:5173
ADDRESS_SEARCH_PROVIDER=photon ADDRESS_SEARCH_PROVIDER=photon
ADDRESS_SEARCH_URL=https://photon.komoot.io/api ADDRESS_SEARCH_URL=https://photon.komoot.io/api
ADDRESS_SEARCH_LANGUAGE=de ADDRESS_SEARCH_LANGUAGE=de
@ -12,9 +12,13 @@ ADDRESS_SEARCH_USER_AGENT=TEG-App/1.0
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_DISPLAY_NAME=Administrator ADMIN_DISPLAY_NAME=Administrator
ADMIN_EMAIL=admin@tegdssd.de ADMIN_EMAIL=admin@tegdssd.de
ADMIN_UNIT=Administration ADMIN_UNIT=TEG
ADMIN_GROUP=admin ADMIN_GROUP=admin
ADMIN_RIGHTS=admin,users:read,users:write ADMIN_RIGHTS=admin,users:read,users:write
ADMIN_TEAM_NAME=Administration ADMIN_TEAM_NAME=Administration
ADMIN_TEAM_INITIAL=A ADMIN_TEAM_INITIAL=A
ADMIN_TEAM_HREF=#administration 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 { type AdminTeam struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Initial string `json:"initial"` Initial string `json:"initial"`
Href string `json:"href"` Href string `json:"href"`
CreatedAt time.Time `json:"createdAt"` ConversationID string `json:"conversationId"`
UpdatedAt time.Time `json:"updatedAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
type AdminUser struct { 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 return nil
} }
func (s *Server) handleAdminListTeams(w http.ResponseWriter, r *http.Request) { 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( rows, err := s.db.Query(
r.Context(), r.Context(),
` `
SELECT id::TEXT, name, initial, href, created_at, updated_at SELECT
FROM teams t.id::TEXT,
ORDER BY name ASC 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.Name,
&team.Initial, &team.Initial,
&team.Href, &team.Href,
&team.ConversationID,
&team.CreatedAt, &team.CreatedAt,
&team.UpdatedAt, &team.UpdatedAt,
); err != nil { ); err != nil {
@ -531,24 +606,53 @@ func (s *Server) handleAdminCreateTeam(w http.ResponseWriter, r *http.Request) {
return return
} }
_, err := s.db.Exec( tx, err := s.db.Begin(r.Context())
r.Context(),
`
INSERT INTO teams (name, initial, href)
VALUES ($1, $2, $3)
`,
input.Name,
input.Initial,
input.Href,
)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden") writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden")
return 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{ 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 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(), r.Context(),
` `
UPDATE teams UPDATE teams
@ -589,13 +700,45 @@ func (s *Server) handleAdminUpdateTeam(w http.ResponseWriter, r *http.Request) {
input.Initial, input.Initial,
input.Href, input.Href,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden") writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden")
return 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{ 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( err = tx.QueryRow(
ctx, ctx,
` `
INSERT INTO messages (conversation_id, body) INSERT INTO messages (conversation_id, body, expires_at)
SELECT id, $2 SELECT
id,
$2,
CASE
WHEN disappearing_seconds > 0
THEN now() + make_interval(secs => disappearing_seconds)
ELSE NULL
END
FROM conversations FROM conversations
WHERE id = $1 WHERE id = $1
AND type = 'channel' AND type = 'channel'

View File

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

View File

@ -25,14 +25,15 @@ type chatSocketCommand struct {
} }
type chatSocketEvent struct { type chatSocketEvent struct {
Type string `json:"type"` Type string `json:"type"`
ClientID string `json:"clientId,omitempty"` ClientID string `json:"clientId,omitempty"`
ConversationID string `json:"conversationId,omitempty"` ConversationID string `json:"conversationId,omitempty"`
UserID string `json:"userId,omitempty"` UserID string `json:"userId,omitempty"`
UserDisplayName string `json:"userDisplayName,omitempty"` UserDisplayName string `json:"userDisplayName,omitempty"`
Typing bool `json:"typing,omitempty"` Typing bool `json:"typing,omitempty"`
Message *ChatMessage `json:"message,omitempty"` Message *ChatMessage `json:"message,omitempty"`
Error string `json:"error,omitempty"` Conversation *ChatConversation `json:"conversation,omitempty"`
Error string `json:"error,omitempty"`
} }
type chatSocketClient struct { type chatSocketClient struct {
@ -284,17 +285,7 @@ func (s *Server) publishChatMessage(
` `
SELECT cm.user_id::TEXT, cm.muted SELECT cm.user_id::TEXT, cm.muted
FROM conversation_members cm FROM conversation_members cm
JOIN conversations c ON c.id = cm.conversation_id
WHERE cm.conversation_id = $1 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, message.ConversationID,
) )
@ -325,7 +316,13 @@ func (s *Server) publishChatMessage(
rows.Close() 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 { if err != nil {
return return
} }
@ -340,12 +337,13 @@ func (s *Server) publishChatMessage(
} }
data, err := json.Marshal(map[string]any{ data, err := json.Marshal(map[string]any{
"conversationId": message.ConversationID, "conversationId": message.ConversationID,
"messageId": message.ID, "messageId": message.ID,
"senderId": message.Sender.ID, "senderId": message.Sender.ID,
"senderAvatar": message.Sender.Avatar, "senderAvatar": message.Sender.Avatar,
"senderName": chatUserDisplayName(message.Sender), "senderName": chatUserDisplayName(message.Sender),
"conversationType": conversationType, "conversationType": conversationType,
"conversationImage": conversationImage,
}) })
if err != nil { if err != nil {
return return
@ -379,17 +377,7 @@ func (s *Server) chatConversationMemberIDs(ctx context.Context, conversationID s
` `
SELECT cm.user_id::TEXT SELECT cm.user_id::TEXT
FROM conversation_members cm FROM conversation_members cm
JOIN conversations c ON c.id = cm.conversation_id
WHERE cm.conversation_id = $1 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, 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 { func chatUserDisplayName(user ChatUser) string {
if user.DisplayName != "" { if user.DisplayName != "" {
return user.DisplayName return user.DisplayName
@ -439,31 +486,32 @@ func chatUserDisplayName(user ChatUser) string {
func (s *Server) chatNotificationTitle( func (s *Server) chatNotificationTitle(
ctx context.Context, ctx context.Context,
message ChatMessage, message ChatMessage,
) (string, string, error) { ) (string, string, string, error) {
var conversationType string var conversationType string
var conversationName string var conversationName string
var conversationImage string
err := s.db.QueryRow( err := s.db.QueryRow(
ctx, ctx,
` `
SELECT type, name SELECT type, name, channel_image
FROM conversations FROM conversations
WHERE id = $1 WHERE id = $1
`, `,
message.ConversationID, message.ConversationID,
).Scan(&conversationType, &conversationName) ).Scan(&conversationType, &conversationName, &conversationImage)
if err != nil { if err != nil {
return "", "", err return "", "", "", err
} }
senderName := chatUserDisplayName(message.Sender) senderName := chatUserDisplayName(message.Sender)
if conversationType == "team" && conversationName != "" { if conversationType == "group" && conversationName != "" {
return senderName + " in " + conversationName, conversationType, nil return senderName + " in " + conversationName, conversationType, conversationImage, nil
} }
if conversationType == "channel" && conversationName != "" { 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 go 1.26.2
require ( require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coder/websocket v1.8.14 github.com/coder/websocket v1.8.14
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.9.2 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 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= 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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 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/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -38,6 +38,10 @@ func main() {
server := NewServer(db, jwtSecret, frontendOrigin) server := NewServer(db, jwtSecret, frontendOrigin)
server.startPresenceMonitor(ctx) 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) log.Printf("Backend läuft auf http://localhost:%s", port)

View File

@ -12,34 +12,48 @@ import (
) )
type notificationPreferencesResponse struct { type notificationPreferencesResponse struct {
InAppEnabled bool `json:"inAppEnabled"` SoundEnabled bool `json:"soundEnabled"`
SoundEnabled bool `json:"soundEnabled"` SoundName string `json:"soundName"`
SoundName string `json:"soundName"` EmailEnabled bool `json:"emailEnabled"`
EmailEnabled bool `json:"emailEnabled"` DeviceUpdates bool `json:"deviceUpdates"`
OperationUpdates bool `json:"operationUpdates"` DeviceJournals bool `json:"deviceJournals"`
OperationJournals bool `json:"operationJournals"` ChatMessages bool `json:"chatMessages"`
DeviceUpdates bool `json:"deviceUpdates"` ChannelMessages bool `json:"channelMessages"`
DeviceJournals bool `json:"deviceJournals"` DesktopEnabled bool `json:"desktopEnabled"`
SystemMessages bool `json:"systemMessages"` PushChatMessages bool `json:"pushChatMessages"`
ChatMessages bool `json:"chatMessages"` PushChannelMessages bool `json:"pushChannelMessages"`
ChannelMessages bool `json:"channelMessages"`
DesktopEnabled bool `json:"desktopEnabled"`
} }
func defaultNotificationPreferences() notificationPreferencesResponse { func defaultNotificationPreferences() notificationPreferencesResponse {
return notificationPreferencesResponse{ return notificationPreferencesResponse{
InAppEnabled: true, SoundEnabled: false,
SoundEnabled: false, SoundName: "default",
SoundName: "default", EmailEnabled: false,
EmailEnabled: false, DeviceUpdates: true,
OperationUpdates: true, DeviceJournals: true,
OperationJournals: true, ChatMessages: true,
DeviceUpdates: true, ChannelMessages: true,
DeviceJournals: true, DesktopEnabled: false,
SystemMessages: true, PushChatMessages: true,
ChatMessages: true, PushChannelMessages: true,
ChannelMessages: true, }
DesktopEnabled: false, }
// 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 ( INSERT INTO notification_preferences (
user_id, user_id,
in_app_enabled,
sound_enabled, sound_enabled,
sound_name, sound_name,
email_enabled, email_enabled,
operation_updates,
operation_journals,
device_updates, device_updates,
device_journals, device_journals,
system_messages,
chat_messages, chat_messages,
channel_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) ON CONFLICT (user_id)
DO UPDATE SET DO UPDATE SET
in_app_enabled = EXCLUDED.in_app_enabled,
sound_enabled = EXCLUDED.sound_enabled, sound_enabled = EXCLUDED.sound_enabled,
sound_name = EXCLUDED.sound_name, sound_name = EXCLUDED.sound_name,
email_enabled = EXCLUDED.email_enabled, email_enabled = EXCLUDED.email_enabled,
operation_updates = EXCLUDED.operation_updates,
operation_journals = EXCLUDED.operation_journals,
device_updates = EXCLUDED.device_updates, device_updates = EXCLUDED.device_updates,
device_journals = EXCLUDED.device_journals, device_journals = EXCLUDED.device_journals,
system_messages = EXCLUDED.system_messages,
chat_messages = EXCLUDED.chat_messages, chat_messages = EXCLUDED.chat_messages,
channel_messages = EXCLUDED.channel_messages, channel_messages = EXCLUDED.channel_messages,
desktop_enabled = EXCLUDED.desktop_enabled, desktop_enabled = EXCLUDED.desktop_enabled,
push_chat_messages = EXCLUDED.push_chat_messages,
push_channel_messages = EXCLUDED.push_channel_messages,
updated_at = now() updated_at = now()
`, `,
user.ID, user.ID,
input.InAppEnabled,
input.SoundEnabled, input.SoundEnabled,
input.SoundName, input.SoundName,
input.EmailEnabled, input.EmailEnabled,
input.OperationUpdates,
input.OperationJournals,
input.DeviceUpdates, input.DeviceUpdates,
input.DeviceJournals, input.DeviceJournals,
input.SystemMessages,
input.ChatMessages, input.ChatMessages,
input.ChannelMessages, input.ChannelMessages,
input.DesktopEnabled, input.DesktopEnabled,
input.PushChatMessages,
input.PushChannelMessages,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden") writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden")
@ -167,36 +175,32 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string)
ctx, ctx,
` `
SELECT SELECT
in_app_enabled,
sound_enabled, sound_enabled,
COALESCE(sound_name, 'default'), COALESCE(sound_name, 'default'),
email_enabled, email_enabled,
operation_updates,
operation_journals,
device_updates, device_updates,
device_journals, device_journals,
system_messages,
chat_messages, chat_messages,
channel_messages, channel_messages,
desktop_enabled desktop_enabled,
push_chat_messages,
push_channel_messages
FROM notification_preferences FROM notification_preferences
WHERE user_id = $1 WHERE user_id = $1
LIMIT 1 LIMIT 1
`, `,
userID, userID,
).Scan( ).Scan(
&settings.InAppEnabled,
&settings.SoundEnabled, &settings.SoundEnabled,
&settings.SoundName, &settings.SoundName,
&settings.EmailEnabled, &settings.EmailEnabled,
&settings.OperationUpdates,
&settings.OperationJournals,
&settings.DeviceUpdates, &settings.DeviceUpdates,
&settings.DeviceJournals, &settings.DeviceJournals,
&settings.SystemMessages,
&settings.ChatMessages, &settings.ChatMessages,
&settings.ChannelMessages, &settings.ChannelMessages,
&settings.DesktopEnabled, &settings.DesktopEnabled,
&settings.PushChatMessages,
&settings.PushChannelMessages,
) )
settings.SoundName = normalizeNotificationSoundName(settings.SoundName) settings.SoundName = normalizeNotificationSoundName(settings.SoundName)

View File

@ -104,24 +104,14 @@ func shouldDeliverNotification(
case strings.HasPrefix(notificationType, "chat.") || entityType == "chat": case strings.HasPrefix(notificationType, "chat.") || entityType == "chat":
return preferences.ChatMessages 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": case notificationType == "device.journal":
return preferences.DeviceJournals return preferences.DeviceJournals
case strings.HasPrefix(notificationType, "device.") || entityType == "device": case strings.HasPrefix(notificationType, "device.") || entityType == "device":
return preferences.DeviceUpdates return preferences.DeviceUpdates
case strings.HasPrefix(notificationType, "system.") || entityType == "system": // Systemmeldungen werden immer ausgeliefert; Einsatz-Benachrichtigungen
return preferences.SystemMessages // werden ausschließlich als stille Events gesendet (siehe createAndPublish).
default: default:
return true return true
} }
@ -157,7 +147,7 @@ func (s *Server) createAndPublishNotification(
return nil return nil
} }
inAppVisible := silent || (preferences.InAppEnabled && entityType != "chat") inAppVisible := silent || entityType != "chat"
var notification Notification var notification Notification
var rawData []byte var rawData []byte
@ -229,7 +219,7 @@ func (s *Server) createAndPublishNotification(
if !notification.Silent { if !notification.Silent {
notification.SoundName = notificationSoundName(preferences) notification.SoundName = notificationSoundName(preferences)
notification.Desktop = preferences.DesktopEnabled notification.Desktop = preferences.DesktopEnabled
notification.InApp = preferences.InAppEnabled notification.InApp = true
if notification.EntityType == "chat" { if notification.EntityType == "chat" {
if notification.Type == "channel.message" { if notification.Type == "channel.message" {
notification.InApp = preferences.ChannelMessages notification.InApp = preferences.ChannelMessages
@ -240,6 +230,11 @@ func (s *Server) createAndPublishNotification(
} }
notificationsBroker.publish(notification) notificationsBroker.publish(notification)
if !notification.Silent &&
preferences.DesktopEnabled &&
shouldPushNotification(preferences, notification.Type) {
s.publishWebPushAsync(notification)
}
return nil return nil
} }
@ -478,6 +473,8 @@ func (s *Server) createOperationNotification(
return err return err
} }
// Einsatz-Benachrichtigungen werden ausschließlich als stille Events
// gesendet (kein sichtbarer Toast / keine Notification-Center-Einträge).
if err := s.createAndPublishNotification( if err := s.createAndPublishNotification(
ctx, ctx,
recipientID, recipientID,
@ -487,7 +484,7 @@ func (s *Server) createOperationNotification(
"operation", "operation",
operation.ID, operation.ID,
dataJSON, dataJSON,
false, true,
); err != nil { ); err != nil {
return err 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("GET /health", s.handleHealth)
mux.HandleFunc("POST /webhooks/channels/{slug}", s.handleChannelWebhook) 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/register", s.handleRegister)
mux.HandleFunc("POST /auth/login", s.handleLogin) 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("GET /settings/notifications/preferences", s.requireAuth(s.handleGetNotificationPreferences))
mux.HandleFunc("PUT /settings/notifications/preferences", s.requireAuth(s.handleUpdateNotificationPreferences)) 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)) 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("GET /settings/presence", s.requireAuth(s.handleGetPresenceSettings))
mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings)) mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings))
mux.HandleFunc("PUT /settings/presence/status", s.requireAuth(s.handleUpdatePresenceStatus)) 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/conversations", s.requireAuth(s.handleListChatConversations))
mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages)) 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}/messages", s.requireAuth(s.handleCreateChatMessage))
mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead)) 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}/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))) 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("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("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("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 */ /* milestone settings */

View File

@ -7,6 +7,7 @@ import (
"crypto/subtle" "crypto/subtle"
"errors" "errors"
"net/http" "net/http"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -25,11 +26,14 @@ const (
) )
type Server struct { type Server struct {
db *pgxpool.Pool db *pgxpool.Pool
jwtSecret []byte jwtSecret []byte
frontendOrigin string frontendOrigin string
presenceMu sync.Mutex vapidPublicKey string
presenceStatus map[string]string vapidPrivateKey string
vapidSubscriber string
presenceMu sync.Mutex
presenceStatus map[string]string
} }
type User struct { type User struct {
@ -92,11 +96,21 @@ type Claims struct {
} }
func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Server { 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{ return &Server{
db: db, db: db,
jwtSecret: []byte(jwtSecret), jwtSecret: []byte(jwtSecret),
frontendOrigin: frontendOrigin, frontendOrigin: frontendOrigin,
presenceStatus: map[string]string{}, 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 { 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") writeError(w, http.StatusUnauthorized, "Invalid username/email or password")
return return
} }
@ -273,10 +298,23 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
user.LastSeenAt = &now user.LastSeenAt = &now
if err := s.setSessionCookie(w, user, sessionID); err != nil { 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") writeError(w, http.StatusInternalServerError, "Could not create session")
return 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{ writeJSON(w, http.StatusOK, map[string]any{
"user": user, "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) { func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if sessionID, ok := s.currentSessionIDFromCookie(r); ok { if sessionID, ok := s.currentSessionIDFromCookie(r); ok {
_, _ = s.db.Exec(
r.Context(),
`DELETE FROM push_subscriptions WHERE session_id = $1`,
sessionID,
)
_, _ = s.db.Exec( _, _ = s.db.Exec(
r.Context(), 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 ( CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, chat_messages BOOLEAN NOT NULL DEFAULT true,
channel_messages BOOLEAN NOT NULL DEFAULT true, channel_messages BOOLEAN NOT NULL DEFAULT true,
desktop_enabled BOOLEAN NOT NULL DEFAULT false, 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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 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 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 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 ( 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(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL DEFAULT 'direct' type TEXT NOT NULL DEFAULT 'direct'
CHECK (type IN ('team', 'direct', 'group', 'channel')), CHECK (type IN ('direct', 'group', 'channel')),
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
team_id UUID REFERENCES teams(id) ON DELETE CASCADE, 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, last_message_at TIMESTAMPTZ,
disappearing_seconds INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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 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 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_source_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_image 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, reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
forwarded_from_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(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
edited_at TIMESTAMPTZ, edited_at TIMESTAMPTZ,
deleted_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 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 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. // 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`, `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_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_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_user_id ON feedback(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON feedback(created_at)`, `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_type ON conversations(type)`,
`CREATE INDEX IF NOT EXISTS idx_conversations_last_message_at ON conversations(last_message_at DESC)`, `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 ( INSERT INTO conversations (
type, type,
name, 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_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_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_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_message_id ON message_attachments(message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_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) 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) { func (s *Server) pruneUserLogs(ctx context.Context, user *User) {
if user == nil || user.ID == "" { if user == nil || user.ID == "" {
return return

View File

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

View File

@ -245,6 +245,17 @@ func (s *Server) handleLogoutOtherSessions(w http.ResponseWriter, r *http.Reques
return 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{ writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Andere Sitzungen wurden abgemeldet", "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 dist-ssr
*.local *.local
# Local dev TLS certificates (mkcert)
certs
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

View File

@ -3,8 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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" /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -27,6 +27,7 @@
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
@ -2250,6 +2251,19 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "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": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", "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/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1", "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 DashboardPage from './pages/DashboardPage'
import DevicesPage from './pages/devices/DevicesPage' import DevicesPage from './pages/devices/DevicesPage'
import OperationsPage from './pages/operations/OperationsPage' import OperationsPage from './pages/operations/OperationsPage'
import CalendarPage from './pages/CalendarPage' import CalendarPage from './pages/calendar/CalendarPage'
import DocumentsPage from './pages/DocumentsPage' import DocumentsPage from './pages/DocumentsPage'
import ReportsPage from './pages/ReportsPage' import ReportsPage from './pages/ReportsPage'
import SettingsPage from './pages/settings/SettingsPage' import SettingsPage from './pages/settings/SettingsPage'
@ -23,6 +23,8 @@ import MilestoneDevicesPage from './pages/operations/milestone-devices/Milestone
import { hasRight } from './components/permissions' import { hasRight } from './components/permissions'
import ChatPage from './pages/chat/ChatPage' import ChatPage from './pages/chat/ChatPage'
import { recordNavigation } from './utils/activityLog' 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' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -78,7 +80,11 @@ function resolveNotificationImage(value: unknown) {
} }
try { 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 { } catch {
return new URL('/favicon.svg', window.location.origin).toString() return new URL('/favicon.svg', window.location.origin).toString()
} }
@ -157,6 +163,12 @@ function App() {
recordNavigation(location.pathname) recordNavigation(location.pathname)
}, [location.pathname]) }, [location.pathname])
useEffect(() => {
if (currentUserId) {
void syncExistingPushSubscription().catch(() => undefined)
}
}, [currentUserId])
useEffect(() => { useEffect(() => {
if (!currentUserId) { if (!currentUserId) {
setNotifications([]) setNotifications([])
@ -180,9 +192,6 @@ function App() {
if (notification.entityType === 'chat') { if (notification.entityType === 'chat') {
const data = getNotificationData(notification) const data = getNotificationData(notification)
const senderAvatar = resolveNotificationImage(data.senderAvatar) const senderAvatar = resolveNotificationImage(data.senderAvatar)
const desktopIcon =
senderAvatar ||
new URL('/favicon.svg', window.location.origin).toString()
const senderName = const senderName =
typeof data.senderName === 'string' ? data.senderName : notification.title typeof data.senderName === 'string' ? data.senderName : notification.title
const isChannel = data.conversationType === 'channel' const isChannel = data.conversationType === 'channel'
@ -190,30 +199,6 @@ function App() {
? `/chat/channel/${notification.entityId}` ? `/chat/channel/${notification.entityId}`
: `/chat/${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 ( if (
notification.inAppEnabled !== false && notification.inAppEnabled !== false &&
document.visibilityState === 'visible' document.visibilityState === 'visible'
@ -252,6 +237,10 @@ function App() {
}) })
} }
if (document.visibilityState !== 'visible') {
notifyNewMessage()
}
playNotificationSound(notification.soundName) playNotificationSound(notification.soundName)
return return
} }

View File

@ -28,6 +28,7 @@ type ModalProps = {
children?: ReactNode children?: ReactNode
panelClassName?: string panelClassName?: string
backdropClassName?: string
zIndexClassName?: string zIndexClassName?: string
} }
@ -35,6 +36,25 @@ function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ') 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({ export default function Modal({
open, open,
setOpen, setOpen,
@ -46,6 +66,7 @@ export default function Modal({
onConfirm, onConfirm,
children, children,
panelClassName, panelClassName,
backdropClassName,
zIndexClassName = 'z-[9999]', zIndexClassName = 'z-[9999]',
}: ModalProps) { }: ModalProps) {
const isSuccess = variant === 'success-single' || variant === 'success-wide' const isSuccess = variant === 'success-single' || variant === 'success-wide'
@ -82,7 +103,10 @@ export default function Modal({
> >
<DialogBackdrop <DialogBackdrop
transition 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"> <div className="fixed inset-0 z-10 w-screen overflow-hidden">
@ -303,4 +327,4 @@ export default function Modal({
</div> </div>
</Dialog> </Dialog>
) )
} }

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 App from './App.tsx'
import { NotificationProvider } from './components/Notifications.tsx' import { NotificationProvider } from './components/Notifications.tsx'
import { initActivityLog } from './utils/activityLog.ts' import { initActivityLog } from './utils/activityLog.ts'
import { registerServiceWorker } from './utils/pushNotifications.ts'
initActivityLog() initActivityLog()
void registerServiceWorker().catch(() => {
// Die App bleibt ohne Service Worker nutzbar, nur Hintergrund-Push entfällt.
})
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <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 Checkbox from '../components/Checkbox'
import type { User } from '../components/types' import type { User } from '../components/types'
import { getCurrentSessionInfo } from '../utils/sessionInfo' import { getCurrentSessionInfo } from '../utils/sessionInfo'
import { clearActivityLog } from '../utils/activityLog'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' 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 data = await response.json().catch(() => null)
const user = data?.user as User | undefined 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) { if (user) {
onLogin?.(user) onLogin?.(user)
} }

View File

@ -2,18 +2,18 @@
import { useLocation, Navigate } from 'react-router' import { useLocation, Navigate } from 'react-router'
import { import {
CalendarDaysIcon,
ChatBubbleLeftRightIcon, ChatBubbleLeftRightIcon,
HashtagIcon,
UserGroupIcon, UserGroupIcon,
UsersIcon, UsersIcon,
VideoCameraIcon, VideoCameraIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import Tabs from '../../components/Tabs' import Tabs from '../../components/Tabs'
import UsersAdministration from './users/UsersAdministration' import UsersAdministration from './users/UsersAdministration'
import TeamsAdministration from './teams/TeamsAdministration'
import FeedbackAdministration from './feedback/FeedbackAdministration' import FeedbackAdministration from './feedback/FeedbackAdministration'
import Milestone from './milestone/Milestone' import Milestone from './milestone/Milestone'
import ChannelsAdministration from './channels/ChannelsAdministration' import ChatsAdministration from './chats/ChatsAdministration'
import CalendarAdministration from './calendar/CalendarAdministration'
export default function AdministrationPage() { export default function AdministrationPage() {
const location = useLocation() const location = useLocation()
@ -30,16 +30,10 @@ export default function AdministrationPage() {
current: section === 'benutzer', current: section === 'benutzer',
}, },
{ {
name: 'Teams', name: 'Teams & Chats',
href: '/administration/teams', href: '/administration/chats/teams',
icon: UserGroupIcon, icon: UserGroupIcon,
current: section === 'teams', current: section === 'chats',
},
{
name: 'Channels',
href: '/administration/channels',
icon: HashtagIcon,
current: section === 'channels',
}, },
{ {
name: 'Milestone', name: 'Milestone',
@ -47,6 +41,12 @@ export default function AdministrationPage() {
icon: VideoCameraIcon, icon: VideoCameraIcon,
current: section === 'milestone', current: section === 'milestone',
}, },
{
name: 'Kalender',
href: '/administration/kalender',
icon: CalendarDaysIcon,
current: section === 'kalender',
},
{ {
name: 'Feedback', name: 'Feedback',
href: '/administration/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 /> return <Navigate to="/administration/benutzer" replace />
} }
@ -80,14 +92,14 @@ export default function AdministrationPage() {
</h1> </h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400"> <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> </p>
</div> </div>
{section === 'benutzer' && <UsersAdministration />} {section === 'benutzer' && <UsersAdministration />}
{section === 'teams' && <TeamsAdministration />} {section === 'chats' && <ChatsAdministration />}
{section === 'channels' && <ChannelsAdministration />}
{section === 'milestone' && <Milestone />} {section === 'milestone' && <Milestone />}
{section === 'kalender' && <CalendarAdministration />}
{section === 'feedback' && <FeedbackAdministration />} {section === 'feedback' && <FeedbackAdministration />}
</div> </div>
</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 name: string
initial: string initial: string
href: string href: string
conversationId: string
} }
const inputClassName = const inputClassName =
@ -263,7 +264,9 @@ export default function TeamsAdministration() {
</span> </span>
<span className="block truncate text-xs text-gray-500 dark:text-gray-400"> <span className="block truncate text-xs text-gray-500 dark:text-gray-400">
{team.href || '#'} {team.conversationId
? 'Gruppenchat aktiv'
: 'Gruppenchat wird vorbereitet'}
</span> </span>
</span> </span>
</button> </button>
@ -294,6 +297,11 @@ export default function TeamsAdministration() {
</div> </div>
<div className="mt-4 space-y-4"> <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> <div>
<label <label
htmlFor="team-name" htmlFor="team-name"
@ -380,4 +388,4 @@ export default function TeamsAdministration() {
</div> </div>
</> </>
) )
} }

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' } from 'react'
import { import {
BellIcon, BellIcon,
EnvelopeIcon, ComputerDesktopIcon,
PlayIcon, PlayIcon,
SpeakerWaveIcon, SpeakerWaveIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
@ -17,39 +17,41 @@ import LoadingSpinner from '../../../components/LoadingSpinner'
import Switch from '../../../components/Switch' import Switch from '../../../components/Switch'
import Button from '../../../components/Button' import Button from '../../../components/Button'
import { useNotifications } from '../../../components/Notifications' 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' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type NotificationSoundName = 'default' | 'chime' | 'soft' | 'alert' | 'success' type NotificationSoundName = 'default' | 'chime' | 'soft' | 'alert' | 'success'
type NotificationSettings = { type NotificationSettings = {
inAppEnabled: boolean
soundEnabled: boolean soundEnabled: boolean
soundName: NotificationSoundName soundName: NotificationSoundName
emailEnabled: boolean emailEnabled: boolean
operationUpdates: boolean
operationJournals: boolean
deviceUpdates: boolean deviceUpdates: boolean
deviceJournals: boolean deviceJournals: boolean
systemMessages: boolean
chatMessages: boolean chatMessages: boolean
channelMessages: boolean channelMessages: boolean
desktopEnabled: boolean desktopEnabled: boolean
pushChatMessages: boolean
pushChannelMessages: boolean
} }
const defaultSettings: NotificationSettings = { const defaultSettings: NotificationSettings = {
inAppEnabled: true,
soundEnabled: false, soundEnabled: false,
soundName: 'default', soundName: 'default',
emailEnabled: false, emailEnabled: false,
operationUpdates: true,
operationJournals: true,
deviceUpdates: true, deviceUpdates: true,
deviceJournals: true, deviceJournals: true,
systemMessages: true,
chatMessages: true, chatMessages: true,
channelMessages: true, channelMessages: true,
desktopEnabled: false, desktopEnabled: false,
pushChatMessages: true,
pushChannelMessages: true,
} }
const notificationSounds: Array<{ const notificationSounds: Array<{
@ -151,12 +153,17 @@ export default function Benachrichtigungen() {
useState<NotificationSettings>(defaultSettings) useState<NotificationSettings>(defaultSettings)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [isPushSubscribed, setIsPushSubscribed] = useState(false)
const pushSupported = isPushNotificationSupported()
const settingsRef = useRef<NotificationSettings>(defaultSettings) const settingsRef = useRef<NotificationSettings>(defaultSettings)
const saveRequestIdRef = useRef(0) const saveRequestIdRef = useRef(0)
useEffect(() => { useEffect(() => {
void loadSettings() void loadSettings()
void syncExistingPushSubscription()
.then(setIsPushSubscribed)
.catch(() => setIsPushSubscribed(false))
}, []) }, [])
async function loadSettings() { async function loadSettings() {
@ -274,29 +281,31 @@ export default function Benachrichtigungen() {
} }
async function enableDesktopNotifications() { async function enableDesktopNotifications() {
if (!('Notification' in window)) { try {
showToast({ const subscribed = await enablePushNotifications()
title: 'Nicht unterstützt', setIsPushSubscribed(subscribed)
message: 'Dieser Browser unterstützt keine Windows-Benachrichtigungen.', updateSetting('desktopEnabled', true)
variant: 'warning', } catch (error) {
showErrorToast({
title: 'Windows-Benachrichtigungen konnten nicht aktiviert werden',
error,
fallback: 'Windows-Benachrichtigungen konnten nicht aktiviert werden.',
}) })
return
} }
}
const permission = await Notification.requestPermission() async function disableDesktopNotifications() {
if (permission !== 'granted') { try {
showToast({ await disablePushNotifications()
title: 'Berechtigung fehlt', setIsPushSubscribed(false)
message: 'Windows-Benachrichtigungen wurden nicht freigegeben.', updateSetting('desktopEnabled', false)
variant: 'warning', } 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) { if (isLoading) {
@ -339,21 +348,11 @@ export default function Benachrichtigungen() {
title="In-App-Benachrichtigungen" title="In-App-Benachrichtigungen"
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden." 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 <Switch
checked={settings.soundEnabled} checked={settings.soundEnabled}
onChange={(event) => onChange={(event) =>
updateSetting('soundEnabled', event.target.checked) updateSetting('soundEnabled', event.target.checked)
} }
disabled={!settings.inAppEnabled}
label="Signalton abspielen" label="Signalton abspielen"
description="Spielt einen kurzen Ton bei neuen Benachrichtigungen ab." description="Spielt einen kurzen Ton bei neuen Benachrichtigungen ab."
/> />
@ -370,7 +369,7 @@ export default function Benachrichtigungen() {
<select <select
id="notification-sound" id="notification-sound"
value={settings.soundName} value={settings.soundName}
disabled={!settings.inAppEnabled || !settings.soundEnabled} disabled={!settings.soundEnabled}
onChange={(event) => onChange={(event) =>
updateSetting( updateSetting(
'soundName', 'soundName',
@ -391,7 +390,7 @@ export default function Benachrichtigungen() {
variant="secondary" variant="secondary"
color="gray" color="gray"
size="sm" size="sm"
disabled={!settings.inAppEnabled || !settings.soundEnabled} disabled={!settings.soundEnabled}
leadingIcon={<PlayIcon aria-hidden="true" className="size-4" />} leadingIcon={<PlayIcon aria-hidden="true" className="size-4" />}
onClick={() => playNotificationSound(settings.soundName)} onClick={() => playNotificationSound(settings.soundName)}
> >
@ -405,38 +404,6 @@ export default function Benachrichtigungen() {
</div> </div>
</NotificationCard> </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 <NotificationCard
icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />} icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />}
title="Ereignisse" title="Ereignisse"
@ -459,36 +426,85 @@ export default function Benachrichtigungen() {
label="Channel-Nachrichten" label="Channel-Nachrichten"
description="Zeigt eine Browser-Benachrichtigung bei neuen Meldungen aus Channels." 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 <Switch
checked={settings.operationUpdates} checked={settings.desktopEnabled}
onChange={(event) => onChange={(event) => {
updateSetting('operationUpdates', event.target.checked) if (event.target.checked) {
} void enableDesktopNotifications()
disabled={!settings.inAppEnabled && !settings.emailEnabled} return
label="Einsatzänderungen" }
description="Benachrichtigt dich, wenn Einsätze erstellt oder geändert werden." void disableDesktopNotifications()
}}
label="Auf diesem Gerät empfangen"
description="Registriert dieses Gerät für Hintergrund-Push, auch ohne geöffneten Tab."
/> />
<Switch {!pushSupported && (
checked={settings.operationJournals} <p className="text-sm text-amber-700 dark:text-amber-300">
onChange={(event) => Push-Benachrichtigungen benötigen HTTPS oder localhost
updateSetting('operationJournals', event.target.checked) und einen Browser mit Web-Push-Unterstützung.
} </p>
disabled={!settings.inAppEnabled && !settings.emailEnabled} )}
label="Einsatz-Journal"
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Einsätzen."
/>
<Switch {pushSupported &&
checked={settings.systemMessages} settings.desktopEnabled &&
onChange={(event) => !isPushSubscribed && (
updateSetting('systemMessages', event.target.checked) <Button
} type="button"
disabled={!settings.inAppEnabled && !settings.emailEnabled} variant="secondary"
label="Systemmeldungen" color="indigo"
description="Benachrichtigt dich bei wichtigen Systemhinweisen." 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> </NotificationCard>
</div> </div>
</Section> </Section>

View File

@ -66,7 +66,7 @@ export default function PresenceSettings({
onUserUpdated?.(data.user) onUserUpdated?.(data.user)
} }
showToast({ showToast({
title: 'Datenschutz gespeichert', title: 'Einstellungen gespeichert',
message: 'Deine Datenschutz- und Inaktivitätseinstellungen wurden aktualisiert.', message: 'Deine Datenschutz- und Inaktivitätseinstellungen wurden aktualisiert.',
variant: 'success', variant: 'success',
}) })
@ -142,7 +142,7 @@ export default function PresenceSettings({
</div> </div>
<Button type="submit" isLoading={saving}> <Button type="submit" isLoading={saving}>
Datenschutz speichern Speichern
</Button> </Button>
</form> </form>
</div> </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 { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
const certDir = fileURLToPath(new URL('./certs/', import.meta.url))
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
tailwindcss(), 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/, ''),
},
},
},
}) })