feat: add phase 4 notifications service and in-app center

This commit is contained in:
thibaud-leclere 2026-04-14 10:09:19 +02:00
parent 512e502f9b
commit 7983cfb405
18 changed files with 1118 additions and 18 deletions

10
package-lock.json generated
View file

@ -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",

View file

@ -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",

486
src-tauri/Cargo.lock generated
View file

@ -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",
]

View file

@ -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"] }

View file

@ -5,6 +5,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default"
"dialog:default",
"notification:default"
]
}

View file

@ -1,4 +1,5 @@
pub mod credential;
pub mod notification;
pub mod orchestrator;
pub mod poller;
pub mod project;

View file

@ -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<Vec<Notification>, 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<usize, AppError> {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let count = Notification::mark_all_read(&conn, &project_id)?;
Ok(count)
}

View file

@ -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,

View file

@ -1,4 +1,5 @@
pub mod credential;
pub mod notification;
pub mod project;
pub mod ticket;
pub mod tracker;

View file

@ -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<String>,
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<Notification> {
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<Notification> {
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<Vec<Notification>> {
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<usize> {
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);
}
}

View file

@ -1,5 +1,6 @@
pub mod crypto;
pub mod filter_engine;
pub mod notifier;
pub mod orchestrator;
pub mod poller;
pub mod tuleap_client;

View file

@ -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<Mutex<Connection>>,
app_handle: &AppHandle,
project_id: &str,
ticket_id: Option<&str>,
notification_type: &str,
title: &str,
message: &str,
) -> Result<Notification, String> {
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<Mutex<Connection>>,
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<Mutex<Connection>>,
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<Mutex<Connection>>,
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<Mutex<Connection>>,
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,
);
}

View file

@ -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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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)
}

View file

@ -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,
);
}
}
}

View file

@ -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() {
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-y-auto bg-gray-50">
<header className="sticky top-0 z-10 border-b border-gray-200 bg-gray-50/95 px-6 py-3 backdrop-blur">
<div className="flex justify-end">
<NotificationCenter />
</div>
</header>
<Outlet />
</main>
</div>

View file

@ -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<OrchaiNotification[]>([]);
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<NewNotificationEvent>("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 (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="relative rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
Notifications
{unreadCount > 0 && (
<span className="ml-2 rounded-full bg-red-600 px-1.5 py-0.5 text-xs font-semibold text-white">
{unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 z-20 mt-2 w-[360px] rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2">
<h3 className="text-sm font-semibold text-gray-800">Notifications</h3>
<button
type="button"
onClick={handleMarkAllRead}
className="text-xs text-blue-600 hover:underline"
disabled={!projectId || unreadCount === 0}
>
Mark all read
</button>
</div>
<div className="max-h-[420px] overflow-y-auto">
{notifications.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-gray-400">
No notifications.
</div>
) : (
notifications.map((notification) => (
<button
key={notification.id}
type="button"
onClick={() => void handleOpenNotification(notification)}
className={`block w-full border-b border-gray-100 px-3 py-2 text-left hover:bg-gray-50 ${
notification.read ? "bg-white" : "bg-blue-50/40"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-gray-900">
{notification.title}
</span>
{!notification.read && (
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-600" />
)}
</div>
<p className="mt-0.5 line-clamp-2 text-xs text-gray-600">
{notification.message}
</p>
<p className="mt-1 text-[11px] text-gray-400">
{new Date(notification.created_at).toLocaleString()}
</p>
</button>
))
)}
</div>
</div>
)}
</div>
);
}

View file

@ -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<void> {
export async function listLocalBranches(projectId: string): Promise<string[]> {
return invoke("list_local_branches", { projectId });
}
// Notifications
export async function listNotifications(
projectId: string,
unreadOnly: boolean
): Promise<OrchaiNotification[]> {
return invoke("list_notifications", { projectId, unreadOnly });
}
export async function markNotificationRead(id: string): Promise<void> {
return invoke("mark_notification_read", { id });
}
export async function markAllNotificationsRead(projectId: string): Promise<number> {
return invoke("mark_all_notifications_read", { projectId });
}

View file

@ -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;
}