diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index caa161d..3d30bfd 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -26,8 +26,27 @@ jobs: sleep 15 sudo netbird status + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Run clippy (json) + run: | + cargo clippy --workspace --all-targets --all-features \ + --message-format=json > clippy.json || true + - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + + - name: SonarQube Quality Gate + uses: SonarSource/sonarqube-quality-gate-action@v1 + timeout-minutes: 5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 22f8f9c..d310fd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1830,6 +1830,7 @@ dependencies = [ "tokio", "tracing", "uuid", + "waha", ] [[package]] @@ -3623,6 +3624,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "waha" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest", + "serde", + "thiserror 2.0.17", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index cdc57b6..4605e2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = [ "libs/observability", "libs/use_case/biteship", "libs/errors" -] + , "libs/use_case/waha"] resolver = "2" #[build] @@ -27,4 +27,4 @@ anyhow = "1.0" reqwest = { version = "0.13", features = ["json"] } async-trait = "0.1" lapin = {version = "3.7"} -lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls"] } \ No newline at end of file +lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls"] } diff --git a/libs/use_case/waha/Cargo.toml b/libs/use_case/waha/Cargo.toml new file mode 100644 index 0000000..66dd872 --- /dev/null +++ b/libs/use_case/waha/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "waha" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde.workspace = true +anyhow.workspace = true +reqwest.workspace = true +thiserror.workspace = true \ No newline at end of file diff --git a/libs/use_case/waha/src/dto.rs b/libs/use_case/waha/src/dto.rs new file mode 100644 index 0000000..7bed724 --- /dev/null +++ b/libs/use_case/waha/src/dto.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SendTextReq { + pub chat_id: String, + pub text: String, + pub session: String, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct WahaClientErrorResponse { + pub message: String, + pub status_code: i32, +} diff --git a/libs/use_case/waha/src/error.rs b/libs/use_case/waha/src/error.rs new file mode 100644 index 0000000..b9d68d7 --- /dev/null +++ b/libs/use_case/waha/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum WahaError { + #[error("Unauthorized")] + Unauthorized, + + #[error("Client Error: {0}")] + ClientError(String), + + #[error("Internal Server Waha Error")] + InternalServerError(#[from] anyhow::Error), +} diff --git a/libs/use_case/waha/src/lib.rs b/libs/use_case/waha/src/lib.rs new file mode 100644 index 0000000..2a46ba5 --- /dev/null +++ b/libs/use_case/waha/src/lib.rs @@ -0,0 +1,79 @@ +mod dto; +mod error; + +use crate::dto::{SendTextReq, WahaClientErrorResponse}; +use crate::error::WahaError; +use reqwest::header; +use std::env; + +pub struct WahaUseCase { + client: reqwest::Client, + base_url: String, + api_key: String, + session: String, +} + +impl WahaUseCase { + pub fn new(client: reqwest::Client) -> Self { + let base_url = env::var("WAHA_API_URL").expect("BITESHIP_API_URL must be set"); + let api_key = env::var("WAHA_API_KEY").expect("BITESHIP_API_KEY must be set"); + let session = env::var("WAHA_SESSION").expect("WAHA_SESSION must be set"); + Self { + client, + base_url, + api_key, + session, + } + } + + pub async fn send_text(&self, id: String, text: String) -> Result<(), WahaError> { + let url = format!("{}/api/sendText", self.base_url); + + let payload = SendTextReq { + chat_id: format!("{}@c.us", id), + text, + session: self.session.clone(), + }; + + let resp = self + .client + .post(url) + .header(header::CONTENT_TYPE, "application/json") + .header("X-Api-Key", self.api_key.as_str()) + .header(header::ACCEPT, "application/json") + .json(&payload) + .send() + .await + .map_err(|e| WahaError::InternalServerError(e.into()))?; + + self.handle_response(resp).await + } + + async fn handle_response(&self, resp: reqwest::Response) -> Result<(), WahaError> { + if resp.status().is_server_error() { + let waha_err = resp + .json::() + .await + .map_err(|e| WahaError::InternalServerError(anyhow::anyhow!(e)))?; + + return Err(WahaError::InternalServerError(anyhow::anyhow!( + waha_err.message + ))); + } + + if resp.status().is_client_error() { + let waha_err = resp + .json::() + .await + .map_err(|e| WahaError::InternalServerError(anyhow::anyhow!(e)))?; + + if waha_err.status_code == 401 { + return Err(WahaError::Unauthorized); + } + + return Err(WahaError::ClientError(waha_err.message)); + } + + Ok(()) + } +} diff --git a/schema.sql b/schema.sql index 75eec24..b22deb0 100644 --- a/schema.sql +++ b/schema.sql @@ -150,6 +150,9 @@ VALUES ('biteship', 'confirmed', 'CREATED'), ALTER TABLE status_mappings RENAME COLUMN courier_code TO platform; +ALTER TABLE shipments + ADD COLUMN user_id UUID REFERENCES users (id); + -- Dummy user data INSERT INTO users (id, name, phone_number, email, created_at) VALUES @@ -167,3 +170,15 @@ INSERT INTO shipment_subscriptions (id, user_id, shipment_id, subscribed_statuse VALUES ('850e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440000', 'e975bb9f-c0b5-4fe5-a20d-e34eba31dafa', '{OUT_FOR_DELIVERY,DELIVERED,FAILED}', 'Electronics Order', '2024-01-16 09:15:00+00', '2024-01-16 09:15:00+00'), ('850e8400-e29b-41d4-a716-446655440001', '550e8400-e29b-41d4-a716-446655440001', 'e975bb9f-c0b5-4fe5-a20d-e34eba31dafa', '{DELIVERED,FAILED, RETURNED}', 'Books Order', '2024-01-22 16:15:00+00', '2024-01-22 16:15:00+00'); + +SELECT id, + waybill_id, + courier_code, + source, + current_status, + order_id, + external_order_ref, + created_at, + updated_at +FROM shipments +WHERE user_id = '550e8400-e29b-41d4-a716-446655440006'; \ No newline at end of file diff --git a/services/notification-service/Cargo.toml b/services/notification-service/Cargo.toml index 58a0540..fb5fcf2 100644 --- a/services/notification-service/Cargo.toml +++ b/services/notification-service/Cargo.toml @@ -13,6 +13,7 @@ dotenvy.workspace = true observability = { path = "../../libs/observability" } tracing.workspace = true config = { path = "../../libs/config" } +waha = { path = "../../libs/use_case/waha" } async-trait.workspace = true uuid.workspace = true reqwest.workspace = true diff --git a/services/notification-service/src/handler.rs b/services/notification-service/src/handler.rs index 1d54e63..2aee77c 100644 --- a/services/notification-service/src/handler.rs +++ b/services/notification-service/src/handler.rs @@ -14,7 +14,7 @@ impl NotificationHandler { pub async fn handle(&self, event: &TrackingEventMsg) -> anyhow::Result<()> { let template = self.resolve_template(event)?; - let (content, subject) = self.sender.render(template, &event.payload)?; + let (content, subject) = self.sender.render(template, &mut event.payload.clone())?; self.sender.send(event, content, subject).await?; diff --git a/services/notification-service/src/ports/email.rs b/services/notification-service/src/ports/email.rs index 6e1dbbd..b75eb3d 100644 --- a/services/notification-service/src/ports/email.rs +++ b/services/notification-service/src/ports/email.rs @@ -53,7 +53,7 @@ impl ChannelPort for EmailSmtpSender { fn render( &self, template_id: TemplateId, - data: &TrackingMsgPayload, + data: &mut TrackingMsgPayload, ) -> anyhow::Result<(String, String)> { let mut handlebars = Handlebars::new(); diff --git a/services/notification-service/src/ports/mod.rs b/services/notification-service/src/ports/mod.rs index a2ef610..5eaf2c0 100644 --- a/services/notification-service/src/ports/mod.rs +++ b/services/notification-service/src/ports/mod.rs @@ -22,6 +22,6 @@ pub trait ChannelPort: Send + Sync { fn render( &self, template_id: TemplateId, - data: &TrackingMsgPayload, + data: &mut TrackingMsgPayload, ) -> anyhow::Result<(String, String)>; } diff --git a/services/notification-service/src/ports/telegram.rs b/services/notification-service/src/ports/telegram.rs index 30f4951..55c36b3 100644 --- a/services/notification-service/src/ports/telegram.rs +++ b/services/notification-service/src/ports/telegram.rs @@ -30,7 +30,7 @@ impl ChannelPort for TelegramSender { fn render( &self, template_id: TemplateId, - data: &TrackingMsgPayload, + data: &mut TrackingMsgPayload, ) -> anyhow::Result<(String, String)> { todo!() } diff --git a/services/notification-service/src/ports/whatsapp.rs b/services/notification-service/src/ports/whatsapp.rs index 9c241cd..c681976 100644 --- a/services/notification-service/src/ports/whatsapp.rs +++ b/services/notification-service/src/ports/whatsapp.rs @@ -1,15 +1,19 @@ use crate::domain::{TemplateId, TrackingEventMsg, TrackingMsgPayload}; use crate::ports::ChannelPort; +use anyhow::anyhow; use config::reqwest::get_reqwest_pool; -use reqwest::Client; +use handlebars::Handlebars; +use waha::WahaUseCase; pub struct WhatsappSender { - client: Client, + client: WahaUseCase, } impl WhatsappSender { pub fn new() -> Self { - let client = get_reqwest_pool().expect("Failed to create reqwest pool"); + let client_http = get_reqwest_pool().expect("Failed to create reqwest pool"); + + let client = WahaUseCase::new(client_http); Self { client } } @@ -21,17 +25,39 @@ impl ChannelPort for WhatsappSender { &self, event: &TrackingEventMsg, content: String, - subject: String, + _subject: String, ) -> anyhow::Result<()> { - tracing::info!("sending tracking event to whatsapp"); - todo!() + self.client + .send_text(event.recipient.clone(), content) + .await?; + + Ok(()) } fn render( &self, template_id: TemplateId, - data: &TrackingMsgPayload, + data: &mut TrackingMsgPayload, ) -> anyhow::Result<(String, String)> { - todo!() + let mut handlebars = Handlebars::new(); + + let template = match template_id { + TemplateId::TrackingCreatedWa => ( + "templates/tracking_added/chat.stub", + "Your Shipment Is On Tracking", + ), + _ => return Err(anyhow!("invalid template")), + }; + + let root = env!("CARGO_MANIFEST_DIR"); + let path = std::path::Path::new(root).join(template.0); + + handlebars.register_template_file("chat", path)?; + + data.courier = data.courier.to_uppercase(); + data.status = data.status.to_uppercase(); + let rendered = handlebars.render("chat", data)?; + + Ok((rendered, template.1.to_string())) } } diff --git a/services/notification-service/templates/tracking_added/chat.stub b/services/notification-service/templates/tracking_added/chat.stub new file mode 100644 index 0000000..9442f04 --- /dev/null +++ b/services/notification-service/templates/tracking_added/chat.stub @@ -0,0 +1,12 @@ +📦 *Pengiriman Berhasil Ditambahkan* + +Halo, + +Nomor resi *{{waybill_id}}* dari *{{courier}}* sudah berhasil ditambahkan ke sistem tracking kami. + +📍 Status Saat Ini: +_*{{status}}*_ + +Kami akan mengirimkan notifikasi otomatis setiap ada update status pengiriman Anda. + +Terima kasih telah menggunakan layanan kami. \ No newline at end of file diff --git a/services/tracking-service/src/handlers/tracking.rs b/services/tracking-service/src/handlers/tracking.rs index 37598e7..58219c6 100644 --- a/services/tracking-service/src/handlers/tracking.rs +++ b/services/tracking-service/src/handlers/tracking.rs @@ -1,18 +1,18 @@ -use std::sync::Arc; use crate::app::AppState; use crate::models::dto::AddTrackingRequest; +use axum::Json; use axum::extract::rejection::JsonRejection; -use axum::extract::State; +use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; -use axum::Json; use errors::error::HttpError; +use std::sync::Arc; +use uuid::Uuid; pub async fn create_shipments( State(handler): State>, payload: Result, JsonRejection>, ) -> Result { - let Json(data) = payload?; let res = handler.service.add_track(&data).await?; @@ -20,16 +20,38 @@ pub async fn create_shipments( Ok(res) } -pub async fn get_shipments() -> impl IntoResponse { - (StatusCode::OK, "ok") +pub async fn get_shipments( + State(handler): State>, +) -> Result { + let res = handler.service.get_shipments().await?; + + Ok((StatusCode::OK, Json(res))) } -pub async fn get_shipment_by_id() -> impl IntoResponse { - (StatusCode::OK, "ok") +pub async fn get_shipment_by_id( + State(handler): State>, + Path(id): Path, +) -> Result { + let id: Uuid = id + .parse() + .map_err(|_| HttpError::BadRequest("invalid uuid".into()))?; + + let res = handler.service.get_shipment_by_id(id).await?; + + Ok((StatusCode::OK, Json(res))) } -pub async fn delete_shipment_by_id() -> impl IntoResponse { - (StatusCode::OK, "ok") +pub async fn delete_shipment_by_id( + State(handler): State>, + Path(id): Path, +) -> Result { + let id: Uuid = id + .parse() + .map_err(|_| HttpError::BadRequest("invalid uuid".into()))?; + + handler.service.delete_shipment_by_id(id).await?; + + Ok(StatusCode::OK) } pub async fn get_shipment_events() -> impl IntoResponse { diff --git a/services/tracking-service/src/models/dto.rs b/services/tracking-service/src/models/dto.rs index f5876a1..48b085c 100644 --- a/services/tracking-service/src/models/dto.rs +++ b/services/tracking-service/src/models/dto.rs @@ -1,7 +1,8 @@ +use crate::models::notification::NotificationChannel; +use crate::models::shipment::Shipment; use axum::Json; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; -use crate::models::notification::NotificationChannel; #[derive(Serialize, Deserialize, Debug)] pub struct AddTrackingRequest { @@ -21,4 +22,6 @@ impl IntoResponse for AddTrackingResponse { fn into_response(self) -> Response { Json(self).into_response() } -} \ No newline at end of file +} + +pub type GetShipmentsResponse = Vec; diff --git a/services/tracking-service/src/models/shipment.rs b/services/tracking-service/src/models/shipment.rs index 74baa44..de108d4 100644 --- a/services/tracking-service/src/models/shipment.rs +++ b/services/tracking-service/src/models/shipment.rs @@ -56,6 +56,8 @@ pub struct Shipment { pub courier_code: String, pub source: ShipmentSource, pub order_id: Option, + #[sqlx(rename = "external_order_ref")] + #[serde(rename = "external_order_ref")] pub external_ref_id: Option, pub current_status: ShipmentStatus, pub created_at: DateTime, diff --git a/services/tracking-service/src/repository/shipment_repo.rs b/services/tracking-service/src/repository/shipment_repo.rs index f742a04..4de1073 100644 --- a/services/tracking-service/src/repository/shipment_repo.rs +++ b/services/tracking-service/src/repository/shipment_repo.rs @@ -1,6 +1,7 @@ -use crate::models::shipment::{Shipment}; -use sqlx::{Pool, Postgres}; +use crate::models::shipment::{Shipment, ShipmentStatus}; use biteship::error::TrackingError; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; #[derive(Clone)] pub struct ShipmentRepository { @@ -40,6 +41,48 @@ impl ShipmentRepository { } } + pub async fn get_all(&self, user_id: Uuid) -> Result, sqlx::Error> { + let res: Vec = sqlx::query_as( + "SELECT id, waybill_id, courier_code, + source, current_status, order_id, + external_order_ref, created_at, updated_at FROM shipments WHERE user_id = $1", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + + Ok(res) + } + + pub async fn get_by_id( + &self, + user_id: Uuid, + id: Uuid, + ) -> Result, sqlx::Error> { + let res: Option = sqlx::query_as( + "SELECT id, waybill_id, courier_code, + source, current_status, order_id, + external_order_ref, created_at, updated_at FROM shipments + WHERE user_id = $1 AND id = $2", + ) + .bind(user_id) + .bind(id) + .fetch_optional(&self.pool) + .await?; + + Ok(res) + } + + pub async fn delete_by_id(&self, user_id: Uuid, id: Uuid) -> Result { + let res = sqlx::query("DELETE FROM shipments WHERE user_id = $1 AND id = $2") + .bind(user_id) + .bind(id) + .execute(&self.pool) + .await?; + + Ok(res.rows_affected()) + } + fn handle_db_err(&self, e: sqlx::Error) -> Option { if let Some(db_err) = e.as_database_error() { match db_err.code().map(|c| c.to_string()).as_deref() { diff --git a/services/tracking-service/src/repository/shipment_subscription.rs b/services/tracking-service/src/repository/shipment_subscription.rs index 028882a..1552ef0 100644 --- a/services/tracking-service/src/repository/shipment_subscription.rs +++ b/services/tracking-service/src/repository/shipment_subscription.rs @@ -1,6 +1,7 @@ use crate::models::shipment::ShipmentSubscription; use sqlx::{Pool, Postgres}; use std::error::Error; +use uuid::Uuid; #[derive(Clone)] pub struct ShipmentSubsRepository { @@ -30,4 +31,20 @@ impl ShipmentSubsRepository { Ok(()) } + + pub async fn delete_by_shipment_id( + &self, + user_id: Uuid, + shipment_id: Uuid, + ) -> Result { + let res = sqlx::query( + "DELETE FROM shipment_subscriptions WHERE user_id = $1 AND shipment_id = $2", + ) + .bind(user_id) + .bind(shipment_id) + .execute(&self.pool) + .await?; + + Ok(res.rows_affected()) + } } diff --git a/services/tracking-service/src/routes.rs b/services/tracking-service/src/routes.rs index 62cd162..d166c75 100644 --- a/services/tracking-service/src/routes.rs +++ b/services/tracking-service/src/routes.rs @@ -1,11 +1,16 @@ use crate::app::AppState; -use crate::handlers::tracking::create_shipments; -use axum::routing::post; +use crate::handlers::tracking::{ + create_shipments, delete_shipment_by_id, get_shipment_by_id, get_shipments, +}; use axum::Router; +use axum::routing::{delete, get, post}; use std::sync::Arc; pub fn routes(state: Arc) -> Router { Router::new() .route("/shipments", post(create_shipments)) + .route("/shipments", get(get_shipments)) + .route("/shipments/{id}", get(get_shipment_by_id)) + .route("/shipments/{id}", delete(delete_shipment_by_id)) .with_state(state) } diff --git a/services/tracking-service/src/service/tracking_service.rs b/services/tracking-service/src/service/tracking_service.rs index 2f63a43..eb904be 100644 --- a/services/tracking-service/src/service/tracking_service.rs +++ b/services/tracking-service/src/service/tracking_service.rs @@ -1,4 +1,4 @@ -use crate::models::dto::{AddTrackingRequest, AddTrackingResponse}; +use crate::models::dto::{AddTrackingRequest, AddTrackingResponse, GetShipmentsResponse}; use crate::models::notification::{ NotificationChannel, TrackingEventMsg, TrackingEventMsgType, TrackingMsgPayload, }; @@ -161,4 +161,55 @@ impl TrackingService { Ok(response) } + + pub async fn get_shipments(&self) -> Result { + // this is a dummy user for the development phase + let user_uuid = Uuid::from_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + + let res = self + .shipment_repository + .get_all(user_uuid) + .await + .map_err(|e| HttpError::InternalServerError(anyhow::anyhow!(e.to_string())))?; + + Ok(res) + } + + pub async fn get_shipment_by_id(&self, id: Uuid) -> Result { + // this is a dummy user for the development phase + let user_uuid = Uuid::from_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + + let res = self + .shipment_repository + .get_by_id(user_uuid, id) + .await + .map_err(|e| HttpError::InternalServerError(anyhow::anyhow!(e.to_string())))?; + + match res { + Some(shipment) => Ok(shipment), + None => Err(HttpError::NotFound("shipment not found".into())), + } + } + + pub async fn delete_shipment_by_id(&self, id: Uuid) -> Result<(), HttpError> { + // this is a dummy user for the development phase + let user_uuid = Uuid::from_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + + self.shipment_subs_repo + .delete_by_shipment_id(user_uuid, id) + .await + .map_err(|e| HttpError::InternalServerError(anyhow::anyhow!(e.to_string())))?; + + let rows_affected = self + .shipment_repository + .delete_by_id(user_uuid, id) + .await + .map_err(|e| HttpError::InternalServerError(anyhow::anyhow!(e.to_string())))?; + + if rows_affected == 0 { + return Err(HttpError::NotFound("shipment not found".into())); + } + + Ok(()) + } } diff --git a/sonar-project.properties b/sonar-project.properties index 725535e..c2d6b65 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,8 +1,20 @@ sonar.projectKey=logitrack sonar.projectName=logitrack -sonar.sources=libs,services -sonar.exclusions=**/target/**,**/*.lock - sonar.sourceEncoding=UTF-8 sonar.scm.provider=git + +sonar.sources=. + +sonar.exclusions=**/* + +sonar.inclusions=\ +**/*.rs,\ +**/Cargo.toml,\ +**/Dockerfile,\ +**/docker-compose.yml,\ +**/docker-compose.*.yml + +sonar.exclusions=**/target/** + +sonar.rust.clippy.reportPaths=clippy.json \ No newline at end of file