added calendar + bugfixes
This commit is contained in:
parent
04ab4523a6
commit
cd26d67252
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\backend\"; go vet ./... 2>&1 | Select-Object -First 20; \"VET: $LASTEXITCODE\"; gofmt -w chat.go chat_groups.go chat_websocket.go chat_unread.go chat_search.go main.go routes.go setup\\\\main.go; gofmt -l chat.go chat_groups.go chat_websocket.go chat_unread.go chat_search.go; \"fmt done\")",
|
||||||
|
"PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\frontend\"; npx tsc --noEmit 2>&1 | Select-Object -First 40; \"TSC EXIT: $LASTEXITCODE\")",
|
||||||
|
"PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\frontend\"; npx tsc --noEmit 2>&1 | Select-Object -First 30; \"TSC EXIT: $LASTEXITCODE\")",
|
||||||
|
"Bash(npx tsc *)",
|
||||||
|
"Bash(echo \"EXIT:$?\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -75,3 +75,4 @@ $RECYCLE.BIN/
|
|||||||
|
|
||||||
# Windows shortcuts
|
# Windows shortcuts
|
||||||
*.lnk
|
*.lnk
|
||||||
|
.gocache
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
191
backend/admin.go
191
backend/admin.go
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
360
backend/admin_group_chats.go
Normal file
360
backend/admin_group_chats.go
Normal 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 1–80 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(¤tName, ¤tImage, &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
|
||||||
|
}
|
||||||
217
backend/calendar_settings.go
Normal file
217
backend/calendar_settings.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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'
|
||||||
|
|||||||
180
backend/chat.go
180
backend/chat.go
@ -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
850
backend/chat_groups.go
Normal 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 1–80 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(¤tName, ¤tImage, &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 1–80 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})
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
96
backend/cmd/vapid/main.go
Normal 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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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=
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
508
backend/push_notifications.go
Normal file
508
backend/push_notifications.go
Normal 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
|
||||||
|
}
|
||||||
@ -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 */
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
`
|
`
|
||||||
|
|||||||
@ -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)`,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
})
|
})
|
||||||
|
|||||||
1
frontend/.env.development
Normal file
1
frontend/.env.development
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL="/api"
|
||||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
18
frontend/public/manifest.webmanifest
Normal file
18
frontend/public/manifest.webmanifest
Normal 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
64
frontend/public/sw.js
Normal 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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
1251
frontend/src/components/calendar/Calendar.tsx
Normal file
1251
frontend/src/components/calendar/Calendar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
85
frontend/src/components/calendar/calendarSettings.ts
Normal file
85
frontend/src/components/calendar/calendarSettings.ts
Normal 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
|
||||||
|
}
|
||||||
6
frontend/src/components/calendar/dateUtils.ts
Normal file
6
frontend/src/components/calendar/dateUtils.ts
Normal 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}`
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
612
frontend/src/pages/calendar/CalendarPage.tsx
Normal file
612
frontend/src/pages/calendar/CalendarPage.tsx
Normal 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
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
213
frontend/src/utils/pushNotifications.ts
Normal file
213
frontend/src/utils/pushNotifications.ts
Normal 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
|
||||||
|
}
|
||||||
79
frontend/src/utils/titleNotifier.ts
Normal file
79
frontend/src/utils/titleNotifier.ts
Normal 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)
|
||||||
|
}
|
||||||
@ -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/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user