From 7983cfb405464db860b8b2fd886cbbd88901c095 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Tue, 14 Apr 2026 10:09:19 +0200 Subject: [PATCH] feat: add phase 4 notifications service and in-app center --- package-lock.json | 10 + package.json | 1 + src-tauri/Cargo.lock | 486 ++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/notification.rs | 35 ++ src-tauri/src/lib.rs | 4 + src-tauri/src/models/mod.rs | 1 + src-tauri/src/models/notification.rs | 151 ++++++ src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/notifier.rs | 123 +++++ src-tauri/src/services/orchestrator.rs | 64 ++- src-tauri/src/services/poller.rs | 25 +- src/components/layout/AppLayout.tsx | 6 + src/components/layout/NotificationCenter.tsx | 198 ++++++++ src/lib/api.ts | 15 + src/lib/types.ts | 11 + 18 files changed, 1118 insertions(+), 18 deletions(-) create mode 100644 src-tauri/src/commands/notification.rs create mode 100644 src-tauri/src/models/notification.rs create mode 100644 src-tauri/src/services/notifier.rs create mode 100644 src/components/layout/NotificationCenter.tsx diff --git a/package-lock.json b/package-lock.json index 9872af5..d8ff6b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.7.0", + "@tauri-apps/plugin-notification": "^2.3.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -1667,6 +1668,15 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index 58aad10..0e9561f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.7.0", + "@tauri-apps/plugin-notification": "^2.3.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4fe72c9..9c2bf82 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -94,6 +94,137 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -189,6 +320,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.2" @@ -381,6 +525,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -806,6 +959,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -833,6 +1013,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -997,6 +1198,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1429,6 +1643,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2008,6 +2228,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2172,6 +2404,20 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify-rust" +version = "4.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b2c9bc1689653cfbc04400b8719f2562638ff9c545bbd48cc58c657a14526df" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2411,11 +2657,22 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tempfile", "tokio", "uuid", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2441,6 +2698,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2663,6 +2926,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2677,7 +2951,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2695,6 +2969,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2815,6 +3103,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -2870,6 +3167,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2890,6 +3197,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2908,6 +3225,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -4032,6 +4358,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.4", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -4132,6 +4477,18 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4464,9 +4821,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4516,6 +4885,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5456,6 +5836,9 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" @@ -5658,6 +6041,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -5743,3 +6187,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8ade337..d140b66 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" +tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e895c6b..b75b73f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", - "dialog:default" + "dialog:default", + "notification:default" ] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6f50de7..62c2e6c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod credential; +pub mod notification; pub mod orchestrator; pub mod poller; pub mod project; diff --git a/src-tauri/src/commands/notification.rs b/src-tauri/src/commands/notification.rs new file mode 100644 index 0000000..12be37a --- /dev/null +++ b/src-tauri/src/commands/notification.rs @@ -0,0 +1,35 @@ +use crate::error::AppError; +use crate::models::notification::Notification; +use crate::AppState; +use tauri::State; + +#[tauri::command] +pub fn list_notifications( + state: State<'_, AppState>, + project_id: String, + unread_only: bool, +) -> Result, AppError> { + let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; + let notifications = Notification::list_by_project(&conn, &project_id, unread_only)?; + Ok(notifications) +} + +#[tauri::command] +pub fn mark_notification_read( + state: State<'_, AppState>, + id: String, +) -> Result<(), AppError> { + let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; + Notification::mark_read(&conn, &id)?; + Ok(()) +} + +#[tauri::command] +pub fn mark_all_notifications_read( + state: State<'_, AppState>, + project_id: String, +) -> Result { + let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; + let count = Notification::mark_all_read(&conn, &project_id)?; + Ok(count) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7858471..2717cd0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ pub struct AppState { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) .setup(|app| { let db_dir = app.path().app_data_dir()?; std::fs::create_dir_all(&db_dir)?; @@ -70,6 +71,9 @@ pub fn run() { commands::tracker::list_processed_tickets, commands::poller::manual_poll, commands::poller::get_queue_status, + commands::notification::list_notifications, + commands::notification::mark_notification_read, + commands::notification::mark_all_notifications_read, commands::orchestrator::get_ticket_result, commands::orchestrator::retry_ticket, commands::orchestrator::cancel_ticket, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 4499f10..d7e4ff0 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod credential; +pub mod notification; pub mod project; pub mod ticket; pub mod tracker; diff --git a/src-tauri/src/models/notification.rs b/src-tauri/src/models/notification.rs new file mode 100644 index 0000000..0d1d4be --- /dev/null +++ b/src-tauri/src/models/notification.rs @@ -0,0 +1,151 @@ +use rusqlite::{params, Connection, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Notification { + pub id: String, + pub project_id: String, + pub ticket_id: Option, + pub notification_type: String, + pub title: String, + pub message: String, + pub read: bool, + pub created_at: String, +} + +fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + let read_int: i32 = row.get(6)?; + Ok(Notification { + id: row.get(0)?, + project_id: row.get(1)?, + ticket_id: row.get(2)?, + notification_type: row.get(3)?, + title: row.get(4)?, + message: row.get(5)?, + read: read_int != 0, + created_at: row.get(7)?, + }) +} + +impl Notification { + pub fn insert( + conn: &Connection, + project_id: &str, + ticket_id: Option<&str>, + notification_type: &str, + title: &str, + message: &str, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO notifications (id, project_id, ticket_id, type, title, message, read, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7)", + params![id, project_id, ticket_id, notification_type, title, message, now], + )?; + + Ok(Notification { + id, + project_id: project_id.to_string(), + ticket_id: ticket_id.map(String::from), + notification_type: notification_type.to_string(), + title: title.to_string(), + message: message.to_string(), + read: false, + created_at: now, + }) + } + + pub fn list_by_project( + conn: &Connection, + project_id: &str, + unread_only: bool, + ) -> Result> { + let sql = if unread_only { + "SELECT id, project_id, ticket_id, type, title, message, read, created_at \ + FROM notifications WHERE project_id = ?1 AND read = 0 ORDER BY created_at DESC" + } else { + "SELECT id, project_id, ticket_id, type, title, message, read, created_at \ + FROM notifications WHERE project_id = ?1 ORDER BY created_at DESC" + }; + + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map(params![project_id], from_row)?; + rows.collect() + } + + pub fn mark_read(conn: &Connection, id: &str) -> Result<()> { + conn.execute("UPDATE notifications SET read = 1 WHERE id = ?1", params![id])?; + Ok(()) + } + + pub fn mark_all_read(conn: &Connection, project_id: &str) -> Result { + let affected = conn.execute( + "UPDATE notifications SET read = 1 WHERE project_id = ?1 AND read = 0", + params![project_id], + )?; + Ok(affected) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::models::project::Project; + + fn setup() -> (Connection, String) { + let conn = db::init_in_memory().expect("db init should succeed"); + let project = Project::insert(&conn, "P", "/tmp/p", None, "main").unwrap(); + (conn, project.id) + } + + #[test] + fn test_insert_and_list() { + let (conn, project_id) = setup(); + + let n = Notification::insert( + &conn, + &project_id, + None, + "NewTicket", + "Ticket #1", + "Nouveau ticket detecte", + ) + .unwrap(); + + assert_eq!(n.notification_type, "NewTicket"); + assert!(!n.read); + + let list = Notification::list_by_project(&conn, &project_id, false).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, n.id); + } + + #[test] + fn test_mark_read() { + let (conn, project_id) = setup(); + + let n = Notification::insert(&conn, &project_id, None, "Error", "Oops", "Erreur").unwrap(); + Notification::mark_read(&conn, &n.id).unwrap(); + + let unread = Notification::list_by_project(&conn, &project_id, true).unwrap(); + assert_eq!(unread.len(), 0); + } + + #[test] + fn test_mark_all_read() { + let (conn, project_id) = setup(); + + Notification::insert(&conn, &project_id, None, "NewTicket", "A", "B").unwrap(); + Notification::insert(&conn, &project_id, None, "FixReady", "C", "D").unwrap(); + + let affected = Notification::mark_all_read(&conn, &project_id).unwrap(); + assert_eq!(affected, 2); + + let unread = Notification::list_by_project(&conn, &project_id, true).unwrap(); + assert_eq!(unread.len(), 0); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index af6a5bf..f1d12a5 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod crypto; pub mod filter_engine; +pub mod notifier; pub mod orchestrator; pub mod poller; pub mod tuleap_client; diff --git a/src-tauri/src/services/notifier.rs b/src-tauri/src/services/notifier.rs new file mode 100644 index 0000000..caae74a --- /dev/null +++ b/src-tauri/src/services/notifier.rs @@ -0,0 +1,123 @@ +use crate::models::notification::Notification; +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter}; + +pub fn create_and_emit( + db: &Arc>, + app_handle: &AppHandle, + project_id: &str, + ticket_id: Option<&str>, + notification_type: &str, + title: &str, + message: &str, +) -> Result { + let notification = { + let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; + Notification::insert( + &conn, + project_id, + ticket_id, + notification_type, + title, + message, + ) + .map_err(|e| format!("Failed to insert notification: {}", e))? + }; + + let _ = app_handle.emit( + "new-notification", + serde_json::json!({ + "notification": notification, + }), + ); + + Ok(notification) +} + +pub fn notify_new_ticket( + db: &Arc>, + app_handle: &AppHandle, + project_id: &str, + ticket_id: &str, + artifact_id: i32, + artifact_title: &str, +) { + let title = format!("Nouveau ticket #{}", artifact_id); + let message = artifact_title.to_string(); + let _ = create_and_emit( + db, + app_handle, + project_id, + Some(ticket_id), + "NewTicket", + &title, + &message, + ); +} + +pub fn notify_analysis_done( + db: &Arc>, + app_handle: &AppHandle, + project_id: &str, + ticket_id: &str, + artifact_id: i32, +) { + let title = format!("Analyse terminee #{}", artifact_id); + let message = "Aucune correction de code necessaire".to_string(); + let _ = create_and_emit( + db, + app_handle, + project_id, + Some(ticket_id), + "AnalysisDone", + &title, + &message, + ); +} + +pub fn notify_fix_ready( + db: &Arc>, + app_handle: &AppHandle, + project_id: &str, + ticket_id: &str, + artifact_id: i32, +) { + let title = format!("Fix pret #{}", artifact_id); + let message = "Un correctif est disponible pour revue".to_string(); + let _ = create_and_emit( + db, + app_handle, + project_id, + Some(ticket_id), + "FixReady", + &title, + &message, + ); +} + +pub fn notify_error( + db: &Arc>, + app_handle: &AppHandle, + project_id: &str, + ticket_id: &str, + artifact_id: i32, + error: &str, +) { + let title = format!("Erreur ticket #{}", artifact_id); + let message = if error.is_empty() { + "Erreur lors du traitement du ticket".to_string() + } else { + error.to_string() + }; + + let _ = create_and_emit( + db, + app_handle, + project_id, + Some(ticket_id), + "Error", + &title, + &message, + ); +} diff --git a/src-tauri/src/services/orchestrator.rs b/src-tauri/src/services/orchestrator.rs index fbcfdc7..7de7669 100644 --- a/src-tauri/src/services/orchestrator.rs +++ b/src-tauri/src/services/orchestrator.rs @@ -2,7 +2,7 @@ use crate::models::project::Project; use crate::models::ticket::ProcessedTicket; use crate::models::tracker::WatchedTracker; use crate::models::worktree::Worktree; -use crate::services::worktree_manager; +use crate::services::{notifier, worktree_manager}; use rusqlite::Connection; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter}; @@ -205,6 +205,15 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> Err(e) => { let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e); + drop(conn); + notifier::notify_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + &e, + ); let _ = app_handle.emit( "ticket-processing-error", serde_json::json!({ "ticket_id": ticket.id, "error": e }), @@ -228,6 +237,13 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> "ticket-processing-done", serde_json::json!({ "ticket_id": ticket.id }), ); + notifier::notify_analysis_done( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + ); return Ok(true); } @@ -239,14 +255,28 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> } } - let (wt_path, branch_name) = - worktree_manager::create_worktree(&project.path, &project.base_branch, ticket.artifact_id) - .inspect_err(|e| { - let conn = db.lock().ok(); - if let Some(conn) = conn { - let _ = ProcessedTicket::set_error(&conn, &ticket.id, e); - } - })?; + let worktree_result = + worktree_manager::create_worktree(&project.path, &project.base_branch, ticket.artifact_id); + + if let Err(e) = &worktree_result { + if let Ok(conn) = db.lock() { + let _ = ProcessedTicket::set_error(&conn, &ticket.id, e); + } + notifier::notify_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + e, + ); + let _ = app_handle.emit( + "ticket-processing-error", + serde_json::json!({ "ticket_id": ticket.id, "error": e }), + ); + } + + let (wt_path, branch_name) = worktree_result?; { let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; @@ -283,6 +313,15 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> Err(e) => { let conn = db.lock().map_err(|e2| format!("DB lock: {}", e2))?; let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e); + drop(conn); + notifier::notify_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + &e, + ); let _ = app_handle.emit( "ticket-processing-error", serde_json::json!({ "ticket_id": ticket.id, "error": e }), @@ -303,6 +342,13 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> "ticket-processing-done", serde_json::json!({ "ticket_id": ticket.id }), ); + notifier::notify_fix_ready( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + ); Ok(true) } diff --git a/src-tauri/src/services/poller.rs b/src-tauri/src/services/poller.rs index 66c0c55..a7d98a8 100644 --- a/src-tauri/src/services/poller.rs +++ b/src-tauri/src/services/poller.rs @@ -1,7 +1,7 @@ use crate::models::credential::TuleapCredentials; use crate::models::ticket::ProcessedTicket; use crate::models::tracker::WatchedTracker; -use crate::services::{crypto, filter_engine}; +use crate::services::{crypto, filter_engine, notifier}; use crate::services::tuleap_client::TuleapClient; use rusqlite::Connection; use std::sync::{Arc, Mutex}; @@ -115,7 +115,7 @@ async fn poll_single_tracker( let filtered = filter_engine::apply_filters(&artifacts, &tracker.filters); // 3. Insert new tickets and update last_polled_at - let new_count = { + let new_tickets = { let conn = match db.lock() { Ok(c) => c, Err(e) => { @@ -124,7 +124,7 @@ async fn poll_single_tracker( } }; - let mut count = 0usize; + let mut inserted = Vec::new(); for artifact in &filtered { let artifact_id = artifact @@ -148,7 +148,7 @@ async fn poll_single_tracker( &artifact_title, &artifact_data, ) { - Ok(Some(_)) => count += 1, + Ok(Some(ticket)) => inserted.push(ticket), Ok(None) => {} Err(e) => { eprintln!("poller: failed to insert ticket (artifact {}): {}", artifact_id, e); @@ -161,20 +161,31 @@ async fn poll_single_tracker( eprintln!("poller: failed to update last_polled_at for tracker {}: {}", tracker.id, e); } - count + inserted }; // lock released // 5. Emit event if new tickets found - if new_count > 0 { + if !new_tickets.is_empty() { if let Err(e) = app_handle.emit( "new-tickets-detected", serde_json::json!({ "tracker_id": tracker.id, "tracker_label": tracker.tracker_label, - "count": new_count, + "count": new_tickets.len(), }), ) { eprintln!("poller: failed to emit event: {}", e); } + + for ticket in &new_tickets { + notifier::notify_new_ticket( + db, + app_handle, + &tracker.project_id, + &ticket.id, + ticket.artifact_id, + &ticket.artifact_title, + ); + } } } diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 7703abd..61cda31 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -1,4 +1,5 @@ import { Outlet } from "react-router-dom"; +import NotificationCenter from "./NotificationCenter"; import Sidebar from "./Sidebar"; export default function AppLayout() { @@ -6,6 +7,11 @@ export default function AppLayout() {
+
+
+ +
+
diff --git a/src/components/layout/NotificationCenter.tsx b/src/components/layout/NotificationCenter.tsx new file mode 100644 index 0000000..49f08b1 --- /dev/null +++ b/src/components/layout/NotificationCenter.tsx @@ -0,0 +1,198 @@ +import { listen } from "@tauri-apps/api/event"; +import { + isPermissionGranted, + requestPermission, + sendNotification, +} from "@tauri-apps/plugin-notification"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + listNotifications, + markAllNotificationsRead, + markNotificationRead, +} from "../../lib/api"; +import type { OrchaiNotification } from "../../lib/types"; + +type NewNotificationEvent = { + notification: OrchaiNotification; +}; + +async function showSystemNotification(notification: OrchaiNotification) { + try { + let permissionGranted = await isPermissionGranted(); + if (!permissionGranted) { + const permission = await requestPermission(); + permissionGranted = permission === "granted"; + } + + if (permissionGranted) { + sendNotification({ + title: notification.title, + body: notification.message, + }); + } + } catch { + // Best effort only + } +} + +export default function NotificationCenter() { + const navigate = useNavigate(); + const { projectId } = useParams(); + const [open, setOpen] = useState(false); + const [notifications, setNotifications] = useState([]); + + async function loadNotifications() { + if (!projectId) { + setNotifications([]); + return; + } + + try { + const items = await listNotifications(projectId, false); + setNotifications(items); + } catch { + // Ignore load errors in layout chrome + } + } + + useEffect(() => { + loadNotifications(); + }, [projectId]); + + useEffect(() => { + let unlisten: (() => void) | null = null; + + const setup = async () => { + unlisten = await listen("new-notification", (event) => { + const incoming = event.payload.notification; + + if (projectId && incoming.project_id !== projectId) { + return; + } + + setNotifications((prev) => { + const withoutDuplicate = prev.filter((n) => n.id !== incoming.id); + return [incoming, ...withoutDuplicate]; + }); + + void showSystemNotification(incoming); + }); + }; + + void setup(); + + return () => { + if (unlisten) { + unlisten(); + } + }; + }, [projectId]); + + const unreadCount = useMemo( + () => notifications.filter((n) => !n.read).length, + [notifications] + ); + + async function handleOpenNotification(notification: OrchaiNotification) { + if (!notification.read) { + try { + await markNotificationRead(notification.id); + setNotifications((prev) => + prev.map((n) => (n.id === notification.id ? { ...n, read: true } : n)) + ); + } catch { + // ignore + } + } + + setOpen(false); + + if (notification.ticket_id) { + navigate(`/tickets/${notification.ticket_id}`); + return; + } + + navigate(`/projects/${notification.project_id}`); + } + + async function handleMarkAllRead() { + if (!projectId) { + return; + } + + try { + await markAllNotificationsRead(projectId); + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + } catch { + // ignore + } + } + + return ( +
+ + + {open && ( +
+
+

Notifications

+ +
+ +
+ {notifications.length === 0 ? ( +
+ No notifications. +
+ ) : ( + notifications.map((notification) => ( + + )) + )} +
+
+ )} +
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 414a1c6..3cab990 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -9,6 +9,7 @@ import type { ProcessedTicket, Worktree, TicketResult, + OrchaiNotification, } from "./types"; export async function createProject( @@ -112,3 +113,17 @@ export async function deleteWorktreeCmd(worktreeId: string): Promise { export async function listLocalBranches(projectId: string): Promise { return invoke("list_local_branches", { projectId }); } + +// Notifications +export async function listNotifications( + projectId: string, + unreadOnly: boolean +): Promise { + return invoke("list_notifications", { projectId, unreadOnly }); +} +export async function markNotificationRead(id: string): Promise { + return invoke("mark_notification_read", { id }); +} +export async function markAllNotificationsRead(projectId: string): Promise { + return invoke("mark_all_notifications_read", { projectId }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 238f630..41d6c0f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -85,3 +85,14 @@ export interface TicketResult { ticket: ProcessedTicket; worktree: Worktree | null; } + +export interface OrchaiNotification { + id: string; + project_id: string; + ticket_id: string | null; + notification_type: string; + title: string; + message: string; + read: boolean; + created_at: string; +}