From 2d10af7d3f413f29e2d1ae2ea743ce422620216f Mon Sep 17 00:00:00 2001 From: alex289 Date: Tue, 12 May 2026 14:17:45 +0200 Subject: [PATCH 01/24] feat: Backend notifications --- backend/go.mod | 14 +- backend/go.sum | 50 +- backend/internal/hub/db/db_test.go | 46 ++ ...00016_create_notifications_tables.down.sql | 4 + .../000016_create_notifications_tables.up.sql | 25 + backend/internal/hub/handlers.go | 6 + backend/internal/hub/models/applications.go | 1 + backend/internal/hub/models/notifications.go | 32 + .../hub/notifications/notifications.go | 137 ++++ .../hub/notifications/notifications_test.go | 241 +++++++ .../internal/hub/notifications/provider.go | 30 + .../hub/notifications/provider/common.go | 67 ++ .../hub/notifications/provider/discord.go | 71 +++ .../hub/notifications/provider/provider.go | 29 + backend/internal/hub/routes/notifications.go | 433 +++++++++++++ .../internal/hub/routes/notifications_test.go | 595 ++++++++++++++++++ 16 files changed, 1760 insertions(+), 21 deletions(-) create mode 100644 backend/internal/hub/db/migrations/000016_create_notifications_tables.down.sql create mode 100644 backend/internal/hub/db/migrations/000016_create_notifications_tables.up.sql create mode 100644 backend/internal/hub/models/notifications.go create mode 100644 backend/internal/hub/notifications/notifications.go create mode 100644 backend/internal/hub/notifications/notifications_test.go create mode 100644 backend/internal/hub/notifications/provider.go create mode 100644 backend/internal/hub/notifications/provider/common.go create mode 100644 backend/internal/hub/notifications/provider/discord.go create mode 100644 backend/internal/hub/notifications/provider/provider.go create mode 100644 backend/internal/hub/routes/notifications.go create mode 100644 backend/internal/hub/routes/notifications_test.go diff --git a/backend/go.mod b/backend/go.mod index ce3661f..26f3af3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -15,6 +15,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 + github.com/nicholas-fedor/shoutrrr v0.15.0 github.com/rs/zerolog v1.35.1 github.com/spf13/cobra v1.10.2 golang.org/x/oauth2 v0.36.0 @@ -34,6 +35,7 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/eclipse/paho.golang v0.23.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/in-toto/attestation v1.1.2 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect @@ -106,7 +108,7 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mattn/go-sqlite3 v1.14.37 // indirect @@ -129,7 +131,7 @@ require ( github.com/morikuni/aec v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/quic-go/qpack v0.6.0 // indirect @@ -170,12 +172,12 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/arch v0.25.0 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7e4b264..20392f5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -8,6 +8,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e h1:rd4bOvKmDIx0WeTv9Qz+hghsgyjikFiPrseXHlKepO0= github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e/go.mod h1:blbwPQh4DTlCZEfk1BLU4oMIhLda2U+A840Uag9DsZw= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.14.1 h1:CMuB3fqQVfPdhyXhUqYdUmPUIOhJkmghCx3dJet8Cqs= @@ -134,6 +136,8 @@ github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQ github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= +github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -205,6 +209,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= @@ -230,6 +236,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -257,6 +265,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= +github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= +github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -283,8 +293,8 @@ github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= @@ -339,9 +349,16 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nicholas-fedor/shoutrrr v0.15.0 h1:4gKIev9ucsY50dy+GkkPQKyfIJdKOEwr0dnsVpGdDWk= +github.com/nicholas-fedor/shoutrrr v0.15.0/go.mod h1:Z6b9KNn8q9nXl27/p39zo7iJHBmqkigzVxWnbfwcU8w= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= +github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= +github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -352,8 +369,8 @@ github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22 github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/package-url/packageurl-go v0.1.1 h1:KTRE0bK3sKbFKAk3yy63DpeskU7Cvs/x/Da5l+RtzyU= github.com/package-url/packageurl-go v0.1.1/go.mod h1:uQd4a7Rh3ZsVg5j0lNyAfyxIeGde9yrlhjF78GzeW0c= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -413,6 +430,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -500,20 +519,20 @@ golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -528,21 +547,22 @@ golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/backend/internal/hub/db/db_test.go b/backend/internal/hub/db/db_test.go index cbecde8..6792a08 100644 --- a/backend/internal/hub/db/db_test.go +++ b/backend/internal/hub/db/db_test.go @@ -63,6 +63,8 @@ func TestRunMigrations_AllTablesExist(t *testing.T) { "user_oidc_identities", "repositories", "applications", + "notifications", + "application_notifications", } for _, table := range tables { if !db.Migrator().HasTable(table) { @@ -248,6 +250,50 @@ func TestRunMigrations_ApplicationsTableSchema(t *testing.T) { } } +func TestRunMigrations_NotificationsTableSchema(t *testing.T) { + gormDB := openTestDB(t) + if err := runMigrations(gormDB); err != nil { + t.Fatalf("runMigrations() error: %v", err) + } + + sqlDB, err := gormDB.DB() + if err != nil { + t.Fatalf("failed to get sql.DB: %v", err) + } + cols := columnNames(t, sqlDB, "notifications") + + required := []string{ + "id", "name", "enabled", "enable_by_default", + "status", "type", "config", + "created_at", "updated_at", + } + for _, col := range required { + if !cols[col] { + t.Errorf("notifications table missing column %q", col) + } + } +} + +func TestRunMigrations_ApplicationNotificationsTableSchema(t *testing.T) { + gormDB := openTestDB(t) + if err := runMigrations(gormDB); err != nil { + t.Fatalf("runMigrations() error: %v", err) + } + + sqlDB, err := gormDB.DB() + if err != nil { + t.Fatalf("failed to get sql.DB: %v", err) + } + cols := columnNames(t, sqlDB, "application_notifications") + + required := []string{"application_id", "notification_id"} + for _, col := range required { + if !cols[col] { + t.Errorf("application_notifications table missing column %q", col) + } + } +} + func TestRunMigrations_UserOIDCIdentitiesIndexes(t *testing.T) { gormDB := openTestDB(t) if err := runMigrations(gormDB); err != nil { diff --git a/backend/internal/hub/db/migrations/000016_create_notifications_tables.down.sql b/backend/internal/hub/db/migrations/000016_create_notifications_tables.down.sql new file mode 100644 index 0000000..c888f26 --- /dev/null +++ b/backend/internal/hub/db/migrations/000016_create_notifications_tables.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_application_notifications_notification_id; +DROP INDEX IF EXISTS idx_application_notifications_application_id; +DROP TABLE IF EXISTS application_notifications; +DROP TABLE IF EXISTS notifications; diff --git a/backend/internal/hub/db/migrations/000016_create_notifications_tables.up.sql b/backend/internal/hub/db/migrations/000016_create_notifications_tables.up.sql new file mode 100644 index 0000000..b71a8a4 --- /dev/null +++ b/backend/internal/hub/db/migrations/000016_create_notifications_tables.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + enable_by_default BOOLEAN NOT NULL DEFAULT 0, + status TEXT NOT NULL, + type TEXT NOT NULL, + config TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + updated_at DATETIME NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS application_notifications ( + application_id TEXT NOT NULL, + notification_id TEXT NOT NULL, + PRIMARY KEY (application_id, notification_id), + FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE, + FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_application_notifications_application_id + ON application_notifications (application_id); + +CREATE INDEX IF NOT EXISTS idx_application_notifications_notification_id + ON application_notifications (notification_id); diff --git a/backend/internal/hub/handlers.go b/backend/internal/hub/handlers.go index 46c1311..664189c 100644 --- a/backend/internal/hub/handlers.go +++ b/backend/internal/hub/handlers.go @@ -76,6 +76,12 @@ func RegisterRoutes(router *gin.Engine, cfg Config) error { protected.PUT("/agents/:id", routes.UpdateAgentHandler) protected.DELETE("/agents/:id", routes.DeleteAgentHandler) + protected.POST("/notifications", routes.CreateNotificationHandler) + protected.GET("/notifications", routes.ListNotificationsHandler) + protected.PUT("/notifications/:id", routes.UpdateNotificationHandler) + protected.DELETE("/notifications/:id", routes.DeleteNotificationHandler) + protected.POST("/notifications/:id/test", routes.TestNotificationHandler) + protected.GET("/events", routes.SSEHandler) } diff --git a/backend/internal/hub/models/applications.go b/backend/internal/hub/models/applications.go index 59fb753..ff1d70b 100644 --- a/backend/internal/hub/models/applications.go +++ b/backend/internal/hub/models/applications.go @@ -39,6 +39,7 @@ type Application struct { Path string `gorm:"type:text;not null"` ComposeFile crypto.EncryptedString `gorm:"type:text;not null"` PreviousComposeFile crypto.EncryptedString `gorm:"type:text;not null"` + Notifications []Notification `gorm:"many2many:application_notifications;"` } func (Application) TableName() string { diff --git a/backend/internal/hub/models/notifications.go b/backend/internal/hub/models/notifications.go new file mode 100644 index 0000000..4cf7cc8 --- /dev/null +++ b/backend/internal/hub/models/notifications.go @@ -0,0 +1,32 @@ +package models + +import "github.com/OrcaCD/orca-cd/internal/hub/crypto" + +type NotificationStatus string + +const ( + NotificationHealthy NotificationStatus = "healthy" + NotificationUnhealthy NotificationStatus = "unhealthy" + NotificationUnknownHealth NotificationStatus = "unknown" +) + +type NotificationType string + +const ( + NotificationTypeDiscord NotificationType = "discord" +) + +type Notification struct { + Base + Name crypto.EncryptedString `gorm:"type:text;not null"` + Enabled bool `gorm:"not null"` + EnableByDefault bool `gorm:"not null"` + Status NotificationStatus `gorm:"type:text;not null"` + Type NotificationType `gorm:"type:text;not null"` + Config crypto.EncryptedString `gorm:"type:text;not null"` + Applications []Application `gorm:"many2many:application_notifications;"` +} + +func (Notification) TableName() string { + return "notifications" +} diff --git a/backend/internal/hub/notifications/notifications.go b/backend/internal/hub/notifications/notifications.go new file mode 100644 index 0000000..ec477f5 --- /dev/null +++ b/backend/internal/hub/notifications/notifications.go @@ -0,0 +1,137 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/OrcaCD/orca-cd/internal/hub/db" + "github.com/OrcaCD/orca-cd/internal/hub/models" + "github.com/nicholas-fedor/shoutrrr" + "github.com/rs/zerolog" + "gorm.io/gorm" +) + +const notificationQueryTimeout = 10 * time.Second + +const DefaultTestNotificationMessage = "This is a test notification from OrcaCD." + +var ( + ErrInvalidNotificationConfig = errors.New("invalid notification config") + ErrNotificationDispatch = errors.New("notification dispatch failed") +) + +func SendNotification(applicationId string, message string, log *zerolog.Logger) { + if strings.TrimSpace(message) == "" { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), notificationQueryTimeout) + defer cancel() + + configs, err := getNotificationConfig(ctx, applicationId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + log.Warn().Str("applicationId", applicationId).Msg("application not found while sending notifications") + return + } + log.Error().Err(err).Str("applicationId", applicationId).Msg("failed to load notification config") + return + } + + for i := range configs { + targets, parseErr := BuildShouterrrUrls(configs[i].Type, configs[i].Config.String()) + if parseErr != nil { + log.Error(). + Err(parseErr). + Str("applicationId", applicationId). + Str("notificationId", configs[i].Id). + Msg("failed to parse notification config") + continue + } + + sender, createErr := shoutrrr.CreateSender(targets...) + if createErr != nil { + log.Error(). + Err(createErr). + Str("applicationId", applicationId). + Str("notificationId", configs[i].Id). + Msg("failed to create notification sender") + continue + } + + sendErrs := sender.Send(message, nil) + for _, sendErr := range sendErrs { + if sendErr == nil { + continue + } + log.Error(). + Err(sendErr). + Str("applicationId", applicationId). + Str("notificationId", configs[i].Id). + Msg("failed to send notification") + } + } +} + +func getNotificationConfig(ctx context.Context, applicationId string) ([]models.Notification, error) { + app, err := gorm.G[models.Application](db.DB). + Select("id", "health_status"). + Where("id = ?", applicationId). + First(ctx) + if err != nil { + return nil, err + } + + healthStatus := models.NotificationStatus(app.HealthStatus) + allowedStatuses := []models.NotificationStatus{models.NotificationUnknownHealth, healthStatus} + + var notifications []models.Notification + err = db.DB.WithContext(ctx). + Table("notifications"). + Select("notifications.*"). + Joins("LEFT JOIN application_notifications ON application_notifications.notification_id = notifications.id"). + Where("notifications.enabled = ?", true). + Where("(notifications.enable_by_default = ? OR application_notifications.application_id = ?)", true, applicationId). + Where("notifications.status IN ?", allowedStatuses). + Group("notifications.id"). + Find(¬ifications).Error + if err != nil { + return nil, err + } + + return notifications, nil +} + +func SendTestNotification(notificationType models.NotificationType, rawConfig, message string) error { + targets, err := BuildShouterrrUrls(notificationType, rawConfig) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidNotificationConfig, err) + } + + sender, err := shoutrrr.CreateSender(targets...) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidNotificationConfig, err) + } + + msg := strings.TrimSpace(message) + if msg == "" { + msg = DefaultTestNotificationMessage + } + + sendErrs := sender.Send(msg, nil) + errList := make([]error, 0, len(sendErrs)) + for i := range sendErrs { + if sendErrs[i] != nil { + errList = append(errList, sendErrs[i]) + } + } + + if len(errList) > 0 { + return fmt.Errorf("%w: %w", ErrNotificationDispatch, errors.Join(errList...)) + } + + return nil +} diff --git a/backend/internal/hub/notifications/notifications_test.go b/backend/internal/hub/notifications/notifications_test.go new file mode 100644 index 0000000..102505c --- /dev/null +++ b/backend/internal/hub/notifications/notifications_test.go @@ -0,0 +1,241 @@ +package notifications + +import ( + "context" + "log" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/OrcaCD/orca-cd/internal/hub/crypto" + "github.com/OrcaCD/orca-cd/internal/hub/db" + "github.com/OrcaCD/orca-cd/internal/hub/models" + "github.com/google/uuid" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func setupNotificationsTestDB(t *testing.T) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "test.db") + testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ + Logger: gormlogger.New( + log.New(os.Stderr, "\n", log.LstdFlags), + gormlogger.Config{LogLevel: gormlogger.Warn}, + ), + }) + if err != nil { + t.Fatalf("failed to open test db: %v", err) + } + + sqlDB, err := testDB.DB() + if err != nil { + t.Fatalf("failed to get sql db: %v", err) + } + t.Cleanup(func() { + _ = sqlDB.Close() + db.DB = nil + }) + + db.DB = testDB + + if err := crypto.Init("test-secret-that-is-long-enough-32chars"); err != nil { + t.Fatalf("failed to init crypto: %v", err) + } + + if err := db.DB.AutoMigrate(&models.Repository{}, &models.Agent{}, &models.Application{}, &models.Notification{}); err != nil { + t.Fatalf("failed to migrate models: %v", err) + } +} + +func seedNotificationTestApp(t *testing.T, healthStatus models.HealthStatus) models.Application { + t.Helper() + + repo := models.Repository{ + Name: "Repo", + Url: "https://github.com/orcacd/notifications-test-" + uuid.NewString(), + Provider: models.GitHub, + AuthMethod: models.AuthMethodNone, + SyncType: models.SyncTypeManual, + SyncStatus: models.SyncStatusUnknown, + CreatedBy: "user-1", + } + if err := db.DB.WithContext(t.Context()).Create(&repo).Error; err != nil { + t.Fatalf("failed to create repository: %v", err) + } + + agent := models.Agent{ + Name: crypto.EncryptedString("Agent"), + KeyId: crypto.EncryptedString("agent-key"), + Status: models.AgentStatusOffline, + } + if err := db.DB.WithContext(t.Context()).Create(&agent).Error; err != nil { + t.Fatalf("failed to create agent: %v", err) + } + + app := models.Application{ + Name: crypto.EncryptedString("App"), + RepositoryId: repo.Id, + AgentId: agent.Id, + SyncStatus: models.UnknownSync, + HealthStatus: healthStatus, + Branch: "main", + Commit: "abc123", + CommitMessage: "seed", + Path: "compose.yaml", + ComposeFile: crypto.EncryptedString("services: {}"), + PreviousComposeFile: crypto.EncryptedString(""), + } + if err := db.DB.WithContext(t.Context()).Select("*").Create(&app).Error; err != nil { + t.Fatalf("failed to create app: %v", err) + } + + return app +} + +func seedNotificationRecord(t *testing.T, name string, enabled, enableByDefault bool, status models.NotificationStatus, appIds ...string) models.Notification { + t.Helper() + + notification := models.Notification{ + Name: crypto.EncryptedString(name), + Enabled: enabled, + EnableByDefault: enableByDefault, + Status: status, + Type: models.NotificationTypeDiscord, + Config: crypto.EncryptedString("discord://token@channel"), + } + if err := db.DB.WithContext(t.Context()).Select("*").Create(¬ification).Error; err != nil { + t.Fatalf("failed to create notification: %v", err) + } + + if len(appIds) > 0 { + applications, err := gorm.G[models.Application](db.DB).Where("id IN ?", appIds).Find(t.Context()) + if err != nil { + t.Fatalf("failed to load notification applications: %v", err) + } + if err := db.DB.Model(¬ification).Association("Applications").Replace(applications); err != nil { + t.Fatalf("failed to associate applications: %v", err) + } + } + + return notification +} + +func TestGetNotificationConfig_FiltersByStatusAndAssociation(t *testing.T) { + setupNotificationsTestDB(t) + + app := seedNotificationTestApp(t, models.Healthy) + otherApp := seedNotificationTestApp(t, models.Unhealthy) + + associated := seedNotificationRecord(t, "associated", true, false, models.NotificationHealthy, app.Id) + defaultUnknown := seedNotificationRecord(t, "default-unknown", true, true, models.NotificationUnknownHealth) + seedNotificationRecord(t, "disabled", false, true, models.NotificationHealthy) + seedNotificationRecord(t, "wrong-status", true, true, models.NotificationUnhealthy) + seedNotificationRecord(t, "other-app", true, false, models.NotificationHealthy, otherApp.Id) + + configs, err := getNotificationConfig(context.Background(), app.Id) + if err != nil { + t.Fatalf("getNotificationConfig() error: %v", err) + } + + ids := make([]string, 0, len(configs)) + for i := range configs { + ids = append(ids, configs[i].Id) + } + + if !slices.Contains(ids, associated.Id) { + t.Fatalf("expected associated notification in result, ids=%v", ids) + } + if !slices.Contains(ids, defaultUnknown.Id) { + t.Fatalf("expected default unknown notification in result, ids=%v", ids) + } + if len(ids) != 2 { + t.Fatalf("expected exactly 2 matching notifications, got %d (%v)", len(ids), ids) + } +} + +func TestBuildShouterrrUrls_DirectTargets(t *testing.T) { + tests := []struct { + name string + raw string + expects []string + }{ + { + name: "single raw URL", + raw: "discord://token@channel", + expects: []string{"discord://token@channel"}, + }, + { + name: "comma separated URLs", + raw: "discord://a@1, discord://b@2", + expects: []string{"discord://a@1", "discord://b@2"}, + }, + { + name: "JSON object config", + raw: `{"url":"discord://a@1","urls":["discord://b@2"]}`, + expects: []string{"discord://a@1", "discord://b@2"}, + }, + { + name: "JSON array config", + raw: `["discord://a@1","discord://b@2"]`, + expects: []string{"discord://a@1", "discord://b@2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + urls, err := BuildShouterrrUrls(models.NotificationTypeDiscord, tt.raw) + if err != nil { + t.Fatalf("BuildShouterrrUrls() error: %v", err) + } + if !slices.Equal(urls, tt.expects) { + t.Fatalf("expected %v, got %v", tt.expects, urls) + } + }) + } +} + +func TestBuildShouterrrUrls_DiscordObjectConfig(t *testing.T) { + urls, err := BuildShouterrrUrls(models.NotificationTypeDiscord, `{"token":"token-abc","webhookId":"123456789","threadId":"987654321"}`) + if err != nil { + t.Fatalf("BuildShouterrrUrls() error: %v", err) + } + + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + if !strings.HasPrefix(urls[0], "discord://token-abc@123456789") { + t.Fatalf("expected discord URL, got %s", urls[0]) + } + if !strings.Contains(urls[0], "thread_id=987654321") { + t.Fatalf("expected thread_id in URL, got %s", urls[0]) + } +} + +func TestBuildShouterrrUrls_DiscordObjectConfigMissingFields(t *testing.T) { + _, err := BuildShouterrrUrls(models.NotificationTypeDiscord, `{"webhookId":"123456789"}`) + if err == nil { + t.Fatal("expected error for missing discord token") + } +} + +func TestGetProvider_Registered(t *testing.T) { + provider, err := Get(models.NotificationTypeDiscord) + if err != nil { + t.Fatalf("Get() error: %v", err) + } + if provider == nil { + t.Fatal("expected provider to be non-nil") + } +} + +func TestGetProvider_Unregistered(t *testing.T) { + _, err := Get(models.NotificationType("custom")) + if err == nil { + t.Fatal("expected error for unregistered provider") + } +} diff --git a/backend/internal/hub/notifications/provider.go b/backend/internal/hub/notifications/provider.go new file mode 100644 index 0000000..b7b83e1 --- /dev/null +++ b/backend/internal/hub/notifications/provider.go @@ -0,0 +1,30 @@ +package notifications + +import ( + "github.com/OrcaCD/orca-cd/internal/hub/models" + notificationprovider "github.com/OrcaCD/orca-cd/internal/hub/notifications/provider" +) + +type Provider = notificationprovider.Provider + +func Register(notificationType models.NotificationType, provider Provider) { + notificationprovider.Register(notificationType, provider) +} + +func Get(notificationType models.NotificationType) (Provider, error) { + return notificationprovider.Get(notificationType) +} + +func BuildShouterrrUrls(notificationType models.NotificationType, rawConfig string) ([]string, error) { + provider, err := Get(notificationType) + if err != nil { + return nil, err + } + + return provider.BuildShouterrrUrls(rawConfig) +} + +// BuildShoutrrrURLs remains for backward compatibility with existing call sites. +func BuildShoutrrrURLs(notificationType models.NotificationType, rawConfig string) ([]string, error) { + return BuildShouterrrUrls(notificationType, rawConfig) +} diff --git a/backend/internal/hub/notifications/provider/common.go b/backend/internal/hub/notifications/provider/common.go new file mode 100644 index 0000000..4cbf7ca --- /dev/null +++ b/backend/internal/hub/notifications/provider/common.go @@ -0,0 +1,67 @@ +package provider + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +type directTargetsConfig struct { + URL string `json:"url"` + URLs []string `json:"urls"` +} + +func parseDirectTargets(rawConfig string) ([]string, error) { + trimmed := strings.TrimSpace(rawConfig) + if trimmed == "" { + return nil, errors.New("notification config is empty") + } + + if strings.HasPrefix(trimmed, "{") { + var cfg directTargetsConfig + if err := json.Unmarshal([]byte(trimmed), &cfg); err != nil { + return nil, fmt.Errorf("invalid JSON notification config: %w", err) + } + + return normalizeTargets(append([]string{cfg.URL}, cfg.URLs...)) + } + + if strings.HasPrefix(trimmed, "[") { + var urls []string + if err := json.Unmarshal([]byte(trimmed), &urls); err != nil { + return nil, fmt.Errorf("invalid JSON notification URL list: %w", err) + } + + return normalizeTargets(urls) + } + + parts := strings.FieldsFunc(trimmed, func(r rune) bool { + return r == ',' || r == '\n' || r == '\r' + }) + + return normalizeTargets(parts) +} + +func normalizeTargets(rawTargets []string) ([]string, error) { + targets := make([]string, 0, len(rawTargets)) + seen := make(map[string]struct{}, len(rawTargets)) + + for i := range rawTargets { + target := strings.TrimSpace(rawTargets[i]) + if target == "" { + continue + } + if _, exists := seen[target]; exists { + continue + } + seen[target] = struct{}{} + targets = append(targets, target) + } + + if len(targets) == 0 { + return nil, errors.New("notification config does not contain any targets") + } + + return targets, nil +} diff --git a/backend/internal/hub/notifications/provider/discord.go b/backend/internal/hub/notifications/provider/discord.go new file mode 100644 index 0000000..0cab250 --- /dev/null +++ b/backend/internal/hub/notifications/provider/discord.go @@ -0,0 +1,71 @@ +package provider + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" +) + +type DiscordProvider struct{} + +type discordConfig struct { + Token string `json:"token"` + WebhookID string `json:"webhookId"` + ThreadID string `json:"threadId"` + Username string `json:"username"` + AvatarURL string `json:"avatarUrl"` + Title string `json:"title"` + URL string `json:"url"` + URLs []string `json:"urls"` +} + +func (DiscordProvider) BuildShouterrrUrls(rawConfig string) ([]string, error) { + trimmed := strings.TrimSpace(rawConfig) + if trimmed == "" { + return nil, errors.New("notification config is empty") + } + + if strings.HasPrefix(trimmed, "{") { + var cfg discordConfig + if err := json.Unmarshal([]byte(trimmed), &cfg); err != nil { + return nil, fmt.Errorf("invalid JSON discord config: %w", err) + } + + if cfg.URL != "" || len(cfg.URLs) > 0 { + return normalizeTargets(append([]string{cfg.URL}, cfg.URLs...)) + } + + token := strings.TrimSpace(cfg.Token) + webhookID := strings.TrimSpace(cfg.WebhookID) + if token == "" || webhookID == "" { + return nil, errors.New("discord config requires token and webhookId") + } + + query := url.Values{} + if v := strings.TrimSpace(cfg.ThreadID); v != "" { + query.Set("thread_id", v) + } + if v := strings.TrimSpace(cfg.Username); v != "" { + query.Set("username", v) + } + if v := strings.TrimSpace(cfg.AvatarURL); v != "" { + query.Set("avatarurl", v) + } + if v := strings.TrimSpace(cfg.Title); v != "" { + query.Set("title", v) + } + + discordURL := url.URL{ + Scheme: "discord", + User: url.User(token), + Host: webhookID, + RawQuery: query.Encode(), + } + + return []string{discordURL.String()}, nil + } + + return parseDirectTargets(trimmed) +} diff --git a/backend/internal/hub/notifications/provider/provider.go b/backend/internal/hub/notifications/provider/provider.go new file mode 100644 index 0000000..99d75f3 --- /dev/null +++ b/backend/internal/hub/notifications/provider/provider.go @@ -0,0 +1,29 @@ +package provider + +import ( + "fmt" + + "github.com/OrcaCD/orca-cd/internal/hub/models" +) + +type Provider interface { + BuildShouterrrUrls(rawConfig string) ([]string, error) +} + +var registry = map[models.NotificationType]Provider{} + +func init() { + Register(models.NotificationTypeDiscord, DiscordProvider{}) +} + +func Register(notificationType models.NotificationType, provider Provider) { + registry[notificationType] = provider +} + +func Get(notificationType models.NotificationType) (Provider, error) { + provider, ok := registry[notificationType] + if !ok { + return nil, fmt.Errorf("no provider registered for notification type %q", notificationType) + } + return provider, nil +} diff --git a/backend/internal/hub/routes/notifications.go b/backend/internal/hub/routes/notifications.go new file mode 100644 index 0000000..55ed346 --- /dev/null +++ b/backend/internal/hub/routes/notifications.go @@ -0,0 +1,433 @@ +package routes + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sort" + "strings" + "time" + + "github.com/OrcaCD/orca-cd/internal/hub/crypto" + "github.com/OrcaCD/orca-cd/internal/hub/db" + "github.com/OrcaCD/orca-cd/internal/hub/models" + hubnotifications "github.com/OrcaCD/orca-cd/internal/hub/notifications" + "github.com/OrcaCD/orca-cd/internal/hub/sse" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const NotificationsPath = "/api/v1/notifications" + +type createNotificationRequest struct { + Name string `json:"name" binding:"required,min=1,max=128"` + Enabled *bool `json:"enabled"` + EnableByDefault *bool `json:"enableByDefault"` + Status models.NotificationStatus `json:"status" binding:"required"` + Type models.NotificationType `json:"type" binding:"required"` + Config json.RawMessage `json:"config" binding:"required"` + ApplicationIds []string `json:"applicationIds"` +} + +type updateNotificationRequest struct { + Name string `json:"name" binding:"required,min=1,max=128"` + Enabled *bool `json:"enabled"` + EnableByDefault *bool `json:"enableByDefault"` + Status models.NotificationStatus `json:"status" binding:"required"` + Type models.NotificationType `json:"type" binding:"required"` + Config json.RawMessage `json:"config" binding:"required"` + ApplicationIds []string `json:"applicationIds"` +} + +type testNotificationRequest struct { + Message string `json:"message"` +} + +var sendTestNotification = hubnotifications.SendTestNotification + +type notificationResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + EnableByDefault bool `json:"enableByDefault"` + Status string `json:"status"` + Type string `json:"type"` + Config string `json:"config"` + ApplicationIds []string `json:"applicationIds"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +func ListNotificationsHandler(c *gin.Context) { + items, err := gorm.G[models.Notification](db.DB). + Preload("Applications", nil). + Order("created_at ASC"). + Find(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + response := make([]notificationResponse, 0, len(items)) + for i := range items { + response = append(response, toNotificationResponse(&items[i])) + } + + c.JSON(http.StatusOK, response) +} + +func CreateNotificationHandler(c *gin.Context) { + var req createNotificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: name, status, type and config are required"}) + return + } + + if !isValidNotificationStatus(req.Status) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status: must be healthy, unhealthy, or unknown"}) + return + } + if !isValidNotificationType(req.Type) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid type"}) + return + } + + normalizedConfig, err := normalizeNotificationConfig(req.Config) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if _, err := hubnotifications.BuildShouterrrUrls(req.Type, normalizedConfig); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config: " + err.Error()}) + return + } + + ctx := c.Request.Context() + applications, missingApplicationId, err := loadNotificationApplications(ctx, req.ApplicationIds) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + if missingApplicationId != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "application not found: " + missingApplicationId}) + return + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + enableByDefault := false + if req.EnableByDefault != nil { + enableByDefault = *req.EnableByDefault + } + + notification := models.Notification{ + Name: crypto.EncryptedString(strings.TrimSpace(req.Name)), + Enabled: enabled, + EnableByDefault: enableByDefault, + Status: req.Status, + Type: req.Type, + Config: crypto.EncryptedString(normalizedConfig), + } + + err = db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := gorm.G[models.Notification](tx).Select("*").Create(ctx, ¬ification); err != nil { + return err + } + + if err := tx.Model(¬ification).Association("Applications").Replace(applications); err != nil { + return err + } + + return nil + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + createdNotification, err := getNotificationById(ctx, notification.Id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + c.JSON(http.StatusCreated, toNotificationResponse(&createdNotification)) + sse.PublishUpdate(NotificationsPath) +} + +func UpdateNotificationHandler(c *gin.Context) { + id := c.Param("id") + + var req updateNotificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: name, status, type and config are required"}) + return + } + + if !isValidNotificationStatus(req.Status) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status: must be healthy, unhealthy, or unknown"}) + return + } + if !isValidNotificationType(req.Type) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid type"}) + return + } + + normalizedConfig, err := normalizeNotificationConfig(req.Config) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if _, err := hubnotifications.BuildShouterrrUrls(req.Type, normalizedConfig); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config: " + err.Error()}) + return + } + + ctx := c.Request.Context() + + existingNotification, err := getNotificationById(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + applications, missingApplicationId, err := loadNotificationApplications(ctx, req.ApplicationIds) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + if missingApplicationId != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "application not found: " + missingApplicationId}) + return + } + + enabled := existingNotification.Enabled + if req.Enabled != nil { + enabled = *req.Enabled + } + enableByDefault := existingNotification.EnableByDefault + if req.EnableByDefault != nil { + enableByDefault = *req.EnableByDefault + } + + updates := models.Notification{ + Name: crypto.EncryptedString(strings.TrimSpace(req.Name)), + Enabled: enabled, + EnableByDefault: enableByDefault, + Status: req.Status, + Type: req.Type, + Config: crypto.EncryptedString(normalizedConfig), + } + + err = db.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + rowsAffected, err := gorm.G[models.Notification](tx). + Where("id = ?", id). + Select("name", "enabled", "enable_by_default", "status", "type", "config"). + Updates(ctx, updates) + if err != nil { + return err + } + if rowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + if err := tx.Model(&existingNotification).Association("Applications").Replace(applications); err != nil { + return err + } + + return nil + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + updatedNotification, err := getNotificationById(ctx, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + c.JSON(http.StatusOK, toNotificationResponse(&updatedNotification)) + sse.PublishUpdate(NotificationsPath) +} + +func DeleteNotificationHandler(c *gin.Context) { + id := c.Param("id") + + rowsAffected, err := gorm.G[models.Notification](db.DB).Where("id = ?", id).Delete(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + if rowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "notification deleted"}) + sse.PublishUpdate(NotificationsPath) +} + +func TestNotificationHandler(c *gin.Context) { + id := c.Param("id") + ctx := c.Request.Context() + + notification, err := gorm.G[models.Notification](db.DB).Where("id = ?", id).First(ctx) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + var req testNotificationRequest + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: message must be a string"}) + return + } + } + + if err := sendTestNotification(notification.Type, notification.Config.String(), req.Message); err != nil { + switch { + case errors.Is(err, hubnotifications.ErrInvalidNotificationConfig): + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config: " + err.Error()}) + case errors.Is(err, hubnotifications.ErrNotificationDispatch): + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to send test notification"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"message": "test notification sent"}) +} + +func getNotificationById(ctx context.Context, id string) (models.Notification, error) { + return gorm.G[models.Notification](db.DB). + Preload("Applications", nil). + Where("id = ?", id). + First(ctx) +} + +func toNotificationResponse(notification *models.Notification) notificationResponse { + applicationIds := make([]string, 0, len(notification.Applications)) + for i := range notification.Applications { + applicationIds = append(applicationIds, notification.Applications[i].Id) + } + sort.Strings(applicationIds) + + return notificationResponse{ + Id: notification.Id, + Name: notification.Name.String(), + Enabled: notification.Enabled, + EnableByDefault: notification.EnableByDefault, + Status: string(notification.Status), + Type: string(notification.Type), + Config: notification.Config.String(), + ApplicationIds: applicationIds, + CreatedAt: notification.CreatedAt.Format(time.RFC3339), + UpdatedAt: notification.UpdatedAt.Format(time.RFC3339), + } +} + +func isValidNotificationStatus(status models.NotificationStatus) bool { + switch status { + case models.NotificationHealthy, models.NotificationUnhealthy, models.NotificationUnknownHealth: + return true + default: + return false + } +} + +func isValidNotificationType(notificationType models.NotificationType) bool { + _, err := hubnotifications.Get(notificationType) + return err == nil +} + +func normalizeNotificationConfig(raw json.RawMessage) (string, error) { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed == "null" { + return "", errors.New("invalid config: must be a non-empty string or JSON object") + } + + if strings.HasPrefix(trimmed, "\"") { + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", errors.New("invalid config: expected a valid JSON string") + } + value = strings.TrimSpace(value) + if value == "" { + return "", errors.New("invalid config: must not be empty") + } + return value, nil + } + + if !json.Valid([]byte(trimmed)) { + return "", errors.New("invalid config: expected valid JSON") + } + + return trimmed, nil +} + +func normalizeNotificationApplicationIds(applicationIds []string) []string { + if len(applicationIds) == 0 { + return nil + } + + normalized := make([]string, 0, len(applicationIds)) + seen := make(map[string]struct{}, len(applicationIds)) + + for i := range applicationIds { + id := strings.TrimSpace(applicationIds[i]) + if id == "" { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + normalized = append(normalized, id) + } + + return normalized +} + +func loadNotificationApplications(ctx context.Context, applicationIds []string) ([]models.Application, string, error) { + normalizedIds := normalizeNotificationApplicationIds(applicationIds) + if len(normalizedIds) == 0 { + return []models.Application{}, "", nil + } + + applications, err := gorm.G[models.Application](db.DB).Where("id IN ?", normalizedIds).Find(ctx) + if err != nil { + return nil, "", err + } + if len(applications) != len(normalizedIds) { + foundById := make(map[string]struct{}, len(applications)) + for i := range applications { + foundById[applications[i].Id] = struct{}{} + } + for i := range normalizedIds { + if _, ok := foundById[normalizedIds[i]]; !ok { + return nil, normalizedIds[i], nil + } + } + } + + return applications, "", nil +} diff --git a/backend/internal/hub/routes/notifications_test.go b/backend/internal/hub/routes/notifications_test.go new file mode 100644 index 0000000..939bf3c --- /dev/null +++ b/backend/internal/hub/routes/notifications_test.go @@ -0,0 +1,595 @@ +package routes + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/OrcaCD/orca-cd/internal/hub/crypto" + "github.com/OrcaCD/orca-cd/internal/hub/db" + "github.com/OrcaCD/orca-cd/internal/hub/models" + hubnotifications "github.com/OrcaCD/orca-cd/internal/hub/notifications" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func setupTestDBWithNotifications(t *testing.T) { + t.Helper() + setupTestDB(t) + if err := db.DB.AutoMigrate(&models.Repository{}, &models.Agent{}, &models.Application{}, &models.Notification{}); err != nil { + t.Fatalf("failed to migrate Notification/Repository/Agent/Application: %v", err) + } +} + +func seedNotificationRepository(t *testing.T) models.Repository { + t.Helper() + + repository := models.Repository{ + Name: "Repo", + Url: "https://github.com/orcacd/notifications", + Provider: models.GitHub, + AuthMethod: models.AuthMethodNone, + SyncType: models.SyncTypeManual, + SyncStatus: models.SyncStatusUnknown, + CreatedBy: "user-1", + } + + if err := db.DB.WithContext(t.Context()).Create(&repository).Error; err != nil { + t.Fatalf("failed to seed repository: %v", err) + } + + return repository +} + +func seedNotificationAgent(t *testing.T) models.Agent { + t.Helper() + + agent := models.Agent{ + Name: crypto.EncryptedString("Notifications Agent"), + KeyId: crypto.EncryptedString("notifications-key"), + Status: models.AgentStatusOffline, + } + + if err := db.DB.WithContext(t.Context()).Create(&agent).Error; err != nil { + t.Fatalf("failed to seed agent: %v", err) + } + + return agent +} + +func seedNotificationApplication(t *testing.T, repositoryId, agentId, name string) models.Application { + t.Helper() + + application := models.Application{ + Name: crypto.EncryptedString(name), + RepositoryId: repositoryId, + AgentId: agentId, + SyncStatus: models.UnknownSync, + HealthStatus: models.UnknownHealth, + Branch: "main", + Commit: "abc123", + CommitMessage: "seed commit", + Path: "compose.yaml", + ComposeFile: crypto.EncryptedString("services: {}"), + PreviousComposeFile: crypto.EncryptedString(""), + } + + if err := db.DB.WithContext(t.Context()).Select("*").Create(&application).Error; err != nil { + t.Fatalf("failed to seed application: %v", err) + } + + return application +} + +func createNotificationRecord(t *testing.T, applicationIds []string) models.Notification { + t.Helper() + + notification := models.Notification{ + Name: crypto.EncryptedString("Initial Notification"), + Enabled: true, + EnableByDefault: false, + Status: models.NotificationUnknownHealth, + Type: models.NotificationTypeDiscord, + Config: crypto.EncryptedString("discord://token@channel"), + } + + if err := db.DB.WithContext(t.Context()).Select("*").Create(¬ification).Error; err != nil { + t.Fatalf("failed to create notification: %v", err) + } + + if len(applicationIds) > 0 { + applications, err := gorm.G[models.Application](db.DB).Where("id IN ?", applicationIds).Find(t.Context()) + if err != nil { + t.Fatalf("failed to load applications for notification: %v", err) + } + if err := db.DB.Model(¬ification).Association("Applications").Replace(applications); err != nil { + t.Fatalf("failed to attach applications to notification: %v", err) + } + } + + return notification +} + +func TestListNotificationsHandler_Empty(t *testing.T) { + setupTestDBWithNotifications(t) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/notifications", nil) + + ListNotificationsHandler(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body []notificationResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(body) != 0 { + t.Fatalf("expected 0 notifications, got %d", len(body)) + } +} + +func TestListNotificationsHandler_ReturnsNotifications(t *testing.T) { + setupTestDBWithNotifications(t) + + repo := seedNotificationRepository(t) + agent := seedNotificationAgent(t) + appA := seedNotificationApplication(t, repo.Id, agent.Id, "App A") + appB := seedNotificationApplication(t, repo.Id, agent.Id, "App B") + createNotificationRecord(t, []string{appA.Id, appB.Id}) + createNotificationRecord(t, []string{appB.Id}) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/notifications", nil) + + ListNotificationsHandler(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body []notificationResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(body) != 2 { + t.Fatalf("expected 2 notifications, got %d", len(body)) + } +} + +func TestCreateNotificationHandler_Success(t *testing.T) { + setupTestDBWithNotifications(t) + + repo := seedNotificationRepository(t) + agent := seedNotificationAgent(t) + appA := seedNotificationApplication(t, repo.Id, agent.Id, "App A") + appB := seedNotificationApplication(t, repo.Id, agent.Id, "App B") + + reqBody, _ := json.Marshal(map[string]any{ + "name": "Deploy Alerts", + "enabled": true, + "enableByDefault": false, + "status": "healthy", + "type": "discord", + "config": "discord://token@channel", + "applicationIds": []string{appA.Id, appB.Id}, + }) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/notifications", bytes.NewReader(reqBody)) + c.Request.Header.Set("Content-Type", "application/json") + + CreateNotificationHandler(c) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var body notificationResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if body.Name != "Deploy Alerts" { + t.Fatalf("expected name %q, got %q", "Deploy Alerts", body.Name) + } + if body.Status != "healthy" { + t.Fatalf("expected status %q, got %q", "healthy", body.Status) + } + if body.Type != "discord" { + t.Fatalf("expected type %q, got %q", "discord", body.Type) + } + if len(body.ApplicationIds) != 2 { + t.Fatalf("expected 2 applicationIds, got %d", len(body.ApplicationIds)) + } + if !slices.Contains(body.ApplicationIds, appA.Id) || !slices.Contains(body.ApplicationIds, appB.Id) { + t.Fatalf("response applicationIds missing expected ids: %v", body.ApplicationIds) + } + + stored, err := gorm.G[models.Notification](db.DB).Preload("Applications", nil).Where("id = ?", body.Id).First(t.Context()) + if err != nil { + t.Fatalf("failed to load stored notification: %v", err) + } + if stored.Config.String() != "discord://token@channel" { + t.Fatalf("expected config to roundtrip, got %q", stored.Config.String()) + } + if len(stored.Applications) != 2 { + t.Fatalf("expected 2 associated applications, got %d", len(stored.Applications)) + } +} + +func TestCreateNotificationHandler_InvalidRequest(t *testing.T) { + setupTestDBWithNotifications(t) + + reqBody, _ := json.Marshal(map[string]any{}) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/notifications", bytes.NewReader(reqBody)) + c.Request.Header.Set("Content-Type", "application/json") + + CreateNotificationHandler(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreateNotificationHandler_UnknownApplication(t *testing.T) { + setupTestDBWithNotifications(t) + + reqBody, _ := json.Marshal(map[string]any{ + "name": "Deploy Alerts", + "status": "healthy", + "type": "discord", + "config": "discord://token@channel", + "applicationIds": []string{"missing-app"}, + }) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/notifications", bytes.NewReader(reqBody)) + c.Request.Header.Set("Content-Type", "application/json") + + CreateNotificationHandler(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreateNotificationHandler_DiscordObjectConfigWithThreadID(t *testing.T) { + setupTestDBWithNotifications(t) + + repo := seedNotificationRepository(t) + agent := seedNotificationAgent(t) + appA := seedNotificationApplication(t, repo.Id, agent.Id, "App A") + + reqBody, _ := json.Marshal(map[string]any{ + "name": "Discord Alerts", + "status": "healthy", + "type": "discord", + "config": map[string]any{ + "token": "token-abc", + "webhookId": "123456789", + "threadId": "987654321", + }, + "applicationIds": []string{appA.Id}, + }) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/notifications", bytes.NewReader(reqBody)) + c.Request.Header.Set("Content-Type", "application/json") + + CreateNotificationHandler(c) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var body notificationResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + stored, err := gorm.G[models.Notification](db.DB).Where("id = ?", body.Id).First(t.Context()) + if err != nil { + t.Fatalf("failed to load notification: %v", err) + } + + urls, err := hubnotifications.BuildShoutrrrURLs(stored.Type, stored.Config.String()) + if err != nil { + t.Fatalf("BuildShoutrrrURLs() error: %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + if !strings.Contains(urls[0], "thread_id=987654321") { + t.Fatalf("expected built URL to include thread_id, got %s", urls[0]) + } +} + +func TestCreateNotificationHandler_InvalidDiscordObjectConfig(t *testing.T) { + setupTestDBWithNotifications(t) + + reqBody, _ := json.Marshal(map[string]any{ + "name": "Discord Alerts", + "status": "healthy", + "type": "discord", + "config": map[string]any{ + "webhookId": "123456789", + }, + }) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/notifications", bytes.NewReader(reqBody)) + c.Request.Header.Set("Content-Type", "application/json") + + CreateNotificationHandler(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateNotificationHandler_Success(t *testing.T) { + setupTestDBWithNotifications(t) + + repo := seedNotificationRepository(t) + agent := seedNotificationAgent(t) + appA := seedNotificationApplication(t, repo.Id, agent.Id, "App A") + appB := seedNotificationApplication(t, repo.Id, agent.Id, "App B") + notification := createNotificationRecord(t, []string{appA.Id}) + + reqBody, _ := json.Marshal(map[string]any{ + "name": "Updated Alerts", + "enabled": false, + "enableByDefault": true, + "status": "unhealthy", + "type": "discord", + "config": "discord://new-token@channel", + "applicationIds": []string{appB.Id}, + }) + + router := gin.New() + router.PUT("/api/v1/notifications/:id", UpdateNotificationHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/"+notification.Id, bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body notificationResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if body.Name != "Updated Alerts" { + t.Fatalf("expected name %q, got %q", "Updated Alerts", body.Name) + } + if body.Enabled { + t.Fatal("expected enabled=false") + } + if !body.EnableByDefault { + t.Fatal("expected enableByDefault=true") + } + if body.Status != "unhealthy" { + t.Fatalf("expected status %q, got %q", "unhealthy", body.Status) + } + if len(body.ApplicationIds) != 1 || body.ApplicationIds[0] != appB.Id { + t.Fatalf("expected applicationIds [%s], got %v", appB.Id, body.ApplicationIds) + } + + stored, err := gorm.G[models.Notification](db.DB).Preload("Applications", nil).Where("id = ?", notification.Id).First(t.Context()) + if err != nil { + t.Fatalf("failed to load updated notification: %v", err) + } + if stored.Name.String() != "Updated Alerts" { + t.Fatalf("expected updated name, got %q", stored.Name.String()) + } + if stored.Enabled { + t.Fatal("expected stored enabled=false") + } + if !stored.EnableByDefault { + t.Fatal("expected stored enableByDefault=true") + } + if len(stored.Applications) != 1 || stored.Applications[0].Id != appB.Id { + t.Fatalf("expected stored application association to %s, got %+v", appB.Id, stored.Applications) + } +} + +func TestUpdateNotificationHandler_NotFound(t *testing.T) { + setupTestDBWithNotifications(t) + + reqBody, _ := json.Marshal(map[string]any{ + "name": "Updated Alerts", + "status": "healthy", + "type": "discord", + "config": "discord://token@channel", + }) + + router := gin.New() + router.PUT("/api/v1/notifications/:id", UpdateNotificationHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/missing", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestDeleteNotificationHandler_Success(t *testing.T) { + setupTestDBWithNotifications(t) + + notification := createNotificationRecord(t, nil) + + router := gin.New() + router.DELETE("/api/v1/notifications/:id", DeleteNotificationHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/notifications/"+notification.Id, nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + _, err := gorm.G[models.Notification](db.DB).Where("id = ?", notification.Id).First(t.Context()) + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected deleted notification to be missing, got err=%v", err) + } +} + +func TestDeleteNotificationHandler_NotFound(t *testing.T) { + setupTestDBWithNotifications(t) + + router := gin.New() + router.DELETE("/api/v1/notifications/:id", DeleteNotificationHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/notifications/missing", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestTestNotificationHandler_Success(t *testing.T) { + setupTestDBWithNotifications(t) + + notification := createNotificationRecord(t, nil) + + originalSend := sendTestNotification + t.Cleanup(func() { + sendTestNotification = originalSend + }) + + called := false + sendTestNotification = func(notificationType models.NotificationType, rawConfig, message string) error { + called = true + if notificationType != models.NotificationTypeDiscord { + t.Fatalf("expected discord notification type, got %q", notificationType) + } + if rawConfig == "" { + t.Fatal("expected non-empty notification config") + } + if message != "ping" { + t.Fatalf("expected message %q, got %q", "ping", message) + } + + return nil + } + + reqBody, _ := json.Marshal(map[string]any{"message": "ping"}) + + router := gin.New() + router.POST("/api/v1/notifications/:id/test", TestNotificationHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/"+notification.Id+"/test", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if !called { + t.Fatal("expected test notification sender to be called") + } + + var body map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if body["message"] != "test notification sent" { + t.Fatalf("expected success message, got %q", body["message"]) + } +} + +func TestTestNotificationHandler_NotFound(t *testing.T) { + setupTestDBWithNotifications(t) + + originalSend := sendTestNotification + t.Cleanup(func() { + sendTestNotification = originalSend + }) + sendTestNotification = func(models.NotificationType, string, string) error { + t.Fatal("did not expect sender to be called") + return nil + } + + router := gin.New() + router.POST("/api/v1/notifications/:id/test", TestNotificationHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/missing/test", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestTestNotificationHandler_InvalidRequest(t *testing.T) { + setupTestDBWithNotifications(t) + + notification := createNotificationRecord(t, nil) + + originalSend := sendTestNotification + t.Cleanup(func() { + sendTestNotification = originalSend + }) + sendTestNotification = func(models.NotificationType, string, string) error { + t.Fatal("did not expect sender to be called") + return nil + } + + router := gin.New() + router.POST("/api/v1/notifications/:id/test", TestNotificationHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/"+notification.Id+"/test", strings.NewReader(`{"message":123}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestTestNotificationHandler_DispatchFailure(t *testing.T) { + setupTestDBWithNotifications(t) + + notification := createNotificationRecord(t, nil) + + originalSend := sendTestNotification + t.Cleanup(func() { + sendTestNotification = originalSend + }) + sendTestNotification = func(models.NotificationType, string, string) error { + return hubnotifications.ErrNotificationDispatch + } + + router := gin.New() + router.POST("/api/v1/notifications/:id/test", TestNotificationHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/"+notification.Id+"/test", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d: %s", w.Code, w.Body.String()) + } +} From 7e015e024dc189c756bd5ef20421d05cc4dc0365 Mon Sep 17 00:00:00 2001 From: alex289 Date: Wed, 13 May 2026 14:10:03 +0200 Subject: [PATCH 02/24] feat: Apply review suggestions --- backend/internal/hub/models/notifications.go | 6 +- .../hub/notifications/notifications.go | 35 ++- .../hub/notifications/notifications_test.go | 17 +- backend/internal/hub/routes/notifications.go | 172 ++++++++++---- .../internal/hub/routes/notifications_test.go | 219 ++++++++++++++++-- 5 files changed, 371 insertions(+), 78 deletions(-) diff --git a/backend/internal/hub/models/notifications.go b/backend/internal/hub/models/notifications.go index 4cf7cc8..51e7001 100644 --- a/backend/internal/hub/models/notifications.go +++ b/backend/internal/hub/models/notifications.go @@ -5,9 +5,9 @@ import "github.com/OrcaCD/orca-cd/internal/hub/crypto" type NotificationStatus string const ( - NotificationHealthy NotificationStatus = "healthy" - NotificationUnhealthy NotificationStatus = "unhealthy" - NotificationUnknownHealth NotificationStatus = "unknown" + NotificationStatusUnknown NotificationStatus = "unknown" + NotificationStatusSuccess NotificationStatus = "success" + NotificationStatusError NotificationStatus = "error" ) type NotificationType string diff --git a/backend/internal/hub/notifications/notifications.go b/backend/internal/hub/notifications/notifications.go index ec477f5..85aaa0d 100644 --- a/backend/internal/hub/notifications/notifications.go +++ b/backend/internal/hub/notifications/notifications.go @@ -49,6 +49,7 @@ func SendNotification(applicationId string, message string, log *zerolog.Logger) Str("applicationId", applicationId). Str("notificationId", configs[i].Id). Msg("failed to parse notification config") + setNotificationStatus(configs[i].Id, models.NotificationStatusError, log) continue } @@ -59,35 +60,42 @@ func SendNotification(applicationId string, message string, log *zerolog.Logger) Str("applicationId", applicationId). Str("notificationId", configs[i].Id). Msg("failed to create notification sender") + setNotificationStatus(configs[i].Id, models.NotificationStatusError, log) continue } sendErrs := sender.Send(message, nil) + hasSendError := false for _, sendErr := range sendErrs { if sendErr == nil { continue } + hasSendError = true log.Error(). Err(sendErr). Str("applicationId", applicationId). Str("notificationId", configs[i].Id). Msg("failed to send notification") } + + if hasSendError { + setNotificationStatus(configs[i].Id, models.NotificationStatusError, log) + continue + } + + setNotificationStatus(configs[i].Id, models.NotificationStatusSuccess, log) } } func getNotificationConfig(ctx context.Context, applicationId string) ([]models.Notification, error) { - app, err := gorm.G[models.Application](db.DB). - Select("id", "health_status"). + _, err := gorm.G[models.Application](db.DB). + Select("id"). Where("id = ?", applicationId). First(ctx) if err != nil { return nil, err } - healthStatus := models.NotificationStatus(app.HealthStatus) - allowedStatuses := []models.NotificationStatus{models.NotificationUnknownHealth, healthStatus} - var notifications []models.Notification err = db.DB.WithContext(ctx). Table("notifications"). @@ -95,7 +103,6 @@ func getNotificationConfig(ctx context.Context, applicationId string) ([]models. Joins("LEFT JOIN application_notifications ON application_notifications.notification_id = notifications.id"). Where("notifications.enabled = ?", true). Where("(notifications.enable_by_default = ? OR application_notifications.application_id = ?)", true, applicationId). - Where("notifications.status IN ?", allowedStatuses). Group("notifications.id"). Find(¬ifications).Error if err != nil { @@ -135,3 +142,19 @@ func SendTestNotification(notificationType models.NotificationType, rawConfig, m return nil } + +func setNotificationStatus(notificationId string, status models.NotificationStatus, log *zerolog.Logger) { + ctx, cancel := context.WithTimeout(context.Background(), notificationQueryTimeout) + defer cancel() + + rowsAffected, err := gorm.G[models.Notification](db.DB). + Where("id = ?", notificationId). + Update(ctx, "status", status) + if err != nil { + log.Error().Err(err).Str("notificationId", notificationId).Str("status", string(status)).Msg("failed to update notification status") + return + } + if rowsAffected == 0 { + log.Warn().Str("notificationId", notificationId).Msg("notification not found while updating status") + } +} diff --git a/backend/internal/hub/notifications/notifications_test.go b/backend/internal/hub/notifications/notifications_test.go index 102505c..b0b49d7 100644 --- a/backend/internal/hub/notifications/notifications_test.go +++ b/backend/internal/hub/notifications/notifications_test.go @@ -131,11 +131,11 @@ func TestGetNotificationConfig_FiltersByStatusAndAssociation(t *testing.T) { app := seedNotificationTestApp(t, models.Healthy) otherApp := seedNotificationTestApp(t, models.Unhealthy) - associated := seedNotificationRecord(t, "associated", true, false, models.NotificationHealthy, app.Id) - defaultUnknown := seedNotificationRecord(t, "default-unknown", true, true, models.NotificationUnknownHealth) - seedNotificationRecord(t, "disabled", false, true, models.NotificationHealthy) - seedNotificationRecord(t, "wrong-status", true, true, models.NotificationUnhealthy) - seedNotificationRecord(t, "other-app", true, false, models.NotificationHealthy, otherApp.Id) + associated := seedNotificationRecord(t, "associated", true, false, models.NotificationStatusUnknown, app.Id) + defaultUnknown := seedNotificationRecord(t, "default-unknown", true, true, models.NotificationStatusUnknown) + withErrorStatus := seedNotificationRecord(t, "with-error-status", true, true, models.NotificationStatusError) + seedNotificationRecord(t, "disabled", false, true, models.NotificationStatusSuccess) + seedNotificationRecord(t, "other-app", true, false, models.NotificationStatusSuccess, otherApp.Id) configs, err := getNotificationConfig(context.Background(), app.Id) if err != nil { @@ -153,8 +153,11 @@ func TestGetNotificationConfig_FiltersByStatusAndAssociation(t *testing.T) { if !slices.Contains(ids, defaultUnknown.Id) { t.Fatalf("expected default unknown notification in result, ids=%v", ids) } - if len(ids) != 2 { - t.Fatalf("expected exactly 2 matching notifications, got %d (%v)", len(ids), ids) + if !slices.Contains(ids, withErrorStatus.Id) { + t.Fatalf("expected default error-status notification in result, ids=%v", ids) + } + if len(ids) != 3 { + t.Fatalf("expected exactly 3 matching notifications, got %d (%v)", len(ids), ids) } } diff --git a/backend/internal/hub/routes/notifications.go b/backend/internal/hub/routes/notifications.go index 55ed346..f20679d 100644 --- a/backend/internal/hub/routes/notifications.go +++ b/backend/internal/hub/routes/notifications.go @@ -4,10 +4,13 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "sort" + "strconv" "strings" "time" + "unicode/utf8" "github.com/OrcaCD/orca-cd/internal/hub/crypto" "github.com/OrcaCD/orca-cd/internal/hub/db" @@ -15,29 +18,28 @@ import ( hubnotifications "github.com/OrcaCD/orca-cd/internal/hub/notifications" "github.com/OrcaCD/orca-cd/internal/hub/sse" "github.com/gin-gonic/gin" + "github.com/nicholas-fedor/shoutrrr" "gorm.io/gorm" ) const NotificationsPath = "/api/v1/notifications" type createNotificationRequest struct { - Name string `json:"name" binding:"required,min=1,max=128"` - Enabled *bool `json:"enabled"` - EnableByDefault *bool `json:"enableByDefault"` - Status models.NotificationStatus `json:"status" binding:"required"` - Type models.NotificationType `json:"type" binding:"required"` - Config json.RawMessage `json:"config" binding:"required"` - ApplicationIds []string `json:"applicationIds"` + Name string `json:"name" binding:"required,min=1,max=128"` + Enabled *bool `json:"enabled"` + EnableByDefault *bool `json:"enableByDefault"` + Type models.NotificationType `json:"type" binding:"required"` + Config json.RawMessage `json:"config" binding:"required"` + ApplicationIds []string `json:"applicationIds"` } type updateNotificationRequest struct { - Name string `json:"name" binding:"required,min=1,max=128"` - Enabled *bool `json:"enabled"` - EnableByDefault *bool `json:"enableByDefault"` - Status models.NotificationStatus `json:"status" binding:"required"` - Type models.NotificationType `json:"type" binding:"required"` - Config json.RawMessage `json:"config" binding:"required"` - ApplicationIds []string `json:"applicationIds"` + Name string `json:"name" binding:"required,min=1,max=128"` + Enabled *bool `json:"enabled"` + EnableByDefault *bool `json:"enableByDefault"` + Type models.NotificationType `json:"type" binding:"required"` + Config json.RawMessage `json:"config" binding:"required"` + ApplicationIds []string `json:"applicationIds"` } type testNotificationRequest struct { @@ -53,13 +55,19 @@ type notificationResponse struct { EnableByDefault bool `json:"enableByDefault"` Status string `json:"status"` Type string `json:"type"` - Config string `json:"config"` + Config *string `json:"config,omitempty"` ApplicationIds []string `json:"applicationIds"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } func ListNotificationsHandler(c *gin.Context) { + includeConfig, err := parseIncludeConfigQuery(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + items, err := gorm.G[models.Notification](db.DB). Preload("Applications", nil). Order("created_at ASC"). @@ -71,7 +79,7 @@ func ListNotificationsHandler(c *gin.Context) { response := make([]notificationResponse, 0, len(items)) for i := range items { - response = append(response, toNotificationResponse(&items[i])) + response = append(response, toNotificationResponse(&items[i], includeConfig)) } c.JSON(http.StatusOK, response) @@ -80,14 +88,16 @@ func ListNotificationsHandler(c *gin.Context) { func CreateNotificationHandler(c *gin.Context) { var req createNotificationRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: name, status, type and config are required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: name, type and config are required"}) return } - if !isValidNotificationStatus(req.Status) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status: must be healthy, unhealthy, or unknown"}) + normalizedName, err := normalizeNotificationName(req.Name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if !isValidNotificationType(req.Type) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid type"}) return @@ -98,7 +108,7 @@ func CreateNotificationHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if _, err := hubnotifications.BuildShouterrrUrls(req.Type, normalizedConfig); err != nil { + if err := validateNotificationConfig(req.Type, normalizedConfig); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config: " + err.Error()}) return } @@ -124,10 +134,10 @@ func CreateNotificationHandler(c *gin.Context) { } notification := models.Notification{ - Name: crypto.EncryptedString(strings.TrimSpace(req.Name)), + Name: crypto.EncryptedString(normalizedName), Enabled: enabled, EnableByDefault: enableByDefault, - Status: req.Status, + Status: models.NotificationStatusUnknown, Type: req.Type, Config: crypto.EncryptedString(normalizedConfig), } @@ -154,7 +164,7 @@ func CreateNotificationHandler(c *gin.Context) { return } - c.JSON(http.StatusCreated, toNotificationResponse(&createdNotification)) + c.JSON(http.StatusCreated, toNotificationResponse(&createdNotification, true)) sse.PublishUpdate(NotificationsPath) } @@ -163,14 +173,16 @@ func UpdateNotificationHandler(c *gin.Context) { var req updateNotificationRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: name, status, type and config are required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: name, type and config are required"}) return } - if !isValidNotificationStatus(req.Status) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status: must be healthy, unhealthy, or unknown"}) + normalizedName, err := normalizeNotificationName(req.Name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if !isValidNotificationType(req.Type) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid type"}) return @@ -181,7 +193,7 @@ func UpdateNotificationHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if _, err := hubnotifications.BuildShouterrrUrls(req.Type, normalizedConfig); err != nil { + if err := validateNotificationConfig(req.Type, normalizedConfig); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config: " + err.Error()}) return } @@ -218,10 +230,10 @@ func UpdateNotificationHandler(c *gin.Context) { } updates := models.Notification{ - Name: crypto.EncryptedString(strings.TrimSpace(req.Name)), + Name: crypto.EncryptedString(normalizedName), Enabled: enabled, EnableByDefault: enableByDefault, - Status: req.Status, + Status: models.NotificationStatusUnknown, Type: req.Type, Config: crypto.EncryptedString(normalizedConfig), } @@ -259,7 +271,7 @@ func UpdateNotificationHandler(c *gin.Context) { return } - c.JSON(http.StatusOK, toNotificationResponse(&updatedNotification)) + c.JSON(http.StatusOK, toNotificationResponse(&updatedNotification, true)) sse.PublishUpdate(NotificationsPath) } @@ -295,18 +307,33 @@ func TestNotificationHandler(c *gin.Context) { } var req testNotificationRequest - if c.Request.ContentLength > 0 { - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: message must be a string"}) + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: message must be a string"}) + return + } + + sendErr := sendTestNotification(notification.Type, notification.Config.String(), req.Message) + status := models.NotificationStatusSuccess + if sendErr != nil { + status = models.NotificationStatusError + } + + if err := updateNotificationStatus(ctx, notification.Id, status); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"}) return } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return } - if err := sendTestNotification(notification.Type, notification.Config.String(), req.Message); err != nil { + sse.PublishUpdate(NotificationsPath) + + if sendErr != nil { switch { - case errors.Is(err, hubnotifications.ErrInvalidNotificationConfig): - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config: " + err.Error()}) - case errors.Is(err, hubnotifications.ErrNotificationDispatch): + case errors.Is(sendErr, hubnotifications.ErrInvalidNotificationConfig): + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config: " + sendErr.Error()}) + case errors.Is(sendErr, hubnotifications.ErrNotificationDispatch): c.JSON(http.StatusBadGateway, gin.H{"error": "failed to send test notification"}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) @@ -317,6 +344,20 @@ func TestNotificationHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "test notification sent"}) } +func updateNotificationStatus(ctx context.Context, id string, status models.NotificationStatus) error { + rowsAffected, err := gorm.G[models.Notification](db.DB). + Where("id = ?", id). + Update(ctx, "status", status) + if err != nil { + return err + } + if rowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + return nil +} + func getNotificationById(ctx context.Context, id string) (models.Notification, error) { return gorm.G[models.Notification](db.DB). Preload("Applications", nil). @@ -324,34 +365,31 @@ func getNotificationById(ctx context.Context, id string) (models.Notification, e First(ctx) } -func toNotificationResponse(notification *models.Notification) notificationResponse { +func toNotificationResponse(notification *models.Notification, includeConfig bool) notificationResponse { applicationIds := make([]string, 0, len(notification.Applications)) for i := range notification.Applications { applicationIds = append(applicationIds, notification.Applications[i].Id) } sort.Strings(applicationIds) - return notificationResponse{ + response := notificationResponse{ Id: notification.Id, Name: notification.Name.String(), Enabled: notification.Enabled, EnableByDefault: notification.EnableByDefault, Status: string(notification.Status), Type: string(notification.Type), - Config: notification.Config.String(), ApplicationIds: applicationIds, CreatedAt: notification.CreatedAt.Format(time.RFC3339), UpdatedAt: notification.UpdatedAt.Format(time.RFC3339), } -} -func isValidNotificationStatus(status models.NotificationStatus) bool { - switch status { - case models.NotificationHealthy, models.NotificationUnhealthy, models.NotificationUnknownHealth: - return true - default: - return false + if includeConfig { + config := notification.Config.String() + response.Config = &config } + + return response } func isValidNotificationType(notificationType models.NotificationType) bool { @@ -359,6 +397,46 @@ func isValidNotificationType(notificationType models.NotificationType) bool { return err == nil } +func parseIncludeConfigQuery(c *gin.Context) (bool, error) { + raw := strings.TrimSpace(c.Query("includeConfig")) + if raw == "" { + return false, nil + } + + includeConfig, err := strconv.ParseBool(raw) + if err != nil { + return false, errors.New("invalid includeConfig: must be a boolean") + } + + return includeConfig, nil +} + +func normalizeNotificationName(rawName string) (string, error) { + trimmedName := strings.TrimSpace(rawName) + if trimmedName == "" { + return "", errors.New("invalid name: must not be empty") + } + + if utf8.RuneCountInString(trimmedName) > 128 { + return "", errors.New("invalid name: must be at most 128 characters") + } + + return trimmedName, nil +} + +func validateNotificationConfig(notificationType models.NotificationType, rawConfig string) error { + targets, err := hubnotifications.BuildShouterrrUrls(notificationType, rawConfig) + if err != nil { + return err + } + + if _, err := shoutrrr.CreateSender(targets...); err != nil { + return err + } + + return nil +} + func normalizeNotificationConfig(raw json.RawMessage) (string, error) { trimmed := strings.TrimSpace(string(raw)) if trimmed == "" || trimmed == "null" { diff --git a/backend/internal/hub/routes/notifications_test.go b/backend/internal/hub/routes/notifications_test.go index 939bf3c..3bb3adc 100644 --- a/backend/internal/hub/routes/notifications_test.go +++ b/backend/internal/hub/routes/notifications_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "slices" @@ -93,7 +94,7 @@ func createNotificationRecord(t *testing.T, applicationIds []string) models.Noti Name: crypto.EncryptedString("Initial Notification"), Enabled: true, EnableByDefault: false, - Status: models.NotificationUnknownHealth, + Status: models.NotificationStatusUnknown, Type: models.NotificationTypeDiscord, Config: crypto.EncryptedString("discord://token@channel"), } @@ -162,6 +163,54 @@ func TestListNotificationsHandler_ReturnsNotifications(t *testing.T) { if len(body) != 2 { t.Fatalf("expected 2 notifications, got %d", len(body)) } + + for i := range body { + if body[i].Config != nil { + t.Fatal("expected config to be omitted by default in list response") + } + } +} + +func TestListNotificationsHandler_IncludeConfig(t *testing.T) { + setupTestDBWithNotifications(t) + + repo := seedNotificationRepository(t) + agent := seedNotificationAgent(t) + app := seedNotificationApplication(t, repo.Id, agent.Id, "App A") + createNotificationRecord(t, []string{app.Id}) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/notifications?includeConfig=true", nil) + + ListNotificationsHandler(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body []notificationResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(body) != 1 { + t.Fatalf("expected 1 notification, got %d", len(body)) + } + if body[0].Config == nil || *body[0].Config == "" { + t.Fatal("expected config to be included when includeConfig=true") + } +} + +func TestListNotificationsHandler_InvalidIncludeConfig(t *testing.T) { + setupTestDBWithNotifications(t) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/notifications?includeConfig=not-bool", nil) + + ListNotificationsHandler(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } } func TestCreateNotificationHandler_Success(t *testing.T) { @@ -176,7 +225,6 @@ func TestCreateNotificationHandler_Success(t *testing.T) { "name": "Deploy Alerts", "enabled": true, "enableByDefault": false, - "status": "healthy", "type": "discord", "config": "discord://token@channel", "applicationIds": []string{appA.Id, appB.Id}, @@ -200,12 +248,15 @@ func TestCreateNotificationHandler_Success(t *testing.T) { if body.Name != "Deploy Alerts" { t.Fatalf("expected name %q, got %q", "Deploy Alerts", body.Name) } - if body.Status != "healthy" { - t.Fatalf("expected status %q, got %q", "healthy", body.Status) + if body.Status != string(models.NotificationStatusUnknown) { + t.Fatalf("expected status %q, got %q", models.NotificationStatusUnknown, body.Status) } if body.Type != "discord" { t.Fatalf("expected type %q, got %q", "discord", body.Type) } + if body.Config == nil || *body.Config != "discord://token@channel" { + t.Fatalf("expected config to be returned in create response, got %#v", body.Config) + } if len(body.ApplicationIds) != 2 { t.Fatalf("expected 2 applicationIds, got %d", len(body.ApplicationIds)) } @@ -246,7 +297,6 @@ func TestCreateNotificationHandler_UnknownApplication(t *testing.T) { reqBody, _ := json.Marshal(map[string]any{ "name": "Deploy Alerts", - "status": "healthy", "type": "discord", "config": "discord://token@channel", "applicationIds": []string{"missing-app"}, @@ -263,6 +313,46 @@ func TestCreateNotificationHandler_UnknownApplication(t *testing.T) { } } +func TestCreateNotificationHandler_RejectsWhitespaceOnlyName(t *testing.T) { + setupTestDBWithNotifications(t) + + reqBody, _ := json.Marshal(map[string]any{ + "name": " \t ", + "type": "discord", + "config": "discord://token@channel", + }) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/notifications", bytes.NewReader(reqBody)) + c.Request.Header.Set("Content-Type", "application/json") + + CreateNotificationHandler(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreateNotificationHandler_RejectsInvalidDirectTarget(t *testing.T) { + setupTestDBWithNotifications(t) + + reqBody, _ := json.Marshal(map[string]any{ + "name": "Deploy Alerts", + "type": "discord", + "config": "://", + }) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/notifications", bytes.NewReader(reqBody)) + c.Request.Header.Set("Content-Type", "application/json") + + CreateNotificationHandler(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + func TestCreateNotificationHandler_DiscordObjectConfigWithThreadID(t *testing.T) { setupTestDBWithNotifications(t) @@ -271,9 +361,8 @@ func TestCreateNotificationHandler_DiscordObjectConfigWithThreadID(t *testing.T) appA := seedNotificationApplication(t, repo.Id, agent.Id, "App A") reqBody, _ := json.Marshal(map[string]any{ - "name": "Discord Alerts", - "status": "healthy", - "type": "discord", + "name": "Discord Alerts", + "type": "discord", "config": map[string]any{ "token": "token-abc", "webhookId": "123456789", @@ -318,9 +407,8 @@ func TestCreateNotificationHandler_InvalidDiscordObjectConfig(t *testing.T) { setupTestDBWithNotifications(t) reqBody, _ := json.Marshal(map[string]any{ - "name": "Discord Alerts", - "status": "healthy", - "type": "discord", + "name": "Discord Alerts", + "type": "discord", "config": map[string]any{ "webhookId": "123456789", }, @@ -350,7 +438,6 @@ func TestUpdateNotificationHandler_Success(t *testing.T) { "name": "Updated Alerts", "enabled": false, "enableByDefault": true, - "status": "unhealthy", "type": "discord", "config": "discord://new-token@channel", "applicationIds": []string{appB.Id}, @@ -381,8 +468,11 @@ func TestUpdateNotificationHandler_Success(t *testing.T) { if !body.EnableByDefault { t.Fatal("expected enableByDefault=true") } - if body.Status != "unhealthy" { - t.Fatalf("expected status %q, got %q", "unhealthy", body.Status) + if body.Status != string(models.NotificationStatusUnknown) { + t.Fatalf("expected status %q, got %q", models.NotificationStatusUnknown, body.Status) + } + if body.Config == nil || *body.Config != "discord://new-token@channel" { + t.Fatalf("expected updated config in update response, got %#v", body.Config) } if len(body.ApplicationIds) != 1 || body.ApplicationIds[0] != appB.Id { t.Fatalf("expected applicationIds [%s], got %v", appB.Id, body.ApplicationIds) @@ -411,7 +501,6 @@ func TestUpdateNotificationHandler_NotFound(t *testing.T) { reqBody, _ := json.Marshal(map[string]any{ "name": "Updated Alerts", - "status": "healthy", "type": "discord", "config": "discord://token@channel", }) @@ -429,6 +518,54 @@ func TestUpdateNotificationHandler_NotFound(t *testing.T) { } } +func TestUpdateNotificationHandler_RejectsWhitespaceOnlyName(t *testing.T) { + setupTestDBWithNotifications(t) + + notification := createNotificationRecord(t, nil) + + reqBody, _ := json.Marshal(map[string]any{ + "name": " ", + "type": "discord", + "config": "discord://token@channel", + }) + + router := gin.New() + router.PUT("/api/v1/notifications/:id", UpdateNotificationHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/"+notification.Id, bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateNotificationHandler_RejectsInvalidDirectTarget(t *testing.T) { + setupTestDBWithNotifications(t) + + notification := createNotificationRecord(t, nil) + + reqBody, _ := json.Marshal(map[string]any{ + "name": "Updated Alerts", + "type": "discord", + "config": "://", + }) + + router := gin.New() + router.PUT("/api/v1/notifications/:id", UpdateNotificationHandler) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/"+notification.Id, bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + func TestDeleteNotificationHandler_Success(t *testing.T) { setupTestDBWithNotifications(t) @@ -516,6 +653,50 @@ func TestTestNotificationHandler_Success(t *testing.T) { if body["message"] != "test notification sent" { t.Fatalf("expected success message, got %q", body["message"]) } + + stored, err := gorm.G[models.Notification](db.DB).Where("id = ?", notification.Id).First(t.Context()) + if err != nil { + t.Fatalf("failed to load notification after test send: %v", err) + } + if stored.Status != models.NotificationStatusSuccess { + t.Fatalf("expected status %q after successful test send, got %q", models.NotificationStatusSuccess, stored.Status) + } +} + +func TestTestNotificationHandler_BindsChunkedJSONBody(t *testing.T) { + setupTestDBWithNotifications(t) + + notification := createNotificationRecord(t, nil) + + originalSend := sendTestNotification + t.Cleanup(func() { + sendTestNotification = originalSend + }) + + called := false + sendTestNotification = func(notificationType models.NotificationType, rawConfig, message string) error { + called = true + if message != "chunked-ping" { + t.Fatalf("expected message %q, got %q", "chunked-ping", message) + } + return nil + } + + router := gin.New() + router.POST("/api/v1/notifications/:id/test", TestNotificationHandler) + + w := httptest.NewRecorder() + body := io.LimitReader(strings.NewReader(`{"message":"chunked-ping"}`), 1<<20) + req := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/"+notification.Id+"/test", body) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if !called { + t.Fatal("expected test notification sender to be called") + } } func TestTestNotificationHandler_NotFound(t *testing.T) { @@ -592,4 +773,12 @@ func TestTestNotificationHandler_DispatchFailure(t *testing.T) { if w.Code != http.StatusBadGateway { t.Fatalf("expected 502, got %d: %s", w.Code, w.Body.String()) } + + stored, err := gorm.G[models.Notification](db.DB).Where("id = ?", notification.Id).First(t.Context()) + if err != nil { + t.Fatalf("failed to load notification after test send failure: %v", err) + } + if stored.Status != models.NotificationStatusError { + t.Fatalf("expected status %q after failed test send, got %q", models.NotificationStatusError, stored.Status) + } } From 31e76ff237a02868f81fb12fbd37997cb42df047 Mon Sep 17 00:00:00 2001 From: alex289 Date: Wed, 13 May 2026 14:32:24 +0200 Subject: [PATCH 03/24] feat: Add notifications frontend --- frontend/messages/de.json | 35 ++ frontend/messages/en.json | 35 ++ .../badges/notification-status-badge.tsx | 51 +++ .../dialogs/upsert-notification.tsx | 302 ++++++++++++++++++ frontend/src/components/navbar.tsx | 6 +- .../tables/notifications/columns.tsx | 130 ++++++++ .../tables/notifications/data-table.tsx | 98 ++++++ frontend/src/lib/notifications.ts | 52 +++ frontend/src/routeTree.gen.ts | 22 ++ .../_authenticated/notifications/index.tsx | 205 ++++++++++++ 10 files changed, 935 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/badges/notification-status-badge.tsx create mode 100644 frontend/src/components/dialogs/upsert-notification.tsx create mode 100644 frontend/src/components/tables/notifications/columns.tsx create mode 100644 frontend/src/components/tables/notifications/data-table.tsx create mode 100644 frontend/src/lib/notifications.ts create mode 100644 frontend/src/routes/_authenticated/notifications/index.tsx diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 10efefa..bb63345 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -57,6 +57,7 @@ "searchUsers": "Benutzer suchen...", "searchApplications": "Anwendungen suchen...", "searchAgents": "Agenten suchen...", + "searchNotifications": "Benachrichtigungen suchen...", "noResults": "Keine Ergebnisse.", "noApplicationsFound": "Keine Anwendungen gefunden", "noApplicationsFoundDescription": "Passe deine Suche an, um passende Anwendungen zu sehen.", @@ -64,8 +65,11 @@ "noAgentsFoundDescription": "Passe deine Suche an, um passende Agenten zu sehen.", "noRepositoriesFound": "Keine Repositories gefunden", "noRepositoriesFoundDescription": "Passe deine Suche an, um passende Repositories zu sehen.", + "noNotificationsFound": "Keine Benachrichtigungen gefunden", + "noNotificationsFoundDescription": "Passe deine Suche an, um passende Benachrichtigungen zu sehen.", "totalAgentsCount": "Es gibt insgesamt {count} Agenten", "totalRepositoriesCount": "Es gibt insgesamt {count} Repositories", + "totalNotificationsCount": "Es gibt insgesamt {count} Benachrichtigungen", "appsCount": "Anzahl Apps", "notAvailableShort": "k. A.", "cardActions": "Kartenaktionen", @@ -73,6 +77,8 @@ "deleteAgentCardDescription": "Dadurch wird \"{name}\" dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", "deleteRepositoryTitle": "Repository löschen?", "deleteRepositoryDescription": "Dadurch wird \"{name}\" dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteNotificationTitle": "Benachrichtigung löschen?", + "deleteNotificationDescription": "Dadurch wird \"{name}\" dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", "statusOnline": "Online", "statusOffline": "Offline", "statusError": "Fehler", @@ -86,6 +92,7 @@ "navApplications": "Anwendungen", "navAgents": "Agenten", "navRepositories": "Repositories", + "navNotifications": "Benachrichtigungen", "navAdmin": "Admin", "hotkeysDialogTitle": "Tastenkürzel", "hotkeysDialogDescription": "Navigiere schneller mit diesen Kürzeln.", @@ -127,6 +134,9 @@ "pageRepositories": "Repositories", "repositoriesPageDescription": "Verbundene Git-Repositories verwalten", "loadingRepositories": "Repositories werden geladen...", + "pageNotifications": "Benachrichtigungen", + "notificationsPageDescription": "Verwalte Benachrichtigungsendpunkte und teste die Zustellung.", + "loadingNotifications": "Benachrichtigungen werden geladen...", "pageAgents": "Agenten", "agentsPageDescription": "Docker-Hosts und Server verwalten", "loadingAgents": "Agenten werden geladen...", @@ -217,6 +227,7 @@ "selectRepository": "Repository auswählen", "selectAgent": "Agent auswählen", "branch": "Branch", + "selectType": "Typ auswählen", "selectBranch": "Branch auswählen", "selectRepositoryFirst": "Zuerst ein Repository auswählen", "selectBranchFirst": "Zuerst einen Branch auswählen", @@ -232,6 +243,21 @@ "applicationCreated": "Anwendung erstellt", "failedSaveApplication": "Anwendung konnte nicht gespeichert werden", "updateApplication": "Anwendung aktualisieren", + "addNotification": "Benachrichtigung hinzufügen", + "editNotification": "Benachrichtigung bearbeiten", + "addNotificationDescription": "Erstelle einen neuen Benachrichtigungsendpunkt.", + "editNotificationDescription": "Aktualisiere die Benachrichtigungskonfiguration.", + "notificationNamePlaceholder": "z. B. \"deploy-alerts\"", + "notificationConfig": "Konfiguration", + "notificationConfigPlaceholder": "discord://token@webhook oder JSON-Objekt", + "notificationEnabledDescription": "Sende Benachrichtigungen, wenn Ereignisse ausgelöst werden.", + "enableByDefault": "Standardmäßig aktivieren", + "enableByDefaultDescription": "Wende diese Benachrichtigung automatisch auf neue Anwendungen an.", + "noApplicationsAvailable": "Keine Anwendungen verfügbar.", + "notificationUpdated": "Benachrichtigung aktualisiert", + "notificationCreated": "Benachrichtigung erstellt", + "failedSaveNotification": "Benachrichtigung konnte nicht gespeichert werden", + "updateNotification": "Benachrichtigung aktualisieren", "validationApplicationNameRequired": "Name ist erforderlich", "validationApplicationNameMaxLength": "Name darf maximal 128 Zeichen lang sein", "validationRepositoryRequired": "Repository ist erforderlich", @@ -241,6 +267,9 @@ "validationPathRequired": "Pfad ist erforderlich", "validationPathMaxLength": "Pfad darf maximal 512 Zeichen lang sein", "validationPathMustBeYAML": "Pfad muss auf .yml oder .yaml enden", + "validationNotificationNameRequired": "Name ist erforderlich", + "validationNotificationNameMaxLength": "Name darf maximal 128 Zeichen lang sein", + "validationNotificationConfigRequired": "Konfiguration ist erforderlich", "editProvider": "Anbieter bearbeiten", "addProvider": "Anbieter hinzufügen", "addProviderDescription": "Konfiguriere einen neuen OpenID-Connect-Anbieter.", @@ -324,11 +353,17 @@ "you": "Du", "providerPassword": "Passwort", "repositoryDeleted": "Repository {name} erfolgreich gelöscht", + "notificationDeleted": "Benachrichtigung {name} erfolgreich gelöscht", "failedDeleteRepository": "Repository konnte nicht gelöscht werden", + "failedDeleteNotification": "Benachrichtigung konnte nicht gelöscht werden", "failedDeleteAgent": "Agent konnte nicht gelöscht werden", + "testNotificationSent": "Testbenachrichtigung gesendet", + "failedSendTestNotification": "Testbenachrichtigung konnte nicht gesendet werden", + "sendTest": "Test senden", "agentDeleted": "Agent {name} erfolgreich gelöscht", "columnName": "Name", "columnStatus": "Status", + "columnType": "Typ", "columnAppsCount": "Anzahl Apps", "columnLastSeen": "Zuletzt gesehen", "columnUpdatedAt": "Aktualisiert am", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 39ac7cb..83185ab 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -57,6 +57,7 @@ "searchUsers": "Search users...", "searchApplications": "Search applications...", "searchAgents": "Search agents...", + "searchNotifications": "Search notifications...", "noResults": "No results.", "noApplicationsFound": "No applications found", "noApplicationsFoundDescription": "Adjust your search to see matching applications.", @@ -64,8 +65,11 @@ "noAgentsFoundDescription": "Adjust your search to see matching agents.", "noRepositoriesFound": "No repositories found", "noRepositoriesFoundDescription": "Adjust your search to see matching repositories.", + "noNotificationsFound": "No notifications found", + "noNotificationsFoundDescription": "Adjust your search to see matching notifications.", "totalAgentsCount": "There are {count} agents in total", "totalRepositoriesCount": "There are {count} repositories in total", + "totalNotificationsCount": "There are {count} notifications in total", "appsCount": "Apps count", "notAvailableShort": "n/a", "cardActions": "Card actions", @@ -73,6 +77,8 @@ "deleteAgentCardDescription": "This will permanently delete \"{name}\". This action cannot be undone.", "deleteRepositoryTitle": "Delete repository?", "deleteRepositoryDescription": "This will permanently delete \"{name}\". This action cannot be undone.", + "deleteNotificationTitle": "Delete notification?", + "deleteNotificationDescription": "This will permanently delete \"{name}\". This action cannot be undone.", "statusOnline": "Online", "statusOffline": "Offline", "statusError": "Error", @@ -86,6 +92,7 @@ "navApplications": "Applications", "navAgents": "Agents", "navRepositories": "Repositories", + "navNotifications": "Notifications", "navAdmin": "Admin", "hotkeysDialogTitle": "Keyboard Shortcuts", "hotkeysDialogDescription": "Navigate quickly with these shortcuts.", @@ -127,6 +134,9 @@ "pageRepositories": "Repositories", "repositoriesPageDescription": "Manage connected Git repositories", "loadingRepositories": "Loading repositories...", + "pageNotifications": "Notifications", + "notificationsPageDescription": "Manage notification endpoints and test delivery.", + "loadingNotifications": "Loading notifications...", "pageAgents": "Agents", "agentsPageDescription": "Manage Docker hosts and servers", "loadingAgents": "Loading agents...", @@ -217,6 +227,7 @@ "selectRepository": "Select a repository", "selectAgent": "Select an agent", "branch": "Branch", + "selectType": "Select type", "selectBranch": "Select a branch", "selectRepositoryFirst": "Select a repository first", "selectBranchFirst": "Select a branch first", @@ -232,6 +243,21 @@ "applicationCreated": "Application created", "failedSaveApplication": "Failed to save application", "updateApplication": "Update Application", + "addNotification": "Add Notification", + "editNotification": "Edit Notification", + "addNotificationDescription": "Create a new notification endpoint.", + "editNotificationDescription": "Update the notification configuration.", + "notificationNamePlaceholder": "e.g. \"deploy-alerts\"", + "notificationConfig": "Config", + "notificationConfigPlaceholder": "discord://token@webhook or JSON object", + "notificationEnabledDescription": "Send notifications when events are emitted.", + "enableByDefault": "Enable By Default", + "enableByDefaultDescription": "Apply this notification automatically to new applications.", + "noApplicationsAvailable": "No applications available.", + "notificationUpdated": "Notification updated", + "notificationCreated": "Notification created", + "failedSaveNotification": "Failed to save notification", + "updateNotification": "Update Notification", "validationApplicationNameRequired": "Name is required", "validationApplicationNameMaxLength": "Name must be at most 128 characters", "validationRepositoryRequired": "Repository is required", @@ -241,6 +267,9 @@ "validationPathRequired": "Path is required", "validationPathMaxLength": "Path must be at most 512 characters", "validationPathMustBeYAML": "Path must end with .yml or .yaml", + "validationNotificationNameRequired": "Name is required", + "validationNotificationNameMaxLength": "Name must be at most 128 characters", + "validationNotificationConfigRequired": "Config is required", "editProvider": "Edit Provider", "addProvider": "Add Provider", "addProviderDescription": "Configure a new OpenID Connect provider.", @@ -324,11 +353,17 @@ "you": "You", "providerPassword": "Password", "repositoryDeleted": "Repository {name} deleted successfully", + "notificationDeleted": "Notification {name} deleted successfully", "failedDeleteRepository": "Failed to delete repository", + "failedDeleteNotification": "Failed to delete notification", "failedDeleteAgent": "Failed to delete agent", + "testNotificationSent": "Test notification sent", + "failedSendTestNotification": "Failed to send test notification", + "sendTest": "Send Test", "agentDeleted": "Agent {name} deleted successfully", "columnName": "Name", "columnStatus": "Status", + "columnType": "Type", "columnAppsCount": "Apps Count", "columnLastSeen": "Last Seen", "columnUpdatedAt": "Updated At", diff --git a/frontend/src/components/badges/notification-status-badge.tsx b/frontend/src/components/badges/notification-status-badge.tsx new file mode 100644 index 0000000..2d80651 --- /dev/null +++ b/frontend/src/components/badges/notification-status-badge.tsx @@ -0,0 +1,51 @@ +import type { NotificationStatus } from "@/lib/notifications"; +import { m } from "@/lib/paraglide/messages"; +import { Badge } from "../ui/badge"; + +function getStatusLabel(status: NotificationStatus): string { + switch (status) { + case "success": + return m.successAlertTitle(); + case "error": + return m.statusError(); + case "healthy": + return m.statusHealthy(); + case "unhealthy": + return m.statusUnhealthy(); + default: + return m.unknown(); + } +} + +function getStatusDotClass(status: NotificationStatus): string { + switch (status) { + case "success": + return "bg-emerald-500"; + case "error": + return "bg-red-500"; + case "healthy": + return "bg-emerald-500"; + case "unhealthy": + return "bg-amber-500"; + default: + return "bg-zinc-400"; + } +} + +export function NotificationStatusBadge({ status }: { status: NotificationStatus }) { + const badgeVariant = + status === "success" || status === "healthy" + ? "success" + : status === "error" + ? "destructive" + : "secondary"; + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/dialogs/upsert-notification.tsx b/frontend/src/components/dialogs/upsert-notification.tsx new file mode 100644 index 0000000..23a8f43 --- /dev/null +++ b/frontend/src/components/dialogs/upsert-notification.tsx @@ -0,0 +1,302 @@ +// oxlint-disable react/no-children-prop +import { Pencil, Plus } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "@tanstack/react-form"; +import { z } from "zod"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { Field, FieldError, FieldGroup } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useFetch } from "@/lib/api"; +import type { ApplicationListItem } from "@/lib/applications"; +import { + createNotification, + type Notification, + type NotificationType, + updateNotification, +} from "@/lib/notifications"; +import { m } from "@/lib/paraglide/messages"; + +const notificationSchema = z.object({ + name: z + .string() + .trim() + .min(1, m.validationNotificationNameRequired()) + .max(128, m.validationNotificationNameMaxLength()), + type: z.enum(["discord"]), + config: z.string().trim().min(1, m.validationNotificationConfigRequired()), + enabled: z.boolean(), + enableByDefault: z.boolean(), + applicationIds: z.array(z.string()), +}); + +function normalizeIds(ids: string[]): string[] { + return Array.from(new Set(ids)); +} + +export default function UpsertNotificationDialog({ + notification, + asDropdownItem = false, +}: { + notification: Notification | null; + asDropdownItem?: boolean; +}) { + const isEditing = notification !== null; + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data: applications, isLoading: isLoadingApplications } = + useFetch("/applications"); + + const form = useForm({ + defaultValues: { + name: notification?.name ?? "", + type: (notification?.type ?? "discord") as NotificationType, + config: notification?.config ?? "", + enabled: notification?.enabled ?? true, + enableByDefault: notification?.enableByDefault ?? false, + applicationIds: notification?.applicationIds ?? [], + }, + validators: { + onSubmit: notificationSchema, + }, + onSubmit: async ({ value }) => { + setIsSubmitting(true); + try { + const payload = { + name: value.name, + type: value.type, + config: value.config, + enabled: value.enabled, + enableByDefault: value.enableByDefault, + applicationIds: normalizeIds(value.applicationIds), + }; + + if (notification) { + await updateNotification(notification.id, payload); + toast.success(m.notificationUpdated()); + } else { + await createNotification(payload); + toast.success(m.notificationCreated()); + } + + setOpen(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : m.failedSaveNotification()); + } finally { + setIsSubmitting(false); + } + }, + }); + + return ( + + + {asDropdownItem ? ( + e.preventDefault()}> + + {m.edit()} + + ) : ( + + )} + + + + + {isEditing ? m.editNotification() : m.addNotification()} + + {isEditing ? m.editNotificationDescription() : m.addNotificationDescription()} + + + +
{ + e.preventDefault(); + await form.handleSubmit(); + }} + > + + { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + + return ( + + + field.handleChange(event.target.value)} + placeholder={m.notificationNamePlaceholder()} + autoFocus + /> + {isInvalid && } + + ); + }} + /> + + ( + + + + + )} + /> + + { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + + return ( + + +