1292 lines
40 KiB
Go
1292 lines
40 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 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 '',
|
|
location TEXT NOT NULL DEFAULT '',
|
|
loan_status TEXT NOT NULL DEFAULT 'Verfügbar',
|
|
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()
|
|
)
|
|
`,
|
|
|
|
`
|
|
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 '',
|
|
|
|
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`,
|
|
|
|
`
|
|
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_agent_token_encrypted BYTEA,
|
|
|
|
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_agent_token_encrypted BYTEA
|
|
`,
|
|
|
|
`
|
|
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()
|
|
)
|
|
`,
|
|
|
|
// ── 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,
|
|
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`,
|
|
|
|
`
|
|
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()
|
|
)
|
|
`,
|
|
|
|
// 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)`,
|
|
}
|
|
|
|
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')
|
|
}
|