teg/backend/operations.go
2026-06-22 13:57:42 +02:00

1311 lines
35 KiB
Go

// backend\operations.go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type Operation struct {
ID string `json:"id"`
OperationNumber string `json:"operationNumber"`
OperationName string `json:"operationName"`
Offense string `json:"offense"`
CaseWorker string `json:"caseWorker"`
TargetObject string `json:"targetObject"`
KwAddress string `json:"kwAddress"`
KwLatitude *float64 `json:"kwLatitude"`
KwLongitude *float64 `json:"kwLongitude"`
TargetLatitude *float64 `json:"targetLatitude"`
TargetLongitude *float64 `json:"targetLongitude"`
ViewConeFOV float64 `json:"viewConeFov"`
ViewConeLength float64 `json:"viewConeLength"`
OperationLeader string `json:"operationLeader"`
OperationTeam string `json:"operationTeam"`
Legend string `json:"legend"`
Camera string `json:"camera"`
Router string `json:"router"`
Technology string `json:"technology"`
Switchbox string `json:"switchbox"`
HardDrive string `json:"hardDrive"`
Laptop string `json:"laptop"`
Remark string `json:"remark"`
MilestoneStorage string `json:"milestoneStorage"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreateOperationJournalEntryRequest struct {
Content string `json:"content"`
}
type UpdateOperationJournalEntryRequest struct {
Content string `json:"content"`
}
type CreateOperationRequest struct {
OperationNumber string `json:"operationNumber"`
OperationName string `json:"operationName"`
Offense string `json:"offense"`
CaseWorker string `json:"caseWorker"`
TargetObject string `json:"targetObject"`
KwAddress string `json:"kwAddress"`
KwLatitude *float64 `json:"kwLatitude"`
KwLongitude *float64 `json:"kwLongitude"`
TargetLatitude *float64 `json:"targetLatitude"`
TargetLongitude *float64 `json:"targetLongitude"`
ViewConeFOV float64 `json:"viewConeFov"`
ViewConeLength float64 `json:"viewConeLength"`
OperationLeader string `json:"operationLeader"`
OperationTeam string `json:"operationTeam"`
Legend string `json:"legend"`
Camera string `json:"camera"`
Router string `json:"router"`
Technology string `json:"technology"`
Switchbox string `json:"switchbox"`
HardDrive string `json:"hardDrive"`
Laptop string `json:"laptop"`
Remark string `json:"remark"`
MilestoneStorage string `json:"milestoneStorage"`
}
type UpdateOperationRequest = CreateOperationRequest
type OperationHistoryEntry struct {
ID string `json:"id"`
OperationID string `json:"operationId"`
UserID string `json:"userId"`
UserDisplayName string `json:"userDisplayName"`
UserAvatar string `json:"userAvatar"`
Action string `json:"action"`
Changes []OperationHistoryChange `json:"changes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CanEdit bool `json:"canEdit"`
}
type OperationHistoryChange struct {
Field string `json:"field"`
Label string `json:"label"`
OldValue string `json:"oldValue"`
NewValue string `json:"newValue"`
}
func (s *Server) handleOperations(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleListOperations(w, r)
case http.MethodPost:
s.handleCreateOperation(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (s *Server) handleOperation(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
s.handleUpdateOperation(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(
r.Context(),
`
SELECT
id,
operation_number,
operation_name,
offense,
case_worker,
target_object,
kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader,
operation_team,
legend,
camera,
router,
technology,
switchbox,
hard_drive,
laptop,
remark,
milestone_storage,
created_at,
updated_at
FROM operations
ORDER BY operation_number ASC
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not load operations")
return
}
defer rows.Close()
operations := []Operation{}
for rows.Next() {
var operation Operation
if err := rows.Scan(
&operation.ID,
&operation.OperationNumber,
&operation.OperationName,
&operation.Offense,
&operation.CaseWorker,
&operation.TargetObject,
&operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader,
&operation.OperationTeam,
&operation.Legend,
&operation.Camera,
&operation.Router,
&operation.Technology,
&operation.Switchbox,
&operation.HardDrive,
&operation.Laptop,
&operation.Remark,
&operation.MilestoneStorage,
&operation.CreatedAt,
&operation.UpdatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read operations")
return
}
operations = append(operations, operation)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read operations")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"operations": operations,
})
}
func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
var input CreateOperationRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
normalizeOperationInput(&input)
if input.OperationNumber == "" {
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
return
}
if err := validateOperationGeometry(input); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not create operation")
return
}
defer tx.Rollback(r.Context())
var operation Operation
err = tx.QueryRow(
r.Context(),
`
INSERT INTO operations (
operation_number,
operation_name,
offense,
case_worker,
target_object,
kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader,
operation_team,
legend,
camera,
router,
technology,
switchbox,
hard_drive,
laptop,
remark,
milestone_storage
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)
RETURNING
id,
operation_number,
operation_name,
offense,
case_worker,
target_object,
kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader,
operation_team,
legend,
camera,
router,
technology,
switchbox,
hard_drive,
laptop,
remark,
milestone_storage,
created_at,
updated_at
`,
input.OperationNumber,
input.OperationName,
input.Offense,
input.CaseWorker,
input.TargetObject,
input.KwAddress,
input.KwLatitude,
input.KwLongitude,
input.TargetLatitude,
input.TargetLongitude,
input.ViewConeFOV,
input.ViewConeLength,
input.OperationLeader,
input.OperationTeam,
input.Legend,
input.Camera,
input.Router,
input.Technology,
input.Switchbox,
input.HardDrive,
input.Laptop,
input.Remark,
input.MilestoneStorage,
).Scan(
&operation.ID,
&operation.OperationNumber,
&operation.OperationName,
&operation.Offense,
&operation.CaseWorker,
&operation.TargetObject,
&operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader,
&operation.OperationTeam,
&operation.Legend,
&operation.Camera,
&operation.Router,
&operation.Technology,
&operation.Switchbox,
&operation.HardDrive,
&operation.Laptop,
&operation.Remark,
&operation.MilestoneStorage,
&operation.CreatedAt,
&operation.UpdatedAt,
)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeError(w, http.StatusConflict, "Einsatz mit dieser Einsatznummer existiert bereits")
return
}
writeError(w, http.StatusInternalServerError, "Could not create operation")
return
}
changes := buildOperationCreateHistoryChanges(input)
if err := recordOperationHistory(r.Context(), tx, operation.ID, user, "created", changes); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create operation history")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create operation")
return
}
if err := s.createOperationNotification(
r.Context(),
user,
operation,
"operation.created",
"Neuer Einsatz",
fmt.Sprintf("Einsatz %s wurde erstellt.", operation.OperationNumber),
map[string]any{
"operationNumber": operation.OperationNumber,
"operationName": operation.OperationName,
},
); err != nil {
// nicht abbrechen, Einsatz wurde bereits erstellt
}
if err := s.createOperationCreatedEvent(
r.Context(),
user,
operation,
map[string]any{
"operationNumber": operation.OperationNumber,
"operationName": operation.OperationName,
"action": "created",
},
); err != nil {
// nicht abbrechen, Einsatz wurde bereits erstellt
}
writeJSON(w, http.StatusCreated, map[string]any{
"operation": operation,
})
}
func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
operationID := strings.TrimSpace(r.PathValue("id"))
if operationID == "" {
writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt")
return
}
var input UpdateOperationRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
normalizeOperationInput(&input)
if input.OperationNumber == "" {
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
return
}
if err := validateOperationGeometry(input); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not update operation")
return
}
defer tx.Rollback(r.Context())
oldOperation, err := getOperationForHistory(r.Context(), tx, operationID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not load operation history state")
return
}
var operation Operation
err = tx.QueryRow(
r.Context(),
`
UPDATE operations
SET
operation_number = $2,
operation_name = $3,
offense = $4,
case_worker = $5,
target_object = $6,
kw_address = $7,
kw_latitude = $8,
kw_longitude = $9,
target_latitude = $10,
target_longitude = $11,
view_cone_fov = $12,
view_cone_length = $13,
operation_leader = $14,
operation_team = $15,
legend = $16,
camera = $17,
router = $18,
technology = $19,
switchbox = $20,
hard_drive = $21,
laptop = $22,
remark = $23,
milestone_storage = $24,
updated_at = now()
WHERE id = $1
RETURNING
id,
operation_number,
operation_name,
offense,
case_worker,
target_object,
kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader,
operation_team,
legend,
camera,
router,
technology,
switchbox,
hard_drive,
laptop,
remark,
milestone_storage,
created_at,
updated_at
`,
operationID,
input.OperationNumber,
input.OperationName,
input.Offense,
input.CaseWorker,
input.TargetObject,
input.KwAddress,
input.KwLatitude,
input.KwLongitude,
input.TargetLatitude,
input.TargetLongitude,
input.ViewConeFOV,
input.ViewConeLength,
input.OperationLeader,
input.OperationTeam,
input.Legend,
input.Camera,
input.Router,
input.Technology,
input.Switchbox,
input.HardDrive,
input.Laptop,
input.Remark,
input.MilestoneStorage,
).Scan(
&operation.ID,
&operation.OperationNumber,
&operation.OperationName,
&operation.Offense,
&operation.CaseWorker,
&operation.TargetObject,
&operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader,
&operation.OperationTeam,
&operation.Legend,
&operation.Camera,
&operation.Router,
&operation.Technology,
&operation.Switchbox,
&operation.HardDrive,
&operation.Laptop,
&operation.Remark,
&operation.MilestoneStorage,
&operation.CreatedAt,
&operation.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden")
return
}
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeError(w, http.StatusConflict, "Einsatz mit dieser Einsatznummer existiert bereits")
return
}
writeError(w, http.StatusInternalServerError, "Could not update operation")
return
}
changes := buildOperationUpdateHistoryChanges(oldOperation, input)
if len(changes) > 0 {
if err := recordOperationHistory(r.Context(), tx, operation.ID, user, "updated", changes); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create operation history")
return
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Could not update operation")
return
}
if len(changes) > 0 {
if err := s.createOperationUpdateEvent(
r.Context(),
user,
operation,
map[string]any{
"operationNumber": operation.OperationNumber,
"operationName": operation.OperationName,
"changes": changes,
},
); err != nil {
// nicht abbrechen, Änderung wurde bereits gespeichert
}
}
writeJSON(w, http.StatusOK, map[string]any{
"operation": operation,
})
}
func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Request) {
operationID := strings.TrimSpace(r.PathValue("id"))
if operationID == "" {
writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt")
return
}
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
rows, err := s.db.Query(
r.Context(),
`
SELECT
h.id,
h.operation_id,
COALESCE(h.user_id::TEXT, ''),
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
COALESCE(u.avatar, ''),
h.action,
h.changes,
h.created_at,
h.updated_at
FROM operation_history h
LEFT JOIN users u ON u.id = h.user_id
WHERE h.operation_id = $1
ORDER BY h.created_at DESC
`,
operationID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not load operation history")
return
}
defer rows.Close()
history := []OperationHistoryEntry{}
for rows.Next() {
var entry OperationHistoryEntry
var changesRaw []byte
if err := rows.Scan(
&entry.ID,
&entry.OperationID,
&entry.UserID,
&entry.UserDisplayName,
&entry.UserAvatar,
&entry.Action,
&changesRaw,
&entry.CreatedAt,
&entry.UpdatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read operation history")
return
}
if len(changesRaw) > 0 {
if err := json.Unmarshal(changesRaw, &entry.Changes); err != nil {
writeError(w, http.StatusInternalServerError, "Could not parse operation history")
return
}
}
entry.CanEdit =
entry.Action == "journal" &&
entry.UserID == user.ID &&
time.Since(entry.CreatedAt) <= 10*time.Minute
history = append(history, entry)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read operation history")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"history": history,
})
}
func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string) (Operation, error) {
var operation Operation
err := tx.QueryRow(
ctx,
`
SELECT
id,
operation_number,
operation_name,
offense,
case_worker,
target_object,
kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader,
operation_team,
legend,
camera,
router,
technology,
switchbox,
hard_drive,
laptop,
remark,
milestone_storage,
created_at,
updated_at
FROM operations
WHERE id = $1
LIMIT 1
`,
operationID,
).Scan(
&operation.ID,
&operation.OperationNumber,
&operation.OperationName,
&operation.Offense,
&operation.CaseWorker,
&operation.TargetObject,
&operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader,
&operation.OperationTeam,
&operation.Legend,
&operation.Camera,
&operation.Router,
&operation.Technology,
&operation.Switchbox,
&operation.HardDrive,
&operation.Laptop,
&operation.Remark,
&operation.MilestoneStorage,
&operation.CreatedAt,
&operation.UpdatedAt,
)
return operation, err
}
func recordOperationHistory(
ctx context.Context,
tx pgx.Tx,
operationID string,
user User,
action string,
changes []OperationHistoryChange,
) error {
changesJSON, err := json.Marshal(changes)
if err != nil {
return err
}
_, err = tx.Exec(
ctx,
`
INSERT INTO operation_history (
operation_id,
user_id,
action,
changes
)
VALUES ($1, $2, $3, $4::JSONB)
`,
operationID,
user.ID,
action,
string(changesJSON),
)
return err
}
func buildOperationCreateHistoryChanges(input CreateOperationRequest) []OperationHistoryChange {
changes := []OperationHistoryChange{}
changes = appendOperationHistoryChange(changes, "operationNumber", "Einsatznummer", "", input.OperationNumber)
changes = appendOperationHistoryChange(changes, "operationName", "Einsatzname", "", input.OperationName)
changes = appendOperationHistoryChange(changes, "offense", "Delikt", "", input.Offense)
changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", "", input.CaseWorker)
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", "", input.TargetObject)
changes = appendOperationHistoryChange(changes, "kwAddress", "KW", "", input.KwAddress)
changes = appendOperationHistoryChange(changes, "kwPosition", "KW-Position", "", formatOperationCoordinates(input.KwLatitude, input.KwLongitude))
changes = appendOperationHistoryChange(changes, "targetPosition", "ZO-Position", "", formatOperationCoordinates(input.TargetLatitude, input.TargetLongitude))
if input.KwLatitude != nil && input.TargetLatitude != nil {
changes = appendOperationHistoryChange(changes, "viewConeFov", "Sichtfeld", "", fmt.Sprintf("%.0f°", input.ViewConeFOV))
changes = appendOperationHistoryChange(changes, "viewConeLength", "Sichtweite", "", formatOperationDistance(input.ViewConeLength))
}
changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", "", input.OperationLeader)
changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", "", input.OperationTeam)
changes = appendOperationHistoryChange(changes, "legend", "Legende", "", input.Legend)
changes = appendOperationHistoryChange(changes, "camera", "Kamera", "", input.Camera)
changes = appendOperationHistoryChange(changes, "router", "Router", "", input.Router)
changes = appendOperationHistoryChange(changes, "technology", "Technik", "", input.Technology)
changes = appendOperationHistoryChange(changes, "switchbox", "Switchbox", "", input.Switchbox)
changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", "", input.HardDrive)
changes = appendOperationHistoryChange(changes, "laptop", "Laptop", "", input.Laptop)
changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", "", input.Remark)
changes = appendOperationHistoryChange(changes, "milestoneStorage", "Milestone-Speicher", "", input.MilestoneStorage)
return changes
}
func buildOperationUpdateHistoryChanges(oldOperation Operation, input UpdateOperationRequest) []OperationHistoryChange {
changes := []OperationHistoryChange{}
changes = appendOperationHistoryChange(changes, "operationNumber", "Einsatznummer", oldOperation.OperationNumber, input.OperationNumber)
changes = appendOperationHistoryChange(changes, "operationName", "Einsatzname", oldOperation.OperationName, input.OperationName)
changes = appendOperationHistoryChange(changes, "offense", "Delikt", oldOperation.Offense, input.Offense)
changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", oldOperation.CaseWorker, input.CaseWorker)
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", oldOperation.TargetObject, input.TargetObject)
changes = appendOperationHistoryChange(changes, "kwAddress", "KW", oldOperation.KwAddress, input.KwAddress)
changes = appendOperationHistoryChange(changes, "kwPosition", "KW-Position", formatOperationCoordinates(oldOperation.KwLatitude, oldOperation.KwLongitude), formatOperationCoordinates(input.KwLatitude, input.KwLongitude))
changes = appendOperationHistoryChange(changes, "targetPosition", "ZO-Position", formatOperationCoordinates(oldOperation.TargetLatitude, oldOperation.TargetLongitude), formatOperationCoordinates(input.TargetLatitude, input.TargetLongitude))
changes = appendOperationHistoryChange(changes, "viewConeFov", "Sichtfeld", fmt.Sprintf("%.0f°", oldOperation.ViewConeFOV), fmt.Sprintf("%.0f°", input.ViewConeFOV))
changes = appendOperationHistoryChange(changes, "viewConeLength", "Sichtweite", formatOperationDistance(oldOperation.ViewConeLength), formatOperationDistance(input.ViewConeLength))
changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", oldOperation.OperationLeader, input.OperationLeader)
changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", oldOperation.OperationTeam, input.OperationTeam)
changes = appendOperationHistoryChange(changes, "legend", "Legende", oldOperation.Legend, input.Legend)
changes = appendOperationHistoryChange(changes, "camera", "Kamera", oldOperation.Camera, input.Camera)
changes = appendOperationHistoryChange(changes, "router", "Router", oldOperation.Router, input.Router)
changes = appendOperationHistoryChange(changes, "technology", "Technik", oldOperation.Technology, input.Technology)
changes = appendOperationHistoryChange(changes, "switchbox", "Switchbox", oldOperation.Switchbox, input.Switchbox)
changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", oldOperation.HardDrive, input.HardDrive)
changes = appendOperationHistoryChange(changes, "laptop", "Laptop", oldOperation.Laptop, input.Laptop)
changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", oldOperation.Remark, input.Remark)
changes = appendOperationHistoryChange(changes, "milestoneStorage", "Milestone-Speicher", oldOperation.MilestoneStorage, input.MilestoneStorage)
return changes
}
func appendOperationHistoryChange(
changes []OperationHistoryChange,
field string,
label string,
oldValue string,
newValue string,
) []OperationHistoryChange {
oldValue = strings.TrimSpace(oldValue)
newValue = strings.TrimSpace(newValue)
if oldValue == newValue {
return changes
}
return append(changes, OperationHistoryChange{
Field: field,
Label: label,
OldValue: displayHistoryValue(oldValue),
NewValue: displayHistoryValue(newValue),
})
}
func normalizeOperationInput(input *CreateOperationRequest) {
input.OperationNumber = strings.TrimSpace(input.OperationNumber)
input.OperationName = strings.TrimSpace(input.OperationName)
input.Offense = strings.TrimSpace(input.Offense)
input.CaseWorker = strings.TrimSpace(input.CaseWorker)
input.TargetObject = strings.TrimSpace(input.TargetObject)
input.KwAddress = strings.TrimSpace(input.KwAddress)
input.OperationLeader = strings.TrimSpace(input.OperationLeader)
input.OperationTeam = strings.TrimSpace(input.OperationTeam)
input.Legend = strings.TrimSpace(input.Legend)
input.Camera = strings.TrimSpace(input.Camera)
input.Router = strings.TrimSpace(input.Router)
input.Technology = strings.TrimSpace(input.Technology)
input.Switchbox = strings.TrimSpace(input.Switchbox)
input.HardDrive = strings.TrimSpace(input.HardDrive)
input.Laptop = strings.TrimSpace(input.Laptop)
input.Remark = strings.TrimSpace(input.Remark)
input.MilestoneStorage = strings.TrimSpace(input.MilestoneStorage)
if input.ViewConeFOV == 0 {
input.ViewConeFOV = 36
}
}
func validateOperationGeometry(input CreateOperationRequest) error {
if err := validateOperationCoordinatePair("KW", input.KwLatitude, input.KwLongitude); err != nil {
return err
}
if err := validateOperationCoordinatePair("ZO", input.TargetLatitude, input.TargetLongitude); err != nil {
return err
}
if input.ViewConeFOV < 1 || input.ViewConeFOV > 180 {
return errors.New("Das Sichtfeld muss zwischen 1 und 180 Grad liegen")
}
if math.IsNaN(input.ViewConeLength) || math.IsInf(input.ViewConeLength, 0) ||
input.ViewConeLength < 0 || input.ViewConeLength > 100000 {
return errors.New("Die Sichtweite muss zwischen 0 und 100000 Metern liegen")
}
return nil
}
func validateOperationCoordinatePair(label string, latitude, longitude *float64) error {
if latitude == nil && longitude == nil {
return nil
}
if latitude == nil || longitude == nil {
return fmt.Errorf("%s-Koordinaten sind unvollständig", label)
}
if math.IsNaN(*latitude) || math.IsInf(*latitude, 0) ||
math.IsNaN(*longitude) || math.IsInf(*longitude, 0) ||
*latitude < -90 || *latitude > 90 ||
*longitude < -180 || *longitude > 180 {
return fmt.Errorf("%s-Koordinaten sind ungültig", label)
}
return nil
}
func formatOperationCoordinates(latitude, longitude *float64) string {
if latitude == nil || longitude == nil {
return ""
}
return fmt.Sprintf("%.6f, %.6f", *latitude, *longitude)
}
func formatOperationDistance(distance float64) string {
if distance <= 0 {
return "Automatisch bis ZO"
}
return fmt.Sprintf("%.0f m", distance)
}
func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) {
operationID := strings.TrimSpace(r.PathValue("id"))
if operationID == "" {
writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt")
return
}
var input CreateOperationJournalEntryRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
content := strings.TrimSpace(input.Content)
if content == "" {
writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein")
return
}
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht erstellt werden")
return
}
defer tx.Rollback(r.Context())
operation, err := getOperationForHistory(r.Context(), tx, operationID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Einsatz konnte nicht geladen werden")
return
}
changes := []OperationHistoryChange{
{
Field: "journal",
Label: "Journal",
OldValue: "",
NewValue: content,
},
}
if err := recordOperationHistory(r.Context(), tx, operation.ID, user, "journal", changes); err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
return
}
_ = s.createOperationJournalEvent(
r.Context(),
user,
operation,
map[string]any{
"operationNumber": operation.OperationNumber,
"operationName": operation.OperationName,
},
)
writeJSON(w, http.StatusCreated, map[string]any{
"ok": true,
})
}
func (s *Server) handleUpdateOperationJournalEntry(w http.ResponseWriter, r *http.Request) {
operationID := strings.TrimSpace(r.PathValue("id"))
historyID := strings.TrimSpace(r.PathValue("historyId"))
if operationID == "" {
writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt")
return
}
if historyID == "" {
writeError(w, http.StatusBadRequest, "Journal-ID fehlt")
return
}
var input UpdateOperationJournalEntryRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
content := strings.TrimSpace(input.Content)
if content == "" {
writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein")
return
}
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
return
}
defer tx.Rollback(r.Context())
operation, err := getOperationForHistory(r.Context(), tx, operationID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Einsatz konnte nicht geladen werden")
return
}
var changesRaw []byte
var createdAt time.Time
err = tx.QueryRow(
r.Context(),
`
SELECT changes, created_at
FROM operation_history
WHERE id = $1
AND operation_id = $2
AND user_id = $3
AND action = 'journal'
LIMIT 1
`,
historyID,
operationID,
user.ID,
).Scan(&changesRaw, &createdAt)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Journal-Eintrag wurde nicht gefunden oder darf nicht bearbeitet werden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht geladen werden")
return
}
if time.Since(createdAt) > 10*time.Minute {
writeError(w, http.StatusForbidden, "Journal-Eintrag kann nur innerhalb von 10 Minuten bearbeitet werden")
return
}
var oldChanges []OperationHistoryChange
if len(changesRaw) > 0 {
if err := json.Unmarshal(changesRaw, &oldChanges); err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gelesen werden")
return
}
}
oldContent := ""
for _, change := range oldChanges {
if change.Field == "journal" {
oldContent = change.NewValue
break
}
}
if strings.TrimSpace(oldContent) == content {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
return
}
changes := []OperationHistoryChange{
{
Field: "journal",
Label: "Journal",
OldValue: oldContent,
NewValue: content,
},
}
changesJSON, err := json.Marshal(changes)
if err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht vorbereitet werden")
return
}
commandTag, err := tx.Exec(
r.Context(),
`
UPDATE operation_history
SET
changes = $4::JSONB,
updated_at = now()
WHERE id = $1
AND operation_id = $2
AND user_id = $3
AND action = 'journal'
AND created_at >= now() - interval '10 minutes'
`,
historyID,
operationID,
user.ID,
string(changesJSON),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
return
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusForbidden, "Journal-Eintrag kann nicht mehr bearbeitet werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
return
}
_ = s.createOperationJournalEvent(
r.Context(),
user,
operation,
map[string]any{
"operationNumber": operation.OperationNumber,
"operationName": operation.OperationName,
"historyId": historyID,
"action": "updated",
},
)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func (s *Server) handleListUnreadOperationJournals(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
rows, err := s.db.Query(
r.Context(),
`
SELECT entity_id::TEXT
FROM notifications
WHERE user_id = $1
AND silent = true
AND type = 'operation.journal'
AND entity_type = 'operation'
AND entity_id IS NOT NULL
AND read_at IS NULL
GROUP BY entity_id
`,
user.ID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Ungelesene Journal-Einträge konnten nicht geladen werden")
return
}
defer rows.Close()
operationIds := []string{}
for rows.Next() {
var operationID string
if err := rows.Scan(&operationID); err != nil {
writeError(w, http.StatusInternalServerError, "Ungelesene Journal-Einträge konnten nicht gelesen werden")
return
}
operationIds = append(operationIds, operationID)
}
writeJSON(w, http.StatusOK, map[string]any{
"operationIds": operationIds,
})
}
func (s *Server) handleMarkOperationJournalRead(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
operationID := strings.TrimSpace(r.PathValue("id"))
if operationID == "" {
writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt")
return
}
_, err := s.db.Exec(
r.Context(),
`
UPDATE notifications
SET read_at = COALESCE(read_at, now())
WHERE user_id = $1
AND silent = true
AND type = 'operation.journal'
AND entity_type = 'operation'
AND entity_id = $2
AND read_at IS NULL
`,
user.ID,
operationID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Journal-Status konnte nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}