teg/backend/setup/main.go
2026-07-03 16:56:20 +02:00

1490 lines
47 KiB
Go

// backend\setup\main.go
package main
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"flag"
"fmt"
"log"
"net/url"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"golang.org/x/crypto/bcrypt"
)
func main() {
reset := flag.Bool("reset", false, "Datenbank komplett löschen und neu anlegen")
flag.Parse()
if err := godotenv.Load(".env", "backend/.env"); err != nil {
log.Println("Keine .env Datei gefunden, nutze System-Environment oder Fallbacks")
}
databaseURL := env("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/teg?sslmode=disable")
parsedURL, err := url.Parse(databaseURL)
if err != nil {
log.Fatal(err)
}
databaseName := strings.TrimPrefix(parsedURL.Path, "/")
if databaseName == "" {
log.Fatal("DATABASE_URL enthält keinen Datenbanknamen")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if *reset {
if err := dropDatabaseIfExists(ctx, parsedURL, databaseName); err != nil {
if errors.Is(err, errResetAborted) {
fmt.Println("Reset abgebrochen. Es wurden keine Daten gelöscht.")
return
}
log.Fatal(err)
}
}
if err := createDatabaseIfNotExists(ctx, parsedURL, databaseName); err != nil {
log.Fatal(err)
}
db, err := pgxpool.New(ctx, databaseURL)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := db.Ping(ctx); err != nil {
log.Fatal(err)
}
if err := createSchema(ctx, db); err != nil {
log.Fatal(err)
}
password, created, err := createAdminIfNotExists(ctx, db)
if err != nil {
log.Fatal(err)
}
printAdminResult(password, created)
waitForExit()
}
var errResetAborted = errors.New("reset abgebrochen")
func confirmDatabaseReset(databaseName string) (bool, error) {
reader := bufio.NewReader(os.Stdin)
fmt.Println("")
fmt.Println("WARNUNG: Der Datenbank-Reset löscht alle Daten unwiderruflich.")
fmt.Printf("Datenbank: %s\n", databaseName)
fmt.Println("")
fmt.Printf("Bitte gib den Datenbanknamen %q ein, um fortzufahren: ", databaseName)
input, err := reader.ReadString('\n')
if err != nil {
return false, err
}
input = strings.TrimSpace(input)
return input == databaseName, nil
}
func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName string) error {
if databaseName == "postgres" || databaseName == "template0" || databaseName == "template1" {
return fmt.Errorf("datenbank %q darf nicht per -reset gelöscht werden", databaseName)
}
maintenanceURL := *parsedURL
maintenanceURL.Path = "/postgres"
db, err := pgxpool.New(ctx, maintenanceURL.String())
if err != nil {
return err
}
defer db.Close()
var exists bool
err = db.QueryRow(
ctx,
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
databaseName,
).Scan(&exists)
if err != nil {
return err
}
if !exists {
fmt.Printf("Datenbank %q existiert nicht, Reset übersprungen\n", databaseName)
return nil
}
confirmed, err := confirmDatabaseReset(databaseName)
if err != nil {
return err
}
if !confirmed {
return errResetAborted
}
fmt.Printf("Datenbank %q wird zurückgesetzt...\n", databaseName)
_, err = db.Exec(
ctx,
`
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1
AND pid <> pg_backend_pid()
`,
databaseName,
)
if err != nil {
return err
}
_, err = db.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdentifier(databaseName))
if err != nil {
return err
}
fmt.Printf("Datenbank %q wurde gelöscht\n", databaseName)
return nil
}
func createDatabaseIfNotExists(ctx context.Context, parsedURL *url.URL, databaseName string) error {
maintenanceURL := *parsedURL
maintenanceURL.Path = "/postgres"
db, err := pgxpool.New(ctx, maintenanceURL.String())
if err != nil {
return err
}
defer db.Close()
var exists bool
err = db.QueryRow(
ctx,
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
databaseName,
).Scan(&exists)
if err != nil {
return err
}
if exists {
fmt.Printf("Datenbank %q existiert bereits\n", databaseName)
return nil
}
_, err = db.Exec(ctx, `CREATE DATABASE `+quoteIdentifier(databaseName))
if err != nil {
return err
}
fmt.Printf("Datenbank %q wurde erstellt\n", databaseName)
return nil
}
func createSchema(ctx context.Context, db *pgxpool.Pool) error {
queries := []string{
`CREATE EXTENSION IF NOT EXISTS pgcrypto`,
`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE,
display_name TEXT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
avatar TEXT,
unit TEXT,
user_group TEXT,
rights TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
token_version INTEGER NOT NULL DEFAULT 1,
last_seen_at TIMESTAMPTZ,
last_active_at TIMESTAMPTZ,
presence_mode TEXT NOT NULL DEFAULT 'online'
CHECK (presence_mode IN ('online', 'away', 'offline')),
presence_mode_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
away_after_minutes INTEGER NOT NULL DEFAULT 3
CHECK (away_after_minutes BETWEEN 1 AND 120),
show_last_seen BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
// Online-Status: last_seen_at wird im laufenden Betrieb bei Aktivität
// aktualisiert. "Online" wird daraus abgeleitet (z. B. Aktivität innerhalb
// der letzten Minuten).
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ`,
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ`,
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS presence_mode TEXT NOT NULL DEFAULT 'online'`,
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS presence_mode_updated_at TIMESTAMPTZ NOT NULL DEFAULT now()`,
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS away_after_minutes INTEGER NOT NULL DEFAULT 3`,
`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'users'
AND column_name = 'away_after_minutes'
AND column_default LIKE '5%'
) THEN
UPDATE users
SET away_after_minutes = 3
WHERE away_after_minutes = 5;
ALTER TABLE users
ALTER COLUMN away_after_minutes SET DEFAULT 3;
END IF;
END
$$
`,
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS show_last_seen BOOLEAN NOT NULL DEFAULT false`,
// Der Online-Status wird nicht dauerhaft als Boolean gespeichert, damit
// abgebrochene Browser-Sitzungen nicht für immer als online erscheinen.
// Die API liefert onlineStatus=true, wenn last_seen_at höchstens zwei
// Minuten zurückliegt; der Client aktualisiert den Wert per Heartbeat.
`
CREATE OR REPLACE VIEW user_presence AS
SELECT
id AS user_id,
last_seen_at,
last_active_at,
presence_mode,
away_after_minutes,
CASE
WHEN presence_mode = 'offline'
OR last_seen_at IS NULL
OR last_seen_at < now() - INTERVAL '2 minutes'
THEN 'offline'
WHEN presence_mode = 'away'
OR last_active_at IS NULL
OR last_active_at < now() - make_interval(mins => away_after_minutes)
THEN 'away'
ELSE 'online'
END AS status
FROM users
`,
`
CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
browser TEXT NOT NULL DEFAULT '',
operating_system TEXT NOT NULL DEFAULT '',
device_type TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
)
`,
`
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
initial TEXT NOT NULL,
href TEXT NOT NULL DEFAULT '#',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS user_units (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
normalized_name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS user_teams (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, team_id)
)
`,
`
CREATE TABLE IF NOT EXISTS dashboard_widgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
x INTEGER NOT NULL DEFAULT 0,
y INTEGER NOT NULL DEFAULT 0,
w INTEGER NOT NULL DEFAULT 4,
h INTEGER NOT NULL DEFAULT 2,
config JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS dashboard_settings (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
settings JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
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 calendar_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
start_at TIMESTAMP NOT NULL,
end_at TIMESTAMP NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT false,
location TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'indigo'
CHECK (color IN ('indigo', 'blue', 'emerald', 'amber', 'rose', 'slate')),
recurrence TEXT NOT NULL DEFAULT 'none',
recurrence_weekday INTEGER,
recurrence_ordinal INTEGER,
recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT calendar_events_valid_range CHECK (end_at > start_at)
)
`,
`
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT c.conname
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'calendar_events'
AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) ILIKE '%recurrence%'
LOOP
EXECUTE format('ALTER TABLE calendar_events DROP CONSTRAINT %I', constraint_name);
END LOOP;
END
$$
`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_weekday INTEGER`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_ordinal INTEGER`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB`,
`UPDATE calendar_events SET recurrence_exceptions = '[]'::JSONB WHERE recurrence_exceptions IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_calendar_events_start_at ON calendar_events(start_at)`,
`
CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_number TEXT NOT NULL,
manufacturer TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
serial_number TEXT NOT NULL DEFAULT '',
mac_address TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
phone_number TEXT NOT NULL DEFAULT '',
computer_name TEXT NOT NULL DEFAULT '',
device_category TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
loan_status TEXT NOT NULL DEFAULT 'Verfügbar',
loaned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
loaned_until DATE,
is_bao_device BOOLEAN NOT NULL DEFAULT false,
comment TEXT NOT NULL DEFAULT '',
milestone_recording_server_id TEXT NOT NULL DEFAULT '',
milestone_hardware_id TEXT NOT NULL DEFAULT '',
milestone_display_name TEXT NOT NULL DEFAULT '',
firmware_version TEXT NOT NULL DEFAULT '',
milestone_port TEXT NOT NULL DEFAULT '',
milestone_enabled BOOLEAN NOT NULL DEFAULT true,
milestone_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS phone_number TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS computer_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS device_category TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_until DATE`,
`
UPDATE devices
SET device_category = 'Kameras'
WHERE COALESCE(milestone_hardware_id, '') <> ''
AND COALESCE(device_category, '') = ''
`,
`
CREATE TABLE IF NOT EXISTS milestone_hardware_child_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
milestone_hardware_id TEXT NOT NULL,
milestone_device_id TEXT NOT NULL,
device_type TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
short_name TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT true,
recording_enabled BOOLEAN NOT NULL DEFAULT false,
recording_storage_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (milestone_hardware_id, milestone_device_id, device_type)
)
`,
`
CREATE TABLE IF NOT EXISTS device_manufacturers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
normalized_name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS device_locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
normalized_name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS device_relations (
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
related_device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (device_id, related_device_id),
CONSTRAINT device_relations_no_self_reference CHECK (device_id <> related_device_id)
)
`,
`
CREATE TABLE IF NOT EXISTS device_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
changes JSONB NOT NULL DEFAULT '[]'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS operations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
operation_number TEXT NOT NULL,
operation_name TEXT NOT NULL DEFAULT '',
offense TEXT NOT NULL DEFAULT '',
case_worker TEXT NOT NULL DEFAULT '',
target_object TEXT NOT NULL DEFAULT '',
kw_address TEXT NOT NULL DEFAULT '',
kw_latitude DOUBLE PRECISION,
kw_longitude DOUBLE PRECISION,
target_latitude DOUBLE PRECISION,
target_longitude DOUBLE PRECISION,
view_cone_fov DOUBLE PRECISION NOT NULL DEFAULT 36,
view_cone_length DOUBLE PRECISION NOT NULL DEFAULT 0,
operation_leader TEXT NOT NULL DEFAULT '',
operation_team TEXT NOT NULL DEFAULT '',
legend TEXT NOT NULL DEFAULT '',
camera TEXT NOT NULL DEFAULT '',
router TEXT NOT NULL DEFAULT '',
technology TEXT NOT NULL DEFAULT '',
switchbox TEXT NOT NULL DEFAULT '',
hard_drive TEXT NOT NULL DEFAULT '',
laptop TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
milestone_storage TEXT NOT NULL DEFAULT '',
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS milestone_storage TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS kw_latitude DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS kw_longitude DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS target_latitude DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS target_longitude DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS view_cone_fov DOUBLE PRECISION NOT NULL DEFAULT 36`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS view_cone_length DOUBLE PRECISION NOT NULL DEFAULT 0`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`,
`
CREATE TABLE IF NOT EXISTS operation_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
operation_id UUID NOT NULL REFERENCES operations(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
changes JSONB NOT NULL DEFAULT '[]'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'info',
title TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
silent BOOLEAN NOT NULL DEFAULT false,
entity_type TEXT NOT NULL DEFAULT '',
entity_id UUID,
data JSONB NOT NULL DEFAULT '{}'::JSONB,
in_app_visible BOOLEAN NOT NULL DEFAULT true,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS notifications ADD COLUMN IF NOT EXISTS in_app_visible BOOLEAN NOT NULL DEFAULT true`,
`
CREATE TABLE IF NOT EXISTS milestone_settings (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
host TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
password_encrypted BYTEA,
skip_tls_verify BOOLEAN NOT NULL DEFAULT false,
access_token_encrypted BYTEA,
token_type TEXT NOT NULL DEFAULT '',
token_expires_at TIMESTAMPTZ,
token_updated_at TIMESTAMPTZ,
server_id TEXT NOT NULL DEFAULT '',
server_name TEXT NOT NULL DEFAULT '',
server_version TEXT NOT NULL DEFAULT '',
server_updated_at TIMESTAMPTZ,
server_folders_agent_scheme TEXT NOT NULL DEFAULT 'http',
server_folders_agent_port TEXT NOT NULL DEFAULT '8099',
server_folders_storage_root_label TEXT NOT NULL DEFAULT 'Storage D',
server_folders_storage_root_path TEXT NOT NULL DEFAULT 'D:/Milestone-Speicher',
server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E',
server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/',
server_folders_agent_token_encrypted BYTEA,
backup_nas_host TEXT NOT NULL DEFAULT '',
backup_nas_share TEXT NOT NULL DEFAULT '',
backup_nas_username TEXT NOT NULL DEFAULT '',
backup_nas_password_encrypted BYTEA,
backup_log_root_path TEXT NOT NULL DEFAULT '',
active_directory_server_url TEXT NOT NULL DEFAULT '',
active_directory_base_dn TEXT NOT NULL DEFAULT '',
active_directory_bind_username TEXT NOT NULL DEFAULT '',
active_directory_bind_password_encrypted BYTEA,
active_directory_user_group TEXT NOT NULL DEFAULT 'SE_Sachbearbeitung',
active_directory_skip_tls_verify BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS server_folders_agent_scheme TEXT NOT NULL DEFAULT 'http'
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS server_folders_agent_port TEXT NOT NULL DEFAULT '8099'
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS server_folders_storage_root_label TEXT NOT NULL DEFAULT 'Storage D'
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS server_folders_storage_root_path TEXT NOT NULL DEFAULT 'D:/Milestone-Speicher'
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E'
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/'
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS backup_nas_host TEXT NOT NULL DEFAULT ''
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS backup_nas_share TEXT NOT NULL DEFAULT ''
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS backup_nas_username TEXT NOT NULL DEFAULT ''
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS backup_nas_password_encrypted BYTEA
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS backup_log_root_path TEXT NOT NULL DEFAULT ''
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT ''
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS active_directory_base_dn TEXT NOT NULL DEFAULT ''
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS active_directory_bind_username TEXT NOT NULL DEFAULT ''
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS active_directory_bind_password_encrypted BYTEA
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS active_directory_user_group TEXT NOT NULL DEFAULT 'SE_Sachbearbeitung'
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS active_directory_skip_tls_verify BOOLEAN NOT NULL DEFAULT false
`,
`
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
in_app_enabled BOOLEAN NOT NULL DEFAULT true,
sound_enabled BOOLEAN NOT NULL DEFAULT false,
sound_name TEXT NOT NULL DEFAULT 'default',
email_enabled BOOLEAN NOT NULL DEFAULT false,
operation_updates BOOLEAN NOT NULL DEFAULT true,
operation_journals BOOLEAN NOT NULL DEFAULT true,
device_updates BOOLEAN NOT NULL DEFAULT true,
device_journals BOOLEAN NOT NULL DEFAULT true,
system_messages BOOLEAN NOT NULL DEFAULT true,
chat_messages BOOLEAN NOT NULL DEFAULT true,
channel_messages BOOLEAN NOT NULL DEFAULT true,
desktop_enabled BOOLEAN NOT NULL DEFAULT false,
push_chat_messages BOOLEAN NOT NULL DEFAULT true,
push_channel_messages BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS chat_messages BOOLEAN NOT NULL DEFAULT true`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS channel_messages BOOLEAN NOT NULL DEFAULT true`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS desktop_enabled BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS push_chat_messages BOOLEAN NOT NULL DEFAULT true`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS push_channel_messages BOOLEAN NOT NULL DEFAULT true`,
`
CREATE TABLE IF NOT EXISTS push_subscriptions (
endpoint TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id UUID NOT NULL REFERENCES user_sessions(id) ON DELETE CASCADE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
api_base_url TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '24 hours',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS push_subscriptions ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '24 hours'`,
`
CREATE TABLE IF NOT EXISTS feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message TEXT NOT NULL,
log JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS user_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
level TEXT NOT NULL DEFAULT 'info',
category TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
user_snapshot JSONB NOT NULL DEFAULT '{}'::JSONB,
request JSONB NOT NULL DEFAULT '{}'::JSONB,
details JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS camera_firmware_cache (
manufacturer TEXT NOT NULL,
normalized_model TEXT NOT NULL,
latest_version TEXT NOT NULL DEFAULT '',
support_url TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
check_error TEXT NOT NULL DEFAULT '',
support_badges JSONB NOT NULL DEFAULT '[]'::JSONB,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (manufacturer, normalized_model)
)
`,
`
CREATE TABLE IF NOT EXISTS camera_firmware_refresh_state (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
last_checked_at TIMESTAMPTZ,
last_checked_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS onvif_resolution_cache (
milestone_hardware_id TEXT PRIMARY KEY,
host TEXT NOT NULL DEFAULT '',
port TEXT NOT NULL DEFAULT '',
resolutions JSONB NOT NULL DEFAULT '[]'::JSONB,
check_error TEXT NOT NULL DEFAULT '',
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
// ── Chat ────────────────────────────────────────────────────────────
// Eine Konversation ist entweder ein Team-Gruppenchat, Einzelchat,
// freier Gruppenchat oder ein schreibgeschützter Integrations-Channel.
`
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL DEFAULT 'direct'
CHECK (type IN ('direct', 'group', 'channel')),
name TEXT NOT NULL DEFAULT '',
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
-- Stabiler Schlüssel für Einzelchats (sortierte User-IDs),
-- damit pro Personenpaar nur eine Konversation existiert.
direct_key TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL DEFAULT '',
channel_source_name TEXT NOT NULL DEFAULT '',
channel_image TEXT NOT NULL DEFAULT '',
channel_all_users BOOLEAN NOT NULL DEFAULT false,
webhook_enabled BOOLEAN NOT NULL DEFAULT true,
webhook_token_hash TEXT NOT NULL DEFAULT '',
webhook_token_updated_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
last_message_at TIMESTAMPTZ,
disappearing_seconds INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS conversations DROP CONSTRAINT IF EXISTS conversations_type_check`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS disappearing_seconds INTEGER NOT NULL DEFAULT 0`,
`UPDATE conversations SET type = 'group', created_by = NULL WHERE type = 'team'`,
`ALTER TABLE IF EXISTS conversations ADD CONSTRAINT conversations_type_check CHECK (type IN ('direct', 'group', 'channel'))`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS slug TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_source_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_image TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_all_users BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_enabled BOOLEAN NOT NULL DEFAULT true`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_token_hash TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_token_updated_at TIMESTAMPTZ`,
`
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_read_at TIMESTAMPTZ,
last_cleared_at TIMESTAMPTZ,
muted BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (conversation_id, user_id)
)
`,
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS last_cleared_at TIMESTAMPTZ`,
`
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL DEFAULT '',
reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
message_type TEXT NOT NULL DEFAULT 'user',
system_event JSONB,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
)
`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT 'user'`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS system_event JSONB`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ`,
// Standort-Nachrichten: lat/lng sind NULL, wenn die Nachricht keinen Standort enthält.
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_lat DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_lng DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_label TEXT NOT NULL DEFAULT ''`,
`
CREATE TABLE IF NOT EXISTS message_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uploader_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL,
data BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS message_reactions (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id)
)
`,
// Link-Vorschau (OpenGraph-Metadaten), serverseitig asynchron geladen.
// Eine Vorschau pro Nachricht (erste erkannte URL).
`
CREATE TABLE IF NOT EXISTS message_link_previews (
message_id UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
site_name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`,
`CREATE INDEX IF NOT EXISTS idx_users_last_seen_at ON users(last_seen_at)`,
`CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_sessions_active ON user_sessions(user_id, revoked_at)`,
`CREATE INDEX IF NOT EXISTS idx_user_sessions_last_seen_at ON user_sessions(last_seen_at)`,
`CREATE INDEX IF NOT EXISTS idx_teams_name ON teams(name)`,
`CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_units_name ON user_units(name)`,
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`,
`CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_hardware_id ON milestone_hardware_child_devices(milestone_hardware_id)`,
`CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_device_id ON milestone_hardware_child_devices(milestone_device_id)`,
`CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_recording_storage_id ON milestone_hardware_child_devices(recording_storage_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,
`CREATE INDEX IF NOT EXISTS idx_devices_ip_address ON devices(ip_address)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_milestone_hardware_id_unique ON devices(milestone_hardware_id) WHERE milestone_hardware_id <> ''`,
`CREATE INDEX IF NOT EXISTS idx_devices_milestone_recording_server_id ON devices(milestone_recording_server_id)`,
`CREATE INDEX IF NOT EXISTS idx_devices_manufacturer ON devices(manufacturer)`,
`CREATE INDEX IF NOT EXISTS idx_devices_model ON devices(model)`,
`CREATE INDEX IF NOT EXISTS idx_devices_location ON devices(location)`,
`CREATE INDEX IF NOT EXISTS idx_devices_loan_status ON devices(loan_status)`,
`CREATE INDEX IF NOT EXISTS idx_device_manufacturers_name ON device_manufacturers(name)`,
`CREATE INDEX IF NOT EXISTS idx_device_locations_name ON device_locations(name)`,
`CREATE INDEX IF NOT EXISTS idx_device_relations_device_id ON device_relations(device_id)`,
`CREATE INDEX IF NOT EXISTS idx_device_relations_related_device_id ON device_relations(related_device_id)`,
`CREATE INDEX IF NOT EXISTS idx_device_history_device_id ON device_history(device_id)`,
`CREATE INDEX IF NOT EXISTS idx_device_history_user_id ON device_history(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_device_history_created_at ON device_history(created_at)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_operations_operation_number_unique ON operations(lower(operation_number))`,
`CREATE INDEX IF NOT EXISTS idx_operations_operation_name ON operations(operation_name)`,
`CREATE INDEX IF NOT EXISTS idx_operations_offense ON operations(offense)`,
`CREATE INDEX IF NOT EXISTS idx_operations_kw_address ON operations(kw_address)`,
`CREATE INDEX IF NOT EXISTS idx_operations_operation_leader ON operations(operation_leader)`,
`CREATE INDEX IF NOT EXISTS idx_operations_operation_team ON operations(operation_team)`,
`CREATE INDEX IF NOT EXISTS idx_operation_history_operation_id ON operation_history(operation_id)`,
`CREATE INDEX IF NOT EXISTS idx_operation_history_user_id ON operation_history(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_operation_history_created_at ON operation_history(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_notifications_read_at ON notifications(read_at)`,
`CREATE INDEX IF NOT EXISTS idx_notifications_entity ON notifications(entity_type, entity_id)`,
`CREATE INDEX IF NOT EXISTS idx_notification_preferences_user_id ON notification_preferences(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_session_id ON push_subscriptions(session_id)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON feedback(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_user_logs_user_id ON user_logs(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_logs_level ON user_logs(level)`,
`CREATE INDEX IF NOT EXISTS idx_user_logs_category ON user_logs(category)`,
`CREATE INDEX IF NOT EXISTS idx_user_logs_created_at ON user_logs(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_user_logs_user_created_at ON user_logs(user_id, created_at DESC, id DESC)`,
`CREATE INDEX IF NOT EXISTS idx_camera_firmware_cache_checked_at ON camera_firmware_cache(checked_at)`,
// Genau eine Konversation pro Team bzw. pro Einzelchat-Paar.
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_team_id_unique ON conversations(team_id) WHERE team_id IS NOT NULL`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_direct_key_unique ON conversations(direct_key) WHERE direct_key <> ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_channel_slug_unique ON conversations(slug) WHERE type = 'channel' AND slug <> ''`,
`CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type)`,
`CREATE INDEX IF NOT EXISTS idx_conversations_last_message_at ON conversations(last_message_at DESC)`,
`
INSERT INTO conversations (type, name, team_id)
SELECT 'group', name, id
FROM teams
ON CONFLICT (team_id) WHERE team_id IS NOT NULL
DO UPDATE SET
type = 'group',
name = EXCLUDED.name,
created_by = NULL,
updated_at = now()
`,
`
DELETE FROM conversation_members cm
USING conversations c
WHERE cm.conversation_id = c.id
AND c.team_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = cm.user_id
)
`,
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT c.id, ut.user_id
FROM conversations c
JOIN user_teams ut ON ut.team_id = c.team_id
WHERE c.type = 'group'
AND c.team_id IS NOT NULL
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
`
INSERT INTO conversations (
type,
name,
slug,
channel_source_name,
channel_all_users,
webhook_enabled
)
VALUES ('channel', 'Zabbix', 'zabbix', 'Zabbix', true, true)
ON CONFLICT (slug) WHERE type = 'channel' AND slug <> ''
DO NOTHING
`,
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT c.id, u.id
FROM conversations c
CROSS JOIN users u
WHERE c.type = 'channel'
AND c.channel_all_users
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
`CREATE INDEX IF NOT EXISTS idx_conversation_members_user_id ON conversation_members(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_conversation_members_conversation_id ON conversation_members(conversation_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_created_at ON messages(conversation_id, created_at DESC, id DESC)`,
`CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_reply_to_message_id ON messages(reply_to_message_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_forwarded_from_message_id ON messages(forwarded_from_message_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_expires_at ON messages(expires_at) WHERE expires_at IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`,
`
DELETE FROM message_reactions mr
USING (
SELECT ctid
FROM (
SELECT ctid,
ROW_NUMBER() OVER (
PARTITION BY message_id, user_id
ORDER BY created_at DESC, emoji ASC
) AS row_number
FROM message_reactions
) ranked_reactions
WHERE row_number > 1
) duplicate_reactions
WHERE mr.ctid = duplicate_reactions.ctid
`,
`CREATE INDEX IF NOT EXISTS idx_message_reactions_message_id ON message_reactions(message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_reactions_user_id ON message_reactions(user_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_message_reactions_message_user_unique ON message_reactions(message_id, user_id)`,
}
for _, query := range queries {
if _, err := db.Exec(ctx, query); err != nil {
return err
}
}
fmt.Println("Tabellen und Indizes wurden erstellt oder existieren bereits")
return nil
}
func createAdminIfNotExists(ctx context.Context, db *pgxpool.Pool) (string, bool, error) {
adminUsername := env("ADMIN_USERNAME", "admin")
adminDisplayName := env("ADMIN_DISPLAY_NAME", "Administrator")
adminEmail := env("ADMIN_EMAIL", "admin@example.com")
adminAvatar := env("ADMIN_AVATAR", "")
adminUnit := env("ADMIN_UNIT", "Administration")
adminGroup := env("ADMIN_GROUP", "admin")
adminRights := splitCSV(env(
"ADMIN_RIGHTS",
"admin,dashboard:read,settings:read,administration:read,administration:write,users:read,users:write,teams:read,teams:write,devices:read,devices:write,operations:read,operations:write,calendar:read,documents:read,reports:read",
))
adminTeamName := env("ADMIN_TEAM_NAME", "Administration")
adminTeamInitial := env("ADMIN_TEAM_INITIAL", "A")
adminTeamHref := env("ADMIN_TEAM_HREF", "#")
teamID, err := ensureTeam(ctx, db, adminTeamName, adminTeamInitial, adminTeamHref)
if err != nil {
return "", false, err
}
var existingUserID string
err = db.QueryRow(
ctx,
`
SELECT id
FROM users
WHERE username = $1 OR email = $2
LIMIT 1
`,
adminUsername,
adminEmail,
).Scan(&existingUserID)
if err == nil {
if err := assignUserToTeam(ctx, db, existingUserID, teamID); err != nil {
return "", false, err
}
return "", false, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return "", false, err
}
password, err := generatePassword(24)
if err != nil {
return "", false, err
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", false, err
}
var userID string
err = db.QueryRow(
ctx,
`
INSERT INTO users (
username,
display_name,
email,
password_hash,
avatar,
unit,
user_group,
rights
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::TEXT[])
RETURNING id
`,
adminUsername,
adminDisplayName,
adminEmail,
string(passwordHash),
adminAvatar,
adminUnit,
adminGroup,
toPostgresTextArray(adminRights),
).Scan(&userID)
if err != nil {
return "", false, err
}
if err := assignUserToTeam(ctx, db, userID, teamID); err != nil {
return "", false, err
}
return password, true, nil
}
func ensureTeam(ctx context.Context, db *pgxpool.Pool, name string, initial string, href string) (string, error) {
name = strings.TrimSpace(name)
initial = strings.TrimSpace(initial)
href = strings.TrimSpace(href)
if name == "" {
name = "Administration"
}
if initial == "" {
initial = strings.ToUpper(string([]rune(name)[0]))
}
if href == "" {
href = "#"
}
var teamID string
err := db.QueryRow(
ctx,
`
INSERT INTO teams (name, initial, href)
VALUES ($1, $2, $3)
ON CONFLICT (name)
DO UPDATE SET
initial = EXCLUDED.initial,
href = EXCLUDED.href,
updated_at = now()
RETURNING id
`,
name,
initial,
href,
).Scan(&teamID)
return teamID, err
}
func assignUserToTeam(ctx context.Context, db *pgxpool.Pool, userID string, teamID string) error {
_, err := db.Exec(
ctx,
`
INSERT INTO user_teams (user_id, team_id)
VALUES ($1, $2)
ON CONFLICT (user_id, team_id) DO NOTHING
`,
userID,
teamID,
)
return err
}
func generatePassword(length int) (string, error) {
randomBytes := make([]byte, length)
if _, err := rand.Read(randomBytes); err != nil {
return "", err
}
password := base64.RawURLEncoding.EncodeToString(randomBytes)
if len(password) > length {
password = password[:length]
}
return password, nil
}
func printAdminResult(password string, created bool) {
adminUsername := env("ADMIN_USERNAME", "admin")
adminEmail := env("ADMIN_EMAIL", "admin@example.com")
adminTeamName := env("ADMIN_TEAM_NAME", "Administration")
fmt.Println("")
if created {
fmt.Println("=================================================")
fmt.Println("Admin-User wurde erstellt")
fmt.Println("=================================================")
fmt.Printf("Benutzername: %s\n", adminUsername)
fmt.Printf("E-Mail: %s\n", adminEmail)
fmt.Printf("Team: %s\n", adminTeamName)
fmt.Printf("Passwort: %s\n", password)
fmt.Println("=================================================")
fmt.Println("Bitte Passwort sicher speichern. Es wird nicht erneut angezeigt.")
fmt.Println("")
return
}
fmt.Println("=================================================")
fmt.Println("Admin-User existiert bereits")
fmt.Println("=================================================")
fmt.Printf("Benutzername: %s\n", adminUsername)
fmt.Printf("E-Mail: %s\n", adminEmail)
fmt.Printf("Team: %s\n", adminTeamName)
fmt.Println("Passwort: wird nicht angezeigt, da nur der Hash gespeichert wird")
fmt.Println("=================================================")
fmt.Println("")
}
func toPostgresTextArray(values []string) string {
if len(values) == 0 {
return "{}"
}
escaped := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `"`, `\"`)
escaped = append(escaped, `"`+value+`"`)
}
if len(escaped) == 0 {
return "{}"
}
return "{" + strings.Join(escaped, ",") + "}"
}
func splitCSV(value string) []string {
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
return result
}
func quoteIdentifier(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
func env(key string, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
func waitForExit() {
fmt.Println("")
fmt.Print("Drücke Enter, um das Setup zu beenden...")
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
}