diff --git a/Cargo.lock b/Cargo.lock index ed8af1eb..7349b7d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3073,6 +3073,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", diff --git a/crates/smooth-cli/Cargo.toml b/crates/smooth-cli/Cargo.toml index 63b2526a..b87e868e 100644 --- a/crates/smooth-cli/Cargo.toml +++ b/crates/smooth-cli/Cargo.toml @@ -47,7 +47,7 @@ serde_json.workspace = true anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -reqwest = { workspace = true, features = ["blocking"] } +reqwest = { workspace = true, features = ["blocking", "multipart"] } chrono.workspace = true chrono-tz.workspace = true dirs-next.workspace = true diff --git a/crates/smooth-cli/src/main.rs b/crates/smooth-cli/src/main.rs index 9ac12d3f..92441269 100644 --- a/crates/smooth-cli/src/main.rs +++ b/crates/smooth-cli/src/main.rs @@ -649,6 +649,10 @@ enum TunnelCommands { } #[derive(Subcommand)] +// `Brand` carries a dozen optional branding flags, so it dwarfs the other +// variants. Boxing it would break clap's derive (it needs the args inline), and +// a command enum is parsed once per process — the size difference is irrelevant. +#[allow(clippy::large_enum_variant)] enum OrgsCommands { /// List organizations the logged-in user belongs to. List, @@ -669,6 +673,45 @@ enum OrgsCommands { /// Org id (UUID) or a name/slug substring. Omit to pick from a list. org_id: Option, }, + /// View or set org branding (white-label): app name, logo, colors. + /// With no flags, prints the current branding; any flag PUTs a merged update. + Brand { + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + /// White-label app name (e.g. shown as "Proposal by "). + #[arg(long = "app-name")] + app_name: Option, + /// Logo URL (light theme). + #[arg(long)] + logo: Option, + /// Upload a local logo file (light) instead of passing a URL. + #[arg(long = "logo-file")] + logo_file: Option, + /// Logo URL (dark theme). + #[arg(long = "logo-dark")] + logo_dark: Option, + /// Upload a local logo file (dark). + #[arg(long = "logo-dark-file")] + logo_dark_file: Option, + /// Favicon URL. + #[arg(long)] + favicon: Option, + /// Upload a local favicon file. + #[arg(long = "favicon-file")] + favicon_file: Option, + /// Primary brand color (hex, e.g. #00a6a6). + #[arg(long)] + primary: Option, + /// Accent brand color (hex). + #[arg(long)] + accent: Option, + /// Support URL. + #[arg(long = "support-url")] + support_url: Option, + /// Hide the "powered by Smoo AI" mark. + #[arg(long = "hide-powered-by")] + hide_powered_by: bool, + }, } /// Pearl th-9cd759: `th api login/logout/whoami` moved to the `th auth` diff --git a/crates/smooth-cli/src/smooai/crm.rs b/crates/smooth-cli/src/smooai/crm.rs index aa9bc3cc..e917513d 100644 --- a/crates/smooth-cli/src/smooai/crm.rs +++ b/crates/smooth-cli/src/smooai/crm.rs @@ -76,6 +76,12 @@ pub enum Cmd { #[command(subcommand)] cmd: InvoicesCmd, }, + /// Proposals — client proposals sent as password-gated magic links (list / show / create / update / link / url). + #[command(visible_alias = "proposal")] + Proposals { + #[command(subcommand)] + cmd: ProposalsCmd, + }, } #[derive(Subcommand)] @@ -543,6 +549,7 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { Cmd::Conversations { cmd } => conversations(cmd).await, Cmd::Timeline { deal_id, org, json } => timeline(deal_id, org, json).await, Cmd::Invoices { cmd } => invoices(cmd).await, + Cmd::Proposals { cmd } => proposals(cmd).await, } } @@ -2027,6 +2034,288 @@ fn remember(email_to_id: &mut HashMap, phone_to_id: &mut HashMap } } +// ─── Proposals (SMOODEV-2566) ─────────────────────────────────────────────── +// Client proposals authored in the dashboard and sent as password-gated magic +// links. Routes are top-level org-scoped (`/organizations/{org}/proposals`), +// NOT under `/crm`. A proposal optionally links to a CRM deal via `dealId`. + +const PROPOSAL_WEB_URL: &str = "https://smoo.ai"; + +fn proposal_web_url() -> String { + std::env::var("SMOOAI_WEB_URL").unwrap_or_else(|_| PROPOSAL_WEB_URL.to_string()) +} + +#[derive(Subcommand)] +pub enum ProposalsCmd { + /// List the org's proposals. + List { + /// Override the active org. Falls back to `SMOOAI_ORG_ID`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + /// Print raw JSON instead of the table. + #[arg(long)] + json: bool, + }, + /// Show one proposal, with its shareable magic link. + Show { + /// The proposal id from `… proposals list`. + proposal_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + /// Print raw JSON instead of the rendered view. + #[arg(long)] + json: bool, + }, + /// Create a proposal. + Create { + /// Proposal title. + title: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + /// Link to a CRM deal (uuid or title). + #[arg(long)] + deal: Option, + /// Access key (password) recipients need to open the link. + #[arg(long)] + key: Option, + /// Publish immediately (default is draft). + #[arg(long)] + publish: bool, + }, + /// Update a proposal — only the flags you pass change. + Update { + proposal_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + #[arg(long)] + title: Option, + /// Link to a CRM deal (uuid or title). + #[arg(long)] + deal: Option, + /// Set the access key (password). + #[arg(long)] + key: Option, + /// Publish it. + #[arg(long)] + publish: bool, + /// Unpublish it (back to draft). + #[arg(long)] + unpublish: bool, + /// Rotate the magic-link token (invalidates the old link). + #[arg(long = "rotate-token")] + rotate_token: bool, + }, + /// Link a proposal to a CRM deal (uuid or title). + Link { + proposal_id: String, + /// Deal uuid or title. + deal: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Print the shareable magic link for a proposal. + Url { + proposal_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Delete a proposal. + Delete { + proposal_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, +} + +async fn proposals(cmd: ProposalsCmd) -> Result<()> { + let client = UserClient::from_user_session().await?; + match cmd { + ProposalsCmd::List { org, json } => { + let org = resolve_org(org)?; + let body = client.get(&format!("/organizations/{org}/proposals")).await.context("GET proposals")?; + if json { + print_json(&body); + } else { + render_proposals(&body); + } + } + ProposalsCmd::Show { proposal_id, org, json } => { + let org = resolve_org(org)?; + let p = client + .get(&format!("/organizations/{org}/proposals/{proposal_id}")) + .await + .context("GET proposal")?; + if json { + print_json(&p); + } else { + show_proposal(&p); + } + } + ProposalsCmd::Create { + title, + org, + deal, + key, + publish, + } => { + let org = resolve_org(org)?; + let mut body = json!({ "title": title }); + if publish { + body["status"] = json!("published"); + } + let key = key.filter(|s| !s.trim().is_empty()); + if let Some(k) = &key { + body["password"] = json!(k); + } + if let Some(d) = deal.filter(|s| !s.trim().is_empty()) { + body["dealId"] = json!(resolve_deal_id(&client, &org, &d).await?); + } + let p = client.post(&format!("/organizations/{org}/proposals"), &body).await.context("POST proposal")?; + let id = p.get("id").and_then(Value::as_str).unwrap_or("?"); + println!(" {} created proposal {} {}", "✚".green(), id.dimmed(), title.bold()); + print_proposal_link(&p, key.as_deref()); + } + ProposalsCmd::Update { + proposal_id, + org, + title, + deal, + key, + publish, + unpublish, + rotate_token, + } => { + let org = resolve_org(org)?; + let mut body = json!({}); + if let Some(t) = title.filter(|s| !s.trim().is_empty()) { + body["title"] = json!(t); + } + let key = key.filter(|s| !s.trim().is_empty()); + if let Some(k) = &key { + body["password"] = json!(k); + } + if let Some(d) = deal.filter(|s| !s.trim().is_empty()) { + body["dealId"] = json!(resolve_deal_id(&client, &org, &d).await?); + } + if publish { + body["status"] = json!("published"); + } + if unpublish { + body["status"] = json!("draft"); + } + if rotate_token { + body["rotateToken"] = json!(true); + } + let p = client + .patch(&format!("/organizations/{org}/proposals/{proposal_id}"), &body) + .await + .context("PATCH proposal")?; + println!(" {} updated proposal {}", "↻".yellow(), proposal_id.dimmed()); + print_proposal_link(&p, key.as_deref()); + } + ProposalsCmd::Link { proposal_id, deal, org } => { + let org = resolve_org(org)?; + let deal_id = resolve_deal_id(&client, &org, &deal).await?; + client + .patch(&format!("/organizations/{org}/proposals/{proposal_id}"), &json!({ "dealId": deal_id })) + .await + .context("PATCH proposal (link deal)")?; + println!( + " {} linked proposal {} {} deal {}", + "🔗".cyan(), + proposal_id.dimmed(), + "→".dimmed(), + deal_id.dimmed() + ); + } + ProposalsCmd::Url { proposal_id, org } => { + let org = resolve_org(org)?; + let p = client + .get(&format!("/organizations/{org}/proposals/{proposal_id}")) + .await + .context("GET proposal")?; + print_proposal_link(&p, None); + } + ProposalsCmd::Delete { proposal_id, org } => { + let org = resolve_org(org)?; + client + .delete(&format!("/organizations/{org}/proposals/{proposal_id}")) + .await + .context("DELETE proposal")?; + println!(" {} deleted proposal {}", "✗".red(), proposal_id.dimmed()); + } + } + Ok(()) +} + +fn proposal_status_cell(status: &str, width: usize) -> String { + let padded = format!("{status:) -> Option { + let token = p.get("token").and_then(Value::as_str)?; + let base = format!("{}/proposals/{token}", proposal_web_url()); + Some(match key { + Some(k) if !k.is_empty() => format!("{base}?key={}", urlencoding::encode(k)), + _ => base, + }) +} + +fn print_proposal_link(p: &Value, key: Option<&str>) { + if let Some(link) = proposal_link(p, key) { + println!(" {} {}", "link".dimmed(), link.cyan()); + if p.get("status").and_then(Value::as_str) != Some("published") { + println!(" {} draft — run `… proposals update --publish` to make it resolve", "!".yellow()); + } + } +} + +fn show_proposal(p: &Value) { + println!(); + println!(" {}", p.get("title").and_then(Value::as_str).unwrap_or("—").bold()); + println!( + " {} {}", + "Status ".dimmed(), + proposal_status_cell(p.get("status").and_then(Value::as_str).unwrap_or("draft"), 0) + ); + println!(" {} {}", "Views ".dimmed(), p.get("viewCount").and_then(Value::as_u64).unwrap_or(0)); + if let Some(d) = p.get("dealId").and_then(Value::as_str) { + println!(" {} {}", "Deal ".dimmed(), d.dimmed()); + } + println!(" {} {}", "id ".dimmed(), p.get("id").and_then(Value::as_str).unwrap_or("?").dimmed()); + print_proposal_link(p, None); + println!(); +} + +fn render_proposals(body: &Value) { + let items = body.get("proposals").and_then(Value::as_array).cloned().unwrap_or_default(); + if items.is_empty() { + println!("\n {}\n", "no proposals yet".dimmed()); + return; + } + println!(); + println!( + " {} {} {} {}", + format!("{:<38}", "TITLE").dimmed(), + format!("{:<10}", "STATUS").dimmed(), + format!("{:>6}", "VIEWS").dimmed(), + "ID".dimmed() + ); + for p in &items { + let title = truncate(p.get("title").and_then(Value::as_str).unwrap_or("—"), 38); + let status = proposal_status_cell(p.get("status").and_then(Value::as_str).unwrap_or("draft"), 10); + let views = format!("{:>6}", p.get("viewCount").and_then(Value::as_u64).unwrap_or(0)); + let id = p.get("id").and_then(Value::as_str).unwrap_or("?"); + println!(" {:<38} {} {} {}", title, status, views, id.dimmed()); + } + println!(); +} + #[cfg(test)] mod tests { use super::{extract_timeline_items, fmt_cents, group_thousands, is_overdue, looks_like_uuid, norm_email, norm_phone, preview_body, timeline_glyph}; diff --git a/crates/smooth-cli/src/smooai/mod.rs b/crates/smooth-cli/src/smooai/mod.rs index ba1599a4..7f053874 100644 --- a/crates/smooth-cli/src/smooai/mod.rs +++ b/crates/smooth-cli/src/smooai/mod.rs @@ -518,10 +518,104 @@ pub async fn cmd_orgs(cmd: super::OrgsCommands) -> Result<()> { } println!(); } + super::OrgsCommands::Brand { + org, + app_name, + mut logo, + logo_file, + mut logo_dark, + logo_dark_file, + mut favicon, + favicon_file, + primary, + accent, + support_url, + hide_powered_by, + } => { + let resolved = crate::active_org::resolve(org).context("no org id specified and no active org set — pass --org or `th org switch `")?; + + // Upload any local files first; the returned URLs override the URL flags. + if let Some(f) = logo_file { + logo = Some(client.upload_logo(&resolved, "logo", &f).await.context("upload logo file")?); + println!(" {} uploaded logo → {}", "↑".cyan(), logo.as_deref().unwrap_or("").dimmed()); + } + if let Some(f) = logo_dark_file { + // The upload endpoint only accepts logo|icon|logoWordmark variants; the + // returned URL is what matters, so a dark logo uploads as "logo". + logo_dark = Some(client.upload_logo(&resolved, "logo", &f).await.context("upload dark logo file")?); + println!(" {} uploaded dark logo → {}", "↑".cyan(), logo_dark.as_deref().unwrap_or("").dimmed()); + } + if let Some(f) = favicon_file { + favicon = Some(client.upload_logo(&resolved, "icon", &f).await.context("upload favicon file")?); + println!(" {} uploaded favicon → {}", "↑".cyan(), favicon.as_deref().unwrap_or("").dimmed()); + } + + let current = client.get(&format!("/organizations/{resolved}/branding")).await.context("GET branding")?; + + let no_changes = app_name.is_none() + && logo.is_none() + && logo_dark.is_none() + && favicon.is_none() + && primary.is_none() + && accent.is_none() + && support_url.is_none() + && !hide_powered_by; + if no_changes { + print_branding(¤t); + return Ok(()); + } + + let cur = |k: &str| current.get(k).cloned().unwrap_or(serde_json::Value::Null); + let mut theme = current + .get("themeJson") + .cloned() + .filter(serde_json::Value::is_object) + .unwrap_or_else(|| serde_json::json!({})); + if let Some(p) = &primary { + theme["primary"] = serde_json::json!(p); + } + if let Some(a) = &accent { + theme["accent"] = serde_json::json!(a); + } + let body = serde_json::json!({ + "appName": app_name.map(serde_json::Value::from).unwrap_or_else(|| cur("appName")), + "logoUrl": logo.map(serde_json::Value::from).unwrap_or_else(|| cur("logoUrl")), + "logoDarkUrl": logo_dark.map(serde_json::Value::from).unwrap_or_else(|| cur("logoDarkUrl")), + "faviconUrl": favicon.map(serde_json::Value::from).unwrap_or_else(|| cur("faviconUrl")), + "themeJson": theme, + "supportUrl": support_url.map(serde_json::Value::from).unwrap_or_else(|| cur("supportUrl")), + "hidePoweredBy": if hide_powered_by { + serde_json::json!(true) + } else { + current.get("hidePoweredBy").and_then(serde_json::Value::as_bool).map(serde_json::Value::from).unwrap_or(serde_json::json!(false)) + }, + }); + let updated = client + .put(&format!("/organizations/{resolved}/branding"), &body) + .await + .context("PUT branding")?; + println!(" {} updated branding for {}", "↻".yellow(), resolved.dimmed()); + print_branding(&updated); + } } Ok(()) } +/// Pretty-print an organization branding payload. +fn print_branding(b: &serde_json::Value) { + let s = |k: &str| b.get(k).and_then(serde_json::Value::as_str).unwrap_or("—").to_string(); + let theme = b.get("themeJson"); + let tc = |k: &str| theme.and_then(|t| t.get(k)).and_then(serde_json::Value::as_str).unwrap_or("—").to_string(); + println!(); + println!(" {} {}", "App name ".dimmed(), s("appName").bold()); + println!(" {} {}", "Logo ".dimmed(), s("logoUrl")); + println!(" {} {}", "Logo dark ".dimmed(), s("logoDarkUrl")); + println!(" {} {}", "Favicon ".dimmed(), s("faviconUrl")); + println!(" {} primary {} accent {}", "Colors ".dimmed(), tc("primary").cyan(), tc("accent").cyan()); + println!(" {} {}", "Support ".dimmed(), s("supportUrl")); + println!(); +} + /// A single org as far as the switcher cares. #[derive(Debug)] struct OrgRef { diff --git a/crates/smooth-cli/src/smooai/user_client.rs b/crates/smooth-cli/src/smooai/user_client.rs index 0319bec9..956d2721 100644 --- a/crates/smooth-cli/src/smooai/user_client.rs +++ b/crates/smooth-cli/src/smooai/user_client.rs @@ -98,6 +98,54 @@ impl UserClient { Self::body(resp, "PATCH", &url).await } + pub async fn put(&self, path: &str, body: &Value) -> Result { + let url = format!("{}{path}", self.base); + let resp = self + .http + .put(&url) + .bearer_auth(&self.bearer) + .json(body) + .send() + .await + .with_context(|| format!("PUT {url}"))?; + Self::body(resp, "PUT", &url).await + } + + /// Upload a local file to the org logo endpoint (multipart). `variant` is + /// e.g. "logo" | "logoDark" | "favicon". Returns the hosted URL. + pub async fn upload_logo(&self, org: &str, variant: &str, file_path: &str) -> Result { + let bytes = std::fs::read(file_path).with_context(|| format!("read {file_path}"))?; + let name = std::path::Path::new(file_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("logo.png") + .to_string(); + let mime = match name.rsplit('.').next().unwrap_or("").to_lowercase().as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "svg" => "image/svg+xml", + "webp" => "image/webp", + "gif" => "image/gif", + _ => "application/octet-stream", + }; + let part = reqwest::multipart::Part::bytes(bytes).file_name(name).mime_str(mime)?; + let form = reqwest::multipart::Form::new().part("file", part).text("variant", variant.to_string()); + let url = format!("{}/organizations/{org}/logo/upload", self.base); + let resp = self + .http + .post(&url) + .bearer_auth(&self.bearer) + .multipart(form) + .send() + .await + .with_context(|| format!("POST {url}"))?; + let body = Self::body(resp, "POST", &url).await?; + body.get("url") + .and_then(Value::as_str) + .map(str::to_string) + .context("logo upload response missing `url`") + } + pub async fn delete(&self, path: &str) -> Result { let url = format!("{}{path}", self.base); let resp = self