//! Plugin de comunicação com Supabase via REST API //! Substitui completamente o fetch() do JS + n8n como intermediário. use reqwest::blocking::Client; use serde_json::Value; use std::time::Duration; use tauri::State; use thiserror::Error; #[derive(Error, Debug)] #[allow(dead_code)] pub enum SupabaseError { #[error("HTTP error: {0}")] Http(#[from] reqwest::Error), #[error("API error: {0}")] Api(String), #[error("Lock error")] Lock, } impl serde::Serialize for SupabaseError { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&self.to_string()) } } pub struct SupabaseClient { http: Client, anon_key: String, service_role_key: String, base_url: String, } impl SupabaseClient { pub fn new(anon_key: String, service_role_key: String) -> Self { let http = Client::builder() .timeout(Duration::from_secs(15)) .build() .expect("reqwest client"); Self { http, anon_key, service_role_key, base_url: "https://supabase.ofrangao.com.br".to_string(), } } fn headers(&self, use_service: bool) -> reqwest::header::HeaderMap { let key = if use_service { &self.service_role_key } else { &self.anon_key }; let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::AUTHORIZATION, format!("Bearer {}", key).parse().unwrap(), ); headers.insert( reqwest::header::HeaderName::from_static("apikey"), key.parse().unwrap(), ); headers.insert( reqwest::header::CONTENT_TYPE, "application/json".parse().unwrap(), ); headers } /// Salva/atualiza um fechamento na tabela `fechamentos_web`. pub fn salvar_fechamento(&self, estado: &serde_json::Value) -> Result { let uuid = estado .get("uuid") .and_then(|v| v.as_str()) .unwrap_or(""); let payload = serde_json::json!({ "uuid": uuid, "loja": estado["loja"], "data": estado["data"], "operador": estado["operador"], "turno": estado["turno"], "saldo_troco": estado["saldo_troco"], "saldo_esperado": estado["saldo_esperado"], "fechamento": estado["fechamento"], "dados": estado, "observacoes": "rascunho", }); let _url = format!( "{}/rest/v1/fechamentos_web?uuid=eq.{}&select=id", self.base_url, uuid ); // Upsert via POST com Prefer: resolution=merge-duplicates let resp = self .http .post(&format!("{}/rest/v1/rpc/upsert_fechamento", self.base_url)) .headers(self.headers(true)) .json(&payload) .send()?; if resp.status().is_success() { Ok(serde_json::json!({ "ok": true, "uuid": uuid })) } else { let body = resp.text().unwrap_or_default(); // Fallback: tenta POST direto na tabela let resp2 = self .http .post(&format!("{}/rest/v1/fechamentos_web", self.base_url)) .headers(self.headers(true)) .header("Prefer", "resolution=merge-duplicates") .json(&payload) .send()?; if resp2.status().is_success() { Ok(serde_json::json!({ "ok": true, "uuid": uuid })) } else { Err(SupabaseError::Api(format!( "HTTP {}: {}", resp2.status().as_u16(), body ))) } } } /// Busca o rascunho mais recente para loja+data. pub fn carregar_rascunho(&self, loja: &str, data: &str) -> Result, SupabaseError> { let url = format!( "{}/rest/v1/fechamentos_web?loja=eq.{}&data=eq.{}&observacoes=eq.rascunho&order=atualizado_em.desc&limit=1&select=*", self.base_url, urlencoding::encode(loja), urlencoding::encode(data), ); let resp = self .http .get(&url) .headers(self.headers(false)) .send()?; if resp.status().is_success() { let arr: Vec = resp.json()?; Ok(arr.into_iter().next()) } else { let status = resp.status(); let body = resp.text().unwrap_or_default(); Err(SupabaseError::Api(format!( "HTTP {}: {}", status.as_u16(), body ))) } } /// Busca um fechamento pelo UUID. pub fn buscar_por_uuid(&self, uuid: &str) -> Result, SupabaseError> { let url = format!( "{}/rest/v1/fechamentos_web?uuid=eq.{}&select=*", self.base_url, uuid ); let resp = self .http .get(&url) .headers(self.headers(false)) .send()?; if resp.status().is_success() { let arr: Vec = resp.json()?; Ok(arr.into_iter().next()) } else { let status = resp.status(); let body = resp.text().unwrap_or_default(); Err(SupabaseError::Api(format!( "HTTP {}: {}", status.as_u16(), body ))) } } /// Atualiza campos específicos de um fechamento pelo UUID (PATCH). pub fn atualizar_fechamento(&self, uuid: &str, updates: &serde_json::Value) -> Result { let url = format!( "{}/rest/v1/fechamentos_web?uuid=eq.{}", self.base_url, uuid ); let resp = self .http .patch(&url) .headers(self.headers(true)) .json(updates) .send()?; if resp.status().is_success() { Ok(serde_json::json!({ "ok": true, "uuid": uuid })) } else { let status = resp.status(); let body = resp.text().unwrap_or_default(); Err(SupabaseError::Api(format!( "HTTP {}: {}", status.as_u16(), body ))) } } pub fn listar_recentes(&self, loja: &str, limite: i64) -> Result, SupabaseError> { let url = format!( "{}/rest/v1/fechamentos_web?loja=eq.{}&order=data.desc,atualizado_em.desc&limit={}&select=id,uuid,data,operador,turno,saldo_troco,saldo_esperado,fechamento,sync_status,observacoes,atualizado_em", self.base_url, urlencoding::encode(loja), limite, ); let resp = self .http .get(&url) .headers(self.headers(false)) .send()?; if resp.status().is_success() { let arr: Vec = resp.json()?; Ok(arr) } else { let status = resp.status(); let body = resp.text().unwrap_or_default(); Err(SupabaseError::Api(format!( "HTTP {}: {}", status.as_u16(), body ))) } } /// Busca um fechamento por ID. pub fn buscar_por_id(&self, id: i64) -> Result, SupabaseError> { let url = format!( "{}/rest/v1/fechamentos_web?id=eq.{}&select=*", self.base_url, id ); let resp = self .http .get(&url) .headers(self.headers(false)) .send()?; if resp.status().is_success() { let arr: Vec = resp.json()?; Ok(arr.into_iter().next()) } else { let status = resp.status(); let body = resp.text().unwrap_or_default(); Err(SupabaseError::Api(format!( "HTTP {}: {}", status.as_u16(), body ))) } } /// Salva operadores/gerentes/despesas customizadas. pub fn salvar_listas(&self, loja: &str, listas: &serde_json::Value) -> Result { let payload = serde_json::json!({ "loja": loja, "operadores": listas.get("operadores"), "gerentes": listas.get("gerentes"), "despesas_custom": listas.get("despesas_custom"), "clientes": listas.get("clientes"), }); let _url = format!( "{}/rest/v1/listas_personalizadas?loja=eq.{}&select=id", self.base_url, urlencoding::encode(loja), ); let resp = self .http .post(&format!("{}/rest/v1/listas_personalizadas", self.base_url)) .headers(self.headers(true)) .header("Prefer", "resolution=merge-duplicates") .json(&payload) .send()?; if resp.status().is_success() { Ok(serde_json::json!({ "ok": true })) } else { let status = resp.status(); let body = resp.text().unwrap_or_default(); Err(SupabaseError::Api(format!( "HTTP {}: {}", status.as_u16(), body ))) } } /// Carrega listas personalizadas do servidor. pub fn carregar_listas(&self, loja: &str) -> Result, SupabaseError> { let url = format!( "{}/rest/v1/listas_personalizadas?loja=eq.{}&limit=1&select=*", self.base_url, urlencoding::encode(loja), ); let resp = self .http .get(&url) .headers(self.headers(false)) .send()?; if resp.status().is_success() { let arr: Vec = resp.json()?; Ok(arr.into_iter().next()) } else { let status = resp.status(); let body = resp.text().unwrap_or_default(); Err(SupabaseError::Api(format!( "HTTP {}: {}", status.as_u16(), body ))) } } /// Verifica se está online. pub fn health_check(&self) -> bool { let url = format!("{}/rest/v1/?limit=0", self.base_url); self.http .get(&url) .headers(self.headers(false)) .send() .map(|r| r.status().is_success()) .unwrap_or(false) } } // ── Tauri Commands ───────────────────────────────────────────────────────── #[tauri::command] pub fn sb_salvar_fechamento( sb: State<'_, SupabaseClient>, estado: serde_json::Value, ) -> Result { sb.salvar_fechamento(&estado) } #[tauri::command] pub fn sb_carregar_rascunho( sb: State<'_, SupabaseClient>, loja: String, data: String, ) -> Result, SupabaseError> { sb.carregar_rascunho(&loja, &data) } #[tauri::command] pub fn sb_listar_recentes( sb: State<'_, SupabaseClient>, loja: String, limite: Option, ) -> Result, SupabaseError> { sb.listar_recentes(&loja, limite.unwrap_or(30)) } #[tauri::command] pub fn sb_buscar_por_id( sb: State<'_, SupabaseClient>, id: i64, ) -> Result, SupabaseError> { sb.buscar_por_id(id) } #[tauri::command] pub fn sb_buscar_por_uuid( sb: State<'_, SupabaseClient>, uuid: String, ) -> Result, SupabaseError> { sb.buscar_por_uuid(&uuid) } #[tauri::command] pub fn sb_atualizar_fechamento( sb: State<'_, SupabaseClient>, uuid: String, updates: serde_json::Value, ) -> Result { sb.atualizar_fechamento(&uuid, &updates) } #[tauri::command] pub fn sb_salvar_listas( sb: State<'_, SupabaseClient>, loja: String, listas: serde_json::Value, ) -> Result { sb.salvar_listas(&loja, &listas) } #[tauri::command] pub fn sb_carregar_listas( sb: State<'_, SupabaseClient>, loja: String, ) -> Result, SupabaseError> { sb.carregar_listas(&loja) } #[tauri::command] pub fn sb_online(sb: State<'_, SupabaseClient>) -> bool { sb.health_check() } pub fn init(anon_key: String, service_role_key: String) -> SupabaseClient { SupabaseClient::new(anon_key, service_role_key) } // urlencoding helper (we'll add it as a dep) mod urlencoding { pub fn encode(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char); } _ => { out.push_str(&format!("%{:02X}", b)); } } } out } }