diff --git a/Cargo.lock b/Cargo.lock index 16880cf9..69fb2364 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,67 +189,6 @@ dependencies = [ "syn", ] -[[package]] -name = "async-stripe" -version = "1.0.0-rc.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b44bf612e77ab96a4715f1ee36684863fc080ad0ad7ad0fbc2f25180e34565" -dependencies = [ - "async-stripe-client-core", - "async-stripe-shared", - "bytes", - "http-body-util", - "hyper", - "hyper-tls", - "hyper-util", - "miniserde", - "thiserror 2.0.17", - "tokio", - "tracing", -] - -[[package]] -name = "async-stripe-client-core" -version = "1.0.0-rc.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20a5d5b4c2e3357583af0f7333221a79743af5783ce67aa5a4c8d9ed9851690" -dependencies = [ - "async-stripe-shared", - "async-stripe-types", - "bytes", - "futures-util", - "miniserde", - "serde", - "serde_json", - "serde_qs 1.0.0-rc.5", - "thiserror 2.0.17", - "tracing", -] - -[[package]] -name = "async-stripe-shared" -version = "1.0.0-rc.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5054983d00964e5b6795ba2c56e835676831ec1881a652e0f9d3e4bb16071b7d" -dependencies = [ - "async-stripe-types", - "miniserde", - "serde", - "smol_str", - "tracing", -] - -[[package]] -name = "async-stripe-types" -version = "1.0.0-rc.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f08896311aa29456989c5c82b1e32d99f88ea9ac974b5aaef218fd88192dd4" -dependencies = [ - "miniserde", - "serde", - "smol_str", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -504,15 +443,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "borsh" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" -dependencies = [ - "cfg_aliases", -] - [[package]] name = "bumpalo" version = "3.19.0" @@ -527,9 +457,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" @@ -2281,7 +2211,7 @@ dependencies = [ "rustc-hash", "send_wrapper", "serde", - "serde_qs 0.13.0", + "serde_qs", "server_fn", "slotmap", "tachys", @@ -2712,17 +2642,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "mini-internal" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8df435db5df1dd82a74f77e3c3addf6ab7665079c31e222a64f34f7475d87e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "minicov" version = "0.3.8" @@ -2733,17 +2652,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "miniserde" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c48e83ec09ab8a51d4e6be46608bf2a1293a79e2f7ea60246a2ce50eaef44ba" -dependencies = [ - "itoa", - "mini-internal", - "zmij", -] - [[package]] name = "mio" version = "1.1.0" @@ -4149,19 +4057,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "serde_qs" -version = "1.0.0-rc.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcfaeca4768cac840e6517039d77239187e41efe4daa92c577ca1690b5dc879d" -dependencies = [ - "itoa", - "percent-encoding", - "ryu", - "serde", - "thiserror 2.0.17", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -4253,7 +4148,7 @@ dependencies = [ "send_wrapper", "serde", "serde_json", - "serde_qs 0.13.0", + "serde_qs", "server_fn_macro_default", "thiserror 2.0.17", "throw_error", @@ -4377,16 +4272,6 @@ dependencies = [ "serde", ] -[[package]] -name = "smol_str" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9676b89cd56310a87b93dec47b11af744f34d5fc9f367b829474eec0a891350d" -dependencies = [ - "borsh", - "serde", -] - [[package]] name = "socket2" version = "0.6.1" @@ -6169,12 +6054,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zmij" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" - [[package]] name = "zopp-audit" version = "0.1.1" @@ -6188,22 +6067,6 @@ dependencies = [ "zopp-storage", ] -[[package]] -name = "zopp-billing" -version = "0.1.1" -dependencies = [ - "async-stripe", - "async-trait", - "chrono", - "serde", - "serde_json", - "thiserror 2.0.17", - "tokio", - "tracing", - "uuid", - "zopp-storage", -] - [[package]] name = "zopp-cli" version = "0.1.1" @@ -6446,7 +6309,6 @@ dependencies = [ "uuid", "x25519-dalek", "zopp-audit", - "zopp-billing", "zopp-crypto", "zopp-events", "zopp-events-memory", diff --git a/Cargo.toml b/Cargo.toml index 8ce3e075..6350244b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ members = [ "apps/zopp-server", "apps/zopp-web", "crates/zopp-audit", - "crates/zopp-billing", "crates/zopp-config", "crates/zopp-crypto", "crates/zopp-crypto-wasm", diff --git a/apps/zopp-cli/src/cli.rs b/apps/zopp-cli/src/cli.rs index bd710255..7d705f27 100644 --- a/apps/zopp-cli/src/cli.rs +++ b/apps/zopp-cli/src/cli.rs @@ -95,11 +95,6 @@ pub enum Command { #[command(subcommand)] audit_cmd: AuditCommand, }, - /// Organization commands (SaaS cloud) - Org { - #[command(subcommand)] - org_cmd: OrganizationCommand, - }, /// Run a command with secrets injected as environment variables Run { /// Workspace name (defaults from zopp.toml) @@ -1066,112 +1061,3 @@ pub enum AuditCommand { result: Option, }, } - -#[derive(Subcommand)] -pub enum OrganizationCommand { - /// List organizations you belong to - List, - /// Create a new organization - Create { - /// Organization name - name: String, - /// Organization slug (URL-friendly identifier) - #[arg(long)] - slug: Option, - }, - /// Get organization details - Get { - /// Organization ID or slug - org: String, - }, - /// Update organization settings - Update { - /// Organization ID or slug - org: String, - /// New name - #[arg(long)] - name: Option, - /// New slug - #[arg(long)] - slug: Option, - }, - /// List organization members - Members { - /// Organization ID or slug - org: String, - }, - /// Add a member to the organization - AddMember { - /// Organization ID or slug - org: String, - /// User ID (UUID) - #[arg(long)] - user_id: String, - /// Role: owner, admin, or member - #[arg(long, default_value = "member")] - role: String, - }, - /// Remove a member from the organization - RemoveMember { - /// Organization ID or slug - org: String, - /// User ID (UUID) - #[arg(long)] - user_id: String, - }, - /// Set a member's role - SetRole { - /// Organization ID or slug - org: String, - /// User ID (UUID) - #[arg(long)] - user_id: String, - /// Role: owner, admin, or member - #[arg(long)] - role: String, - }, - /// Create an organization invite - Invite { - /// Organization ID or slug - org: String, - /// Invite email - #[arg(long)] - email: String, - /// Role for invited user: owner, admin, or member - #[arg(long, default_value = "member")] - role: String, - }, - /// List organization invites - Invites { - /// Organization ID or slug - org: String, - }, - /// Revoke an organization invite - RevokeInvite { - /// Organization ID or slug - org: String, - /// Invite ID - invite_id: String, - }, - /// Link a workspace to an organization - LinkWorkspace { - /// Organization ID or slug - org: String, - /// Workspace name - #[arg(long, short = 'w')] - workspace: String, - }, - /// Unlink a workspace from an organization - UnlinkWorkspace { - /// Organization ID or slug - org: String, - /// Workspace name - #[arg(long, short = 'w')] - workspace: String, - }, - /// List workspaces in an organization - Workspaces { - /// Organization ID or slug - org: String, - }, -} diff --git a/apps/zopp-cli/src/commands/mod.rs b/apps/zopp-cli/src/commands/mod.rs index dccd88a4..27f145bc 100644 --- a/apps/zopp-cli/src/commands/mod.rs +++ b/apps/zopp-cli/src/commands/mod.rs @@ -4,7 +4,6 @@ pub mod environment; pub mod group; pub mod invite; pub mod join; -pub mod organization; pub mod permission; pub mod principal; pub mod project; @@ -28,12 +27,6 @@ pub use group::{ }; pub use invite::{cmd_invite_create, cmd_invite_list, cmd_invite_revoke}; pub use join::cmd_join; -pub use organization::{ - cmd_org_add_member, cmd_org_create, cmd_org_get, cmd_org_invite, cmd_org_invites, - cmd_org_link_workspace, cmd_org_list, cmd_org_members, cmd_org_remove_member, - cmd_org_revoke_invite, cmd_org_set_role, cmd_org_unlink_workspace, cmd_org_update, - cmd_org_workspaces, -}; pub use permission::{ cmd_permission_effective, cmd_permission_get, cmd_permission_list, cmd_permission_remove, cmd_permission_set, cmd_user_permission_get, cmd_user_permission_list, diff --git a/apps/zopp-cli/src/commands/organization.rs b/apps/zopp-cli/src/commands/organization.rs deleted file mode 100644 index 8d06cc85..00000000 --- a/apps/zopp-cli/src/commands/organization.rs +++ /dev/null @@ -1,495 +0,0 @@ -//! Organization commands: list, create, members, invites -//! -//! These commands manage organizations in the cloud offering. - -use crate::grpc::{add_auth_metadata, setup_client}; -use zopp_proto::{ - get_organization_request, AddOrganizationMemberRequest, CreateOrganizationInviteRequest, - CreateOrganizationRequest, DeleteOrganizationInviteRequest, Empty, GetOrganizationRequest, - LinkWorkspaceToOrganizationRequest, ListOrganizationInvitesRequest, - ListOrganizationMembersRequest, ListOrganizationWorkspacesRequest, OrganizationRole, - RemoveOrganizationMemberRequest, UnlinkWorkspaceFromOrganizationRequest, - UpdateOrganizationMemberRoleRequest, UpdateOrganizationRequest, -}; - -/// Parse organization role from string -fn parse_role(role: &str) -> Result> { - match role.to_lowercase().as_str() { - "owner" => Ok(OrganizationRole::OrganizationOwner), - "admin" => Ok(OrganizationRole::OrganizationAdmin), - "member" => Ok(OrganizationRole::OrganizationMember), - _ => Err(format!("Invalid role: {}. Must be owner, admin, or member", role).into()), - } -} - -/// Format organization role for display -fn format_role(role: i32) -> &'static str { - match OrganizationRole::try_from(role) { - Ok(OrganizationRole::OrganizationOwner) => "owner", - Ok(OrganizationRole::OrganizationAdmin) => "admin", - Ok(OrganizationRole::OrganizationMember) => "member", - _ => "unknown", - } -} - -/// Format plan for display -fn format_plan(plan: i32) -> &'static str { - match zopp_proto::Plan::try_from(plan) { - Ok(zopp_proto::Plan::Free) => "free", - Ok(zopp_proto::Plan::Pro) => "pro", - Ok(zopp_proto::Plan::Enterprise) => "enterprise", - _ => "unknown", - } -} - -pub async fn cmd_org_list( - server: &str, - tls_ca_cert: Option<&std::path::Path>, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let mut request = tonic::Request::new(Empty {}); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/ListUserOrganizations", - )?; - - let response = client.list_user_organizations(request).await?.into_inner(); - - if response.organizations.is_empty() { - println!("No organizations found."); - } else { - println!("Organizations:"); - for org in response.organizations { - println!( - " {} ({}) - {} plan, {} seats", - org.name, - org.slug, - format_plan(org.plan), - org.seat_limit - ); - } - } - - Ok(()) -} - -pub async fn cmd_org_create( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - name: &str, - slug: Option<&str>, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - // Generate slug from name if not provided - let slug = slug - .map(|s| s.to_string()) - .unwrap_or_else(|| name.to_lowercase().replace(' ', "-")); - - let mut request = tonic::Request::new(CreateOrganizationRequest { - name: name.to_string(), - slug, - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/CreateOrganization", - )?; - - let response = client.create_organization(request).await?.into_inner(); - - println!("Organization created!"); - println!(" Name: {}", response.name); - println!(" Slug: {}", response.slug); - println!(" ID: {}", response.id); - - Ok(()) -} - -pub async fn cmd_org_get( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - org: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - // Determine if input is UUID or slug - let identifier = if uuid::Uuid::parse_str(org).is_ok() { - get_organization_request::Identifier::Id(org.to_string()) - } else { - get_organization_request::Identifier::Slug(org.to_string()) - }; - - let mut request = tonic::Request::new(GetOrganizationRequest { - identifier: Some(identifier), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/GetOrganization", - )?; - - let response = client.get_organization(request).await?.into_inner(); - - println!("Organization: {}", response.name); - println!(" ID: {}", response.id); - println!(" Slug: {}", response.slug); - println!(" Plan: {}", format_plan(response.plan)); - println!(" Seats: {}", response.seat_limit); - println!(" Created: {}", response.created_at); - - Ok(()) -} - -pub async fn cmd_org_update( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - org: &str, - name: Option<&str>, - slug: Option<&str>, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let mut request = tonic::Request::new(UpdateOrganizationRequest { - organization_id: org.to_string(), - name: name.map(|s| s.to_string()), - slug: slug.map(|s| s.to_string()), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/UpdateOrganization", - )?; - - let response = client.update_organization(request).await?.into_inner(); - - println!("Organization updated!"); - println!(" Name: {}", response.name); - println!(" Slug: {}", response.slug); - - Ok(()) -} - -pub async fn cmd_org_members( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - org: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let mut request = tonic::Request::new(ListOrganizationMembersRequest { - organization_id: org.to_string(), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/ListOrganizationMembers", - )?; - - let response = client - .list_organization_members(request) - .await? - .into_inner(); - - if response.members.is_empty() { - println!("No members found."); - } else { - println!("Members:"); - for member in response.members { - println!( - " {} ({}) - {}", - member.email, - member.user_id, - format_role(member.role) - ); - } - } - - Ok(()) -} - -pub async fn cmd_org_add_member( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - org: &str, - user_id: &str, - role: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let role_enum = parse_role(role)?; - - let mut request = tonic::Request::new(AddOrganizationMemberRequest { - organization_id: org.to_string(), - user_id: user_id.to_string(), - role: role_enum.into(), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/AddOrganizationMember", - )?; - - client.add_organization_member(request).await?; - - println!("Member added: {} as {}", user_id, role); - - Ok(()) -} - -pub async fn cmd_org_remove_member( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - org: &str, - user_id: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let mut request = tonic::Request::new(RemoveOrganizationMemberRequest { - organization_id: org.to_string(), - user_id: user_id.to_string(), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/RemoveOrganizationMember", - )?; - - client.remove_organization_member(request).await?; - - println!("Member removed: {}", user_id); - - Ok(()) -} - -pub async fn cmd_org_set_role( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - org: &str, - user_id: &str, - role: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let role_enum = parse_role(role)?; - - let mut request = tonic::Request::new(UpdateOrganizationMemberRoleRequest { - organization_id: org.to_string(), - user_id: user_id.to_string(), - role: role_enum.into(), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/UpdateOrganizationMemberRole", - )?; - - client.update_organization_member_role(request).await?; - - println!("Role updated: {} is now {}", user_id, role); - - Ok(()) -} - -pub async fn cmd_org_invite( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - org: &str, - email: &str, - role: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let role_enum = parse_role(role)?; - - let mut request = tonic::Request::new(CreateOrganizationInviteRequest { - organization_id: org.to_string(), - email: email.to_string(), - role: role_enum.into(), - expires_in_hours: Some(72), // Default 72 hours - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/CreateOrganizationInvite", - )?; - - let response = client - .create_organization_invite(request) - .await? - .into_inner(); - - println!("Invite created!"); - println!(" ID: {}", response.id); - println!(" Email: {}", response.email); - println!(" Role: {}", format_role(response.role)); - println!(" Expires: {}", response.expires_at); - - Ok(()) -} - -pub async fn cmd_org_invites( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - org: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let mut request = tonic::Request::new(ListOrganizationInvitesRequest { - organization_id: org.to_string(), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/ListOrganizationInvites", - )?; - - let response = client - .list_organization_invites(request) - .await? - .into_inner(); - - if response.invites.is_empty() { - println!("No pending invites."); - } else { - println!("Pending invites:"); - for invite in response.invites { - println!( - " {} - {} as {} (expires: {})", - invite.id, - invite.email, - format_role(invite.role), - invite.expires_at - ); - } - } - - Ok(()) -} - -pub async fn cmd_org_revoke_invite( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - _org: &str, - invite_id: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let mut request = tonic::Request::new(DeleteOrganizationInviteRequest { - invite_id: invite_id.to_string(), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/DeleteOrganizationInvite", - )?; - - client.delete_organization_invite(request).await?; - - println!("Invite revoked: {}", invite_id); - - Ok(()) -} - -pub async fn cmd_org_link_workspace( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - org: &str, - workspace_id: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let mut request = tonic::Request::new(LinkWorkspaceToOrganizationRequest { - organization_id: org.to_string(), - workspace_id: workspace_id.to_string(), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/LinkWorkspaceToOrganization", - )?; - - client.link_workspace_to_organization(request).await?; - - println!("Workspace '{}' linked to organization", workspace_id); - - Ok(()) -} - -pub async fn cmd_org_unlink_workspace( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - _org: &str, - workspace_id: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let mut request = tonic::Request::new(UnlinkWorkspaceFromOrganizationRequest { - workspace_id: workspace_id.to_string(), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/UnlinkWorkspaceFromOrganization", - )?; - - client.unlink_workspace_from_organization(request).await?; - - println!("Workspace '{}' unlinked from organization", workspace_id); - - Ok(()) -} - -pub async fn cmd_org_workspaces( - server: &str, - tls_ca_cert: Option<&std::path::Path>, - org: &str, -) -> Result<(), Box> { - let (mut client, principal, secrets) = setup_client(server, tls_ca_cert).await?; - - let mut request = tonic::Request::new(ListOrganizationWorkspacesRequest { - organization_id: org.to_string(), - }); - add_auth_metadata( - &mut request, - &principal, - &secrets, - "/zopp.ZoppService/ListOrganizationWorkspaces", - )?; - - let response = client - .list_organization_workspaces(request) - .await? - .into_inner(); - - if response.workspaces.is_empty() { - println!("No workspaces linked to this organization."); - } else { - println!("Workspaces:"); - for ws in response.workspaces { - let project_text = if ws.project_count == 1 { - "1 project".to_string() - } else { - format!("{} projects", ws.project_count) - }; - println!(" {} ({})", ws.name, project_text); - } - } - - Ok(()) -} diff --git a/apps/zopp-cli/src/main.rs b/apps/zopp-cli/src/main.rs index 97c8221c..b57843b7 100644 --- a/apps/zopp-cli/src/main.rs +++ b/apps/zopp-cli/src/main.rs @@ -11,8 +11,8 @@ mod passphrase; use cli::{ AuditCommand, Cli, Command, DiffCommand, EnvironmentCommand, GroupCommand, InviteCommand, - OrganizationCommand, PermissionCommand, PrincipalCommand, ProjectCommand, SecretCommand, - ShellType, SyncCommand, WorkspaceCommand, + PermissionCommand, PrincipalCommand, ProjectCommand, SecretCommand, ShellType, SyncCommand, + WorkspaceCommand, }; use commands::*; use config::{resolve_context, resolve_workspace, resolve_workspace_project}; @@ -1002,82 +1002,6 @@ async fn main() -> Result<(), Box> { .await?; } }, - Command::Org { org_cmd } => match org_cmd { - OrganizationCommand::List => { - cmd_org_list(&cli.server, cli.tls_ca_cert.as_deref()).await?; - } - OrganizationCommand::Create { name, slug } => { - cmd_org_create( - &cli.server, - cli.tls_ca_cert.as_deref(), - &name, - slug.as_deref(), - ) - .await?; - } - OrganizationCommand::Get { org } => { - cmd_org_get(&cli.server, cli.tls_ca_cert.as_deref(), &org).await?; - } - OrganizationCommand::Update { org, name, slug } => { - cmd_org_update( - &cli.server, - cli.tls_ca_cert.as_deref(), - &org, - name.as_deref(), - slug.as_deref(), - ) - .await?; - } - OrganizationCommand::Members { org } => { - cmd_org_members(&cli.server, cli.tls_ca_cert.as_deref(), &org).await?; - } - OrganizationCommand::AddMember { org, user_id, role } => { - cmd_org_add_member( - &cli.server, - cli.tls_ca_cert.as_deref(), - &org, - &user_id, - &role, - ) - .await?; - } - OrganizationCommand::RemoveMember { org, user_id } => { - cmd_org_remove_member(&cli.server, cli.tls_ca_cert.as_deref(), &org, &user_id) - .await?; - } - OrganizationCommand::SetRole { org, user_id, role } => { - cmd_org_set_role( - &cli.server, - cli.tls_ca_cert.as_deref(), - &org, - &user_id, - &role, - ) - .await?; - } - OrganizationCommand::Invite { org, email, role } => { - cmd_org_invite(&cli.server, cli.tls_ca_cert.as_deref(), &org, &email, &role) - .await?; - } - OrganizationCommand::Invites { org } => { - cmd_org_invites(&cli.server, cli.tls_ca_cert.as_deref(), &org).await?; - } - OrganizationCommand::RevokeInvite { org, invite_id } => { - cmd_org_revoke_invite(&cli.server, cli.tls_ca_cert.as_deref(), &org, &invite_id) - .await?; - } - OrganizationCommand::LinkWorkspace { org, workspace } => { - cmd_org_link_workspace(&cli.server, cli.tls_ca_cert.as_deref(), &org, &workspace) - .await?; - } - OrganizationCommand::UnlinkWorkspace { org, workspace } => { - cmd_org_unlink_workspace(&cli.server, cli.tls_ca_cert.as_deref(), &org, &workspace) - .await?; - } - OrganizationCommand::Workspaces { org } => { - cmd_org_workspaces(&cli.server, cli.tls_ca_cert.as_deref(), &org).await?; - } - }, Command::Diff { diff_cmd } => match diff_cmd { DiffCommand::K8s { namespace, diff --git a/apps/zopp-server/Cargo.toml b/apps/zopp-server/Cargo.toml index 2d842ab1..f0938713 100644 --- a/apps/zopp-server/Cargo.toml +++ b/apps/zopp-server/Cargo.toml @@ -25,7 +25,6 @@ zopp-crypto = { path = "../../crates/zopp-crypto", version = "0.1.0" } zopp-events = { path = "../../crates/zopp-events", version = "0.1.0" } zopp-events-memory = { path = "../../crates/zopp-events-memory", version = "0.1.0" } zopp-events-postgres = { path = "../../crates/zopp-events-postgres", version = "0.1.0" } -zopp-billing = { path = "../../crates/zopp-billing", version = "0.1.1" } url = { workspace = true } uuid = { workspace = true } thiserror = { workspace = true } diff --git a/apps/zopp-server/src/backend.rs b/apps/zopp-server/src/backend.rs index 4a7ca4a8..003a3203 100644 --- a/apps/zopp-server/src/backend.rs +++ b/apps/zopp-server/src/backend.rs @@ -1138,242 +1138,6 @@ impl Store for StoreBackend { StoreBackend::Postgres(s) => s.mark_user_verified(user_id).await, } } - - // Organization methods - delegate to underlying store - async fn create_organization( - &self, - params: &CreateOrganizationParams, - ) -> Result { - match self { - StoreBackend::Sqlite(s) => s.create_organization(params).await, - StoreBackend::Postgres(s) => s.create_organization(params).await, - } - } - - async fn get_organization(&self, org_id: &OrganizationId) -> Result { - match self { - StoreBackend::Sqlite(s) => s.get_organization(org_id).await, - StoreBackend::Postgres(s) => s.get_organization(org_id).await, - } - } - - async fn get_organization_by_slug(&self, slug: &str) -> Result { - match self { - StoreBackend::Sqlite(s) => s.get_organization_by_slug(slug).await, - StoreBackend::Postgres(s) => s.get_organization_by_slug(slug).await, - } - } - - async fn list_user_organizations( - &self, - user_id: &UserId, - ) -> Result, StoreError> { - match self { - StoreBackend::Sqlite(s) => s.list_user_organizations(user_id).await, - StoreBackend::Postgres(s) => s.list_user_organizations(user_id).await, - } - } - - async fn update_organization( - &self, - org_id: &OrganizationId, - name: Option, - slug: Option, - ) -> Result<(), StoreError> { - match self { - StoreBackend::Sqlite(s) => { - s.update_organization(org_id, name.clone(), slug.clone()) - .await - } - StoreBackend::Postgres(s) => s.update_organization(org_id, name, slug).await, - } - } - - async fn set_organization_stripe_customer( - &self, - org_id: &OrganizationId, - stripe_customer_id: &str, - ) -> Result<(), StoreError> { - match self { - StoreBackend::Sqlite(s) => { - s.set_organization_stripe_customer(org_id, stripe_customer_id) - .await - } - StoreBackend::Postgres(s) => { - s.set_organization_stripe_customer(org_id, stripe_customer_id) - .await - } - } - } - - async fn set_organization_plan( - &self, - org_id: &OrganizationId, - plan: Plan, - seat_limit: i32, - ) -> Result<(), StoreError> { - match self { - StoreBackend::Sqlite(s) => s.set_organization_plan(org_id, plan, seat_limit).await, - StoreBackend::Postgres(s) => s.set_organization_plan(org_id, plan, seat_limit).await, - } - } - - async fn delete_organization(&self, org_id: &OrganizationId) -> Result<(), StoreError> { - match self { - StoreBackend::Sqlite(s) => s.delete_organization(org_id).await, - StoreBackend::Postgres(s) => s.delete_organization(org_id).await, - } - } - - async fn add_organization_member( - &self, - org_id: &OrganizationId, - user_id: &UserId, - role: OrganizationRole, - invited_by: Option, - ) -> Result<(), StoreError> { - match self { - StoreBackend::Sqlite(s) => { - s.add_organization_member(org_id, user_id, role, invited_by.clone()) - .await - } - StoreBackend::Postgres(s) => { - s.add_organization_member(org_id, user_id, role, invited_by) - .await - } - } - } - - async fn get_organization_member( - &self, - org_id: &OrganizationId, - user_id: &UserId, - ) -> Result { - match self { - StoreBackend::Sqlite(s) => s.get_organization_member(org_id, user_id).await, - StoreBackend::Postgres(s) => s.get_organization_member(org_id, user_id).await, - } - } - - async fn list_organization_members( - &self, - org_id: &OrganizationId, - ) -> Result, StoreError> { - match self { - StoreBackend::Sqlite(s) => s.list_organization_members(org_id).await, - StoreBackend::Postgres(s) => s.list_organization_members(org_id).await, - } - } - - async fn update_organization_member_role( - &self, - org_id: &OrganizationId, - user_id: &UserId, - role: OrganizationRole, - ) -> Result<(), StoreError> { - match self { - StoreBackend::Sqlite(s) => { - s.update_organization_member_role(org_id, user_id, role) - .await - } - StoreBackend::Postgres(s) => { - s.update_organization_member_role(org_id, user_id, role) - .await - } - } - } - - async fn remove_organization_member( - &self, - org_id: &OrganizationId, - user_id: &UserId, - ) -> Result<(), StoreError> { - match self { - StoreBackend::Sqlite(s) => s.remove_organization_member(org_id, user_id).await, - StoreBackend::Postgres(s) => s.remove_organization_member(org_id, user_id).await, - } - } - - async fn count_organization_members(&self, org_id: &OrganizationId) -> Result { - match self { - StoreBackend::Sqlite(s) => s.count_organization_members(org_id).await, - StoreBackend::Postgres(s) => s.count_organization_members(org_id).await, - } - } - - async fn create_organization_invite( - &self, - params: &CreateOrganizationInviteParams, - ) -> Result { - match self { - StoreBackend::Sqlite(s) => s.create_organization_invite(params).await, - StoreBackend::Postgres(s) => s.create_organization_invite(params).await, - } - } - - async fn get_organization_invite( - &self, - invite_id: &OrganizationInviteId, - ) -> Result { - match self { - StoreBackend::Sqlite(s) => s.get_organization_invite(invite_id).await, - StoreBackend::Postgres(s) => s.get_organization_invite(invite_id).await, - } - } - - async fn get_organization_invite_by_token( - &self, - token_hash: &str, - ) -> Result { - match self { - StoreBackend::Sqlite(s) => s.get_organization_invite_by_token(token_hash).await, - StoreBackend::Postgres(s) => s.get_organization_invite_by_token(token_hash).await, - } - } - - async fn list_organization_invites( - &self, - org_id: &OrganizationId, - ) -> Result, StoreError> { - match self { - StoreBackend::Sqlite(s) => s.list_organization_invites(org_id).await, - StoreBackend::Postgres(s) => s.list_organization_invites(org_id).await, - } - } - - async fn delete_organization_invite( - &self, - invite_id: &OrganizationInviteId, - ) -> Result<(), StoreError> { - match self { - StoreBackend::Sqlite(s) => s.delete_organization_invite(invite_id).await, - StoreBackend::Postgres(s) => s.delete_organization_invite(invite_id).await, - } - } - - async fn set_workspace_organization( - &self, - workspace_id: &WorkspaceId, - org_id: Option, - ) -> Result<(), StoreError> { - match self { - StoreBackend::Sqlite(s) => { - s.set_workspace_organization(workspace_id, org_id.clone()) - .await - } - StoreBackend::Postgres(s) => s.set_workspace_organization(workspace_id, org_id).await, - } - } - - async fn list_organization_workspaces( - &self, - org_id: &OrganizationId, - ) -> Result, StoreError> { - match self { - StoreBackend::Sqlite(s) => s.list_organization_workspaces(org_id).await, - StoreBackend::Postgres(s) => s.list_organization_workspaces(org_id).await, - } - } } #[async_trait::async_trait] diff --git a/apps/zopp-server/src/handlers/billing.rs b/apps/zopp-server/src/handlers/billing.rs deleted file mode 100644 index 985c6230..00000000 --- a/apps/zopp-server/src/handlers/billing.rs +++ /dev/null @@ -1,326 +0,0 @@ -//! Billing handlers: subscription, payments, checkout, portal -//! -//! These handlers manage billing operations for organizations. - -use tonic::{Request, Response, Status}; -use url::Url; -use uuid::Uuid; -use zopp_proto::{ - BillingPortalSession, CheckoutSession, CreateBillingPortalSessionRequest, - CreateCheckoutSessionRequest, GetSubscriptionRequest, ListPaymentsRequest, PaymentList, Plan, - Subscription, SubscriptionStatus, -}; -use zopp_storage::{OrganizationId, Store}; - -use crate::server::{extract_signature, ZoppServer}; - -/// Validate that a redirect URL belongs to a trusted domain. -/// -/// SECURITY: This prevents open redirect attacks where an attacker could -/// redirect users to a malicious site after billing operations. -fn validate_redirect_url(url_str: &str) -> Result { - let url = Url::parse(url_str).map_err(|_| Status::invalid_argument("Invalid URL format"))?; - - // Only allow HTTPS URLs (except localhost for development) - let scheme = url.scheme(); - if scheme != "https" && !(scheme == "http" && is_localhost(&url)) { - return Err(Status::invalid_argument("Only HTTPS URLs are allowed")); - } - - // Validate against trusted domains - // In production, this should be configurable via environment variable - let trusted_domains = ["app.zopp.dev", "zopp.dev", "localhost", "127.0.0.1"]; - - let host = url - .host_str() - .ok_or_else(|| Status::invalid_argument("URL must have a host"))?; - - // Check if host matches or is a subdomain of a trusted domain - let is_trusted = trusted_domains - .iter() - .any(|&domain| host == domain || host.ends_with(&format!(".{}", domain))); - - if !is_trusted { - return Err(Status::invalid_argument( - "Redirect URL must point to a trusted domain", - )); - } - - Ok(url) -} - -/// Check if URL points to localhost (for development) -fn is_localhost(url: &Url) -> bool { - matches!(url.host_str(), Some("localhost") | Some("127.0.0.1")) -} - -/// Append a query parameter to a URL, handling existing query strings correctly. -fn append_query_param(url: &Url, key: &str, value: &str) -> String { - let mut url = url.clone(); - url.query_pairs_mut().append_pair(key, value); - url.to_string() -} - -/// Get subscription for an organization -pub async fn get_subscription( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/GetSubscription", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - // Parse organization ID - let org_id = OrganizationId( - Uuid::parse_str(&req.organization_id) - .map_err(|_| Status::invalid_argument("Invalid organization ID"))?, - ); - - // Get the organization - let org = server - .store - .get_organization(&org_id) - .await - .map_err(|e| Status::internal(format!("Failed to get organization: {}", e)))?; - - // Verify the user is a member of the organization - let user_id = principal - .user_id - .ok_or_else(|| Status::permission_denied("Service accounts cannot access billing"))?; - - let _member = server - .store - .get_organization_member(&org_id, &user_id) - .await - .map_err(|_| Status::permission_denied("Not a member of this organization"))?; - - // Convert plan to proto - let plan = match org.plan { - zopp_storage::Plan::Free => Plan::Free, - zopp_storage::Plan::Pro => Plan::Pro, - zopp_storage::Plan::Enterprise => Plan::Enterprise, - }; - - // If organization has no subscription, return a "no subscription" response - let subscription_id = org.stripe_subscription_id.clone().unwrap_or_default(); - if subscription_id.is_empty() && org.plan == zopp_storage::Plan::Free { - return Err(Status::not_found("No active subscription")); - } - - // Build subscription response from organization data - let now = chrono::Utc::now(); - let period_end = now + chrono::Duration::days(30); - - Ok(Response::new(Subscription { - id: format!("sub_{}", org_id.0), - organization_id: org_id.0.to_string(), - stripe_subscription_id: subscription_id, - stripe_price_id: match org.plan { - zopp_storage::Plan::Free => String::new(), - zopp_storage::Plan::Pro => "price_pro".to_string(), - zopp_storage::Plan::Enterprise => "price_enterprise".to_string(), - }, - plan: plan.into(), - status: if org.trial_ends_at.is_some() && org.trial_ends_at.unwrap() > now { - SubscriptionStatus::SubscriptionTrialing.into() - } else { - SubscriptionStatus::SubscriptionActive.into() - }, - current_period_start: now.to_rfc3339(), - current_period_end: period_end.to_rfc3339(), - cancel_at_period_end: false, - canceled_at: None, - created_at: org.created_at.to_rfc3339(), - updated_at: org.updated_at.to_rfc3339(), - })) -} - -/// List payments for an organization -pub async fn list_payments( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/ListPayments", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - // Parse organization ID - let org_id = OrganizationId( - Uuid::parse_str(&req.organization_id) - .map_err(|_| Status::invalid_argument("Invalid organization ID"))?, - ); - - // Verify the user is a member of the organization - let user_id = principal - .user_id - .ok_or_else(|| Status::permission_denied("Service accounts cannot access billing"))?; - - let _member = server - .store - .get_organization_member(&org_id, &user_id) - .await - .map_err(|_| Status::permission_denied("Not a member of this organization"))?; - - // TODO: Implement payment history storage and retrieval - // For now, return empty list - Ok(Response::new(PaymentList { payments: vec![] })) -} - -/// Create a checkout session for upgrading to a paid plan -pub async fn create_checkout_session( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/CreateCheckoutSession", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - // Parse organization ID - let org_id = OrganizationId( - Uuid::parse_str(&req.organization_id) - .map_err(|_| Status::invalid_argument("Invalid organization ID"))?, - ); - - // Get the organization - let org = server - .store - .get_organization(&org_id) - .await - .map_err(|e| Status::internal(format!("Failed to get organization: {}", e)))?; - - // Verify the user is an admin of the organization - let user_id = principal - .user_id - .ok_or_else(|| Status::permission_denied("Service accounts cannot access billing"))?; - - let member = server - .store - .get_organization_member(&org_id, &user_id) - .await - .map_err(|_| Status::permission_denied("Not a member of this organization"))?; - - // Only owners and admins can create checkout sessions - if member.role != zopp_storage::OrganizationRole::Owner - && member.role != zopp_storage::OrganizationRole::Admin - { - return Err(Status::permission_denied( - "Only organization owners and admins can manage billing", - )); - } - - // Check if organization has a Stripe customer - if org.stripe_customer_id.is_none() { - return Err(Status::failed_precondition( - "Organization has no billing customer. Contact support to set up billing.", - )); - } - - // SECURITY: Validate success_url to prevent open redirect attacks - let success_url = validate_redirect_url(&req.success_url)?; - - // TODO: Integrate with billing service to create actual Stripe checkout session - // For now, return a mock URL for development - let session_id = format!("cs_mock_{}", uuid::Uuid::new_v4()); - let checkout_url = append_query_param(&success_url, "session_id", &session_id); - - Ok(Response::new(CheckoutSession { url: checkout_url })) -} - -/// Create a billing portal session for managing subscription -pub async fn create_billing_portal_session( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/CreateBillingPortalSession", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - // Parse organization ID - let org_id = OrganizationId( - Uuid::parse_str(&req.organization_id) - .map_err(|_| Status::invalid_argument("Invalid organization ID"))?, - ); - - // Get the organization - let org = server - .store - .get_organization(&org_id) - .await - .map_err(|e| Status::internal(format!("Failed to get organization: {}", e)))?; - - // Verify the user is an admin of the organization - let user_id = principal - .user_id - .ok_or_else(|| Status::permission_denied("Service accounts cannot access billing"))?; - - let member = server - .store - .get_organization_member(&org_id, &user_id) - .await - .map_err(|_| Status::permission_denied("Not a member of this organization"))?; - - // Only owners and admins can access the billing portal - if member.role != zopp_storage::OrganizationRole::Owner - && member.role != zopp_storage::OrganizationRole::Admin - { - return Err(Status::permission_denied( - "Only organization owners and admins can manage billing", - )); - } - - // Check if organization has a Stripe customer - if org.stripe_customer_id.is_none() { - return Err(Status::failed_precondition( - "Organization has no billing customer. Contact support to set up billing.", - )); - } - - // SECURITY: Validate return_url to prevent open redirect attacks - let return_url = validate_redirect_url(&req.return_url)?; - - // TODO: Integrate with billing service to create actual Stripe portal session - // For now, return the validated return URL for development - Ok(Response::new(BillingPortalSession { - url: return_url.to_string(), - })) -} diff --git a/apps/zopp-server/src/handlers/mod.rs b/apps/zopp-server/src/handlers/mod.rs index 619d4186..59d125c3 100644 --- a/apps/zopp-server/src/handlers/mod.rs +++ b/apps/zopp-server/src/handlers/mod.rs @@ -9,7 +9,7 @@ //! - projects: create, list, get, delete //! - environments: create, list, get, delete //! - secrets: upsert, get, list, delete, watch -//! - generic_permissions: NEW unified permission API (4 methods replacing 36) +//! - generic_permissions: unified permission API (4 methods replacing 36) //! - permissions: principal permissions at workspace/project/environment levels (DEPRECATED) //! - groups: group CRUD + membership //! - group_permissions: group permissions at all levels (DEPRECATED) @@ -18,13 +18,11 @@ pub mod audit; pub mod auth; -pub mod billing; pub mod environments; pub mod generic_permissions; pub mod group_permissions; pub mod groups; pub mod invites; -pub mod organizations; pub mod permissions; pub mod principals; pub mod projects; @@ -678,176 +676,4 @@ impl ZoppService for ZoppServer { } // CountAuditLogs removed - use ListAuditLogs response.total_count instead - - // ───────────────────────────────────── Organizations ───────────────────────────────────── - - async fn create_organization( - &self, - request: Request, - ) -> Result, Status> { - organizations::create_organization(self, request).await - } - - async fn get_organization( - &self, - request: Request, - ) -> Result, Status> { - organizations::get_organization(self, request).await - } - - async fn list_user_organizations( - &self, - request: Request, - ) -> Result, Status> { - organizations::list_user_organizations(self, request).await - } - - async fn update_organization( - &self, - request: Request, - ) -> Result, Status> { - organizations::update_organization(self, request).await - } - - async fn delete_organization( - &self, - request: Request, - ) -> Result, Status> { - organizations::delete_organization(self, request).await - } - - // ───────────────────────────────────── Organization Members ───────────────────────────────────── - - async fn upsert_organization_member( - &self, - request: Request, - ) -> Result, Status> { - organizations::upsert_organization_member(self, request).await - } - - // DEPRECATED: Use UpsertOrganizationMember instead - async fn add_organization_member( - &self, - request: Request, - ) -> Result, Status> { - organizations::add_organization_member(self, request).await - } - - async fn get_organization_member( - &self, - request: Request, - ) -> Result, Status> { - organizations::get_organization_member(self, request).await - } - - async fn list_organization_members( - &self, - request: Request, - ) -> Result, Status> { - organizations::list_organization_members(self, request).await - } - - async fn update_organization_member_role( - &self, - request: Request, - ) -> Result, Status> { - organizations::update_organization_member_role(self, request).await - } - - async fn remove_organization_member( - &self, - request: Request, - ) -> Result, Status> { - organizations::remove_organization_member(self, request).await - } - - // ───────────────────────────────────── Organization Invites ───────────────────────────────────── - - async fn create_organization_invite( - &self, - request: Request, - ) -> Result, Status> { - organizations::create_organization_invite(self, request).await - } - - async fn get_organization_invite( - &self, - request: Request, - ) -> Result, Status> { - organizations::get_organization_invite(self, request).await - } - - async fn list_organization_invites( - &self, - request: Request, - ) -> Result, Status> { - organizations::list_organization_invites(self, request).await - } - - async fn accept_organization_invite( - &self, - request: Request, - ) -> Result, Status> { - organizations::accept_organization_invite(self, request).await - } - - async fn delete_organization_invite( - &self, - request: Request, - ) -> Result, Status> { - organizations::delete_organization_invite(self, request).await - } - - // ───────────────────────────────────── Organization Workspaces ───────────────────────────────────── - - async fn link_workspace_to_organization( - &self, - request: Request, - ) -> Result, Status> { - organizations::link_workspace_to_organization(self, request).await - } - - async fn unlink_workspace_from_organization( - &self, - request: Request, - ) -> Result, Status> { - organizations::unlink_workspace_from_organization(self, request).await - } - - async fn list_organization_workspaces( - &self, - request: Request, - ) -> Result, Status> { - organizations::list_organization_workspaces(self, request).await - } - - // ───────────────────────────────────── Billing ───────────────────────────────────── - - async fn get_subscription( - &self, - request: Request, - ) -> Result, Status> { - billing::get_subscription(self, request).await - } - - async fn list_payments( - &self, - request: Request, - ) -> Result, Status> { - billing::list_payments(self, request).await - } - - async fn create_checkout_session( - &self, - request: Request, - ) -> Result, Status> { - billing::create_checkout_session(self, request).await - } - - async fn create_billing_portal_session( - &self, - request: Request, - ) -> Result, Status> { - billing::create_billing_portal_session(self, request).await - } } diff --git a/apps/zopp-server/src/handlers/organizations.rs b/apps/zopp-server/src/handlers/organizations.rs deleted file mode 100644 index 014dfc0b..00000000 --- a/apps/zopp-server/src/handlers/organizations.rs +++ /dev/null @@ -1,1398 +0,0 @@ -//! Organization handlers for gRPC service implementation -//! -//! Implements organization management RPCs: -//! - create_organization -//! - get_organization -//! - list_user_organizations -//! - update_organization -//! - delete_organization -//! - add_organization_member -//! - get_organization_member -//! - list_organization_members -//! - update_organization_member_role -//! - remove_organization_member -//! - create_organization_invite -//! - get_organization_invite -//! - list_organization_invites -//! - accept_organization_invite -//! - delete_organization_invite -//! - link_workspace_to_organization -//! - unlink_workspace_from_organization -//! - list_organization_workspaces - -use chrono::{Duration, Utc}; -use rand::Rng; -use sha2::{Digest, Sha256}; -use tonic::{Request, Response, Status}; -use uuid::Uuid; - -use crate::server::{extract_signature, ZoppServer}; -use zopp_proto::*; -use zopp_storage::{OrganizationId, OrganizationInviteId, Store, UserId, WorkspaceId}; - -// ───────────────────────────────────── Organizations ───────────────────────────────────── - -pub async fn create_organization( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/CreateOrganization", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot create organizations"))?; - - if req.name.is_empty() { - return Err(Status::invalid_argument("Organization name is required")); - } - if req.slug.is_empty() { - return Err(Status::invalid_argument("Organization slug is required")); - } - - // Validate slug format (lowercase alphanumeric and hyphens) - if !req - .slug - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - { - return Err(Status::invalid_argument( - "Slug must contain only lowercase letters, numbers, and hyphens", - )); - } - - let params = zopp_storage::CreateOrganizationParams { - name: req.name, - slug: req.slug, - owner_user_id: user_id.clone(), - plan: zopp_storage::Plan::Free, - }; - - let org_id = server - .store - .create_organization(¶ms) - .await - .map_err(|e| match e { - zopp_storage::StoreError::AlreadyExists => { - Status::already_exists("Organization with this slug already exists") - } - e => Status::internal(format!("Failed to create organization: {}", e)), - })?; - - // Fetch the created organization to return full details - let org = server - .store - .get_organization(&org_id) - .await - .map_err(|e| Status::internal(format!("Failed to get organization: {}", e)))?; - - let member_count = server - .store - .count_organization_members(&org_id) - .await - .unwrap_or(1); - - Ok(Response::new(organization_to_proto(&org, member_count))) -} - -pub async fn get_organization( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/GetOrganization", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot access organizations"))?; - - let org = match req.identifier { - Some(get_organization_request::Identifier::Id(id)) => { - let org_id = parse_organization_id(&id)?; - server.store.get_organization(&org_id).await - } - Some(get_organization_request::Identifier::Slug(slug)) => { - server.store.get_organization_by_slug(&slug).await - } - None => return Err(Status::invalid_argument("Organization ID or slug required")), - } - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Organization not found"), - e => Status::internal(format!("Failed to get organization: {}", e)), - })?; - - // Check if user is a member of the organization - server - .store - .get_organization_member(&org.id, &user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - let member_count = server - .store - .count_organization_members(&org.id) - .await - .unwrap_or(0); - - Ok(Response::new(organization_to_proto(&org, member_count))) -} - -pub async fn list_user_organizations( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = *request.get_ref(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/ListUserOrganizations", - &req_for_verify, - &request_hash, - ) - .await?; - - let user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot list organizations"))?; - - let orgs = server - .store - .list_user_organizations(&user_id) - .await - .map_err(|e| Status::internal(format!("Failed to list organizations: {}", e)))?; - - let mut proto_orgs = Vec::with_capacity(orgs.len()); - for org in orgs { - let member_count = server - .store - .count_organization_members(&org.id) - .await - .unwrap_or(0); - proto_orgs.push(organization_to_proto(&org, member_count)); - } - - Ok(Response::new(OrganizationList { - organizations: proto_orgs, - })) -} - -pub async fn update_organization( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/UpdateOrganization", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot update organizations"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - - // Check if user is an admin or owner - let membership = server - .store - .get_organization_member(&org_id, &user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - if membership.role != zopp_storage::OrganizationRole::Owner - && membership.role != zopp_storage::OrganizationRole::Admin - { - return Err(Status::permission_denied( - "Only organization owners and admins can update organization settings", - )); - } - - // Validate slug format if provided - if let Some(ref slug) = req.slug { - if !slug - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - { - return Err(Status::invalid_argument( - "Slug must contain only lowercase letters, numbers, and hyphens", - )); - } - } - - server - .store - .update_organization(&org_id, req.name, req.slug) - .await - .map_err(|e| match e { - zopp_storage::StoreError::AlreadyExists => { - Status::already_exists("Organization with this slug already exists") - } - zopp_storage::StoreError::NotFound => Status::not_found("Organization not found"), - e => Status::internal(format!("Failed to update organization: {}", e)), - })?; - - let org = server - .store - .get_organization(&org_id) - .await - .map_err(|e| Status::internal(format!("Failed to get organization: {}", e)))?; - - let member_count = server - .store - .count_organization_members(&org_id) - .await - .unwrap_or(0); - - Ok(Response::new(organization_to_proto(&org, member_count))) -} - -pub async fn delete_organization( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/DeleteOrganization", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot delete organizations"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - - // Only owner can delete organization - let membership = server - .store - .get_organization_member(&org_id, &user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - if membership.role != zopp_storage::OrganizationRole::Owner { - return Err(Status::permission_denied( - "Only organization owner can delete the organization", - )); - } - - server - .store - .delete_organization(&org_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Organization not found"), - e => Status::internal(format!("Failed to delete organization: {}", e)), - })?; - - Ok(Response::new(Empty {})) -} - -// ───────────────────────────────────── Organization Members ───────────────────────────────────── - -pub async fn add_organization_member( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/AddOrganizationMember", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot manage members"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - let user_id = parse_user_id(&req.user_id)?; - let role = proto_role_to_storage(req.role())?; - - // Check if requester is admin or owner - let membership = server - .store - .get_organization_member(&org_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - if membership.role != zopp_storage::OrganizationRole::Owner - && membership.role != zopp_storage::OrganizationRole::Admin - { - return Err(Status::permission_denied( - "Only organization owners and admins can add members", - )); - } - - // Can't add owner role directly (must be set via owner transfer) - if role == zopp_storage::OrganizationRole::Owner { - return Err(Status::invalid_argument( - "Cannot directly add a member as owner", - )); - } - - server - .store - .add_organization_member(&org_id, &user_id, role, Some(caller_user_id)) - .await - .map_err(|e| match e { - zopp_storage::StoreError::AlreadyExists => { - Status::already_exists("User is already a member of this organization") - } - e => Status::internal(format!("Failed to add member: {}", e)), - })?; - - Ok(Response::new(Empty {})) -} - -/// UpsertOrganizationMember: Add a new member or update an existing member's role in one call. -/// This replaces the separate AddOrganizationMember and UpdateOrganizationMemberRole calls. -pub async fn upsert_organization_member( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/UpsertOrganizationMember", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot manage members"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - let user_id = parse_user_id(&req.user_id)?; - let role = proto_role_to_storage(req.role())?; - - // Check if requester is admin or owner - let caller_membership = server - .store - .get_organization_member(&org_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - // Can't upsert with owner role (must be set via owner transfer) - if role == zopp_storage::OrganizationRole::Owner { - return Err(Status::invalid_argument( - "Cannot set member role to owner directly", - )); - } - - // Check if the target user is already a member - let existing_member = server - .store - .get_organization_member(&org_id, &user_id) - .await; - - match existing_member { - Ok(member) => { - // User is already a member - this is an update operation - // Only owner can change roles - if caller_membership.role != zopp_storage::OrganizationRole::Owner { - return Err(Status::permission_denied( - "Only organization owner can change member roles", - )); - } - - // Can't change own role - if user_id == caller_user_id { - return Err(Status::invalid_argument("Cannot change your own role")); - } - - // Can't change owner's role - if member.role == zopp_storage::OrganizationRole::Owner { - return Err(Status::permission_denied("Cannot change owner's role")); - } - - server - .store - .update_organization_member_role(&org_id, &user_id, role) - .await - .map_err(|e| Status::internal(format!("Failed to update member role: {}", e)))?; - } - Err(zopp_storage::StoreError::NotFound) => { - // User is not a member - this is an add operation - // Admin or owner can add members - if caller_membership.role != zopp_storage::OrganizationRole::Owner - && caller_membership.role != zopp_storage::OrganizationRole::Admin - { - return Err(Status::permission_denied( - "Only organization owners and admins can add members", - )); - } - - server - .store - .add_organization_member(&org_id, &user_id, role, Some(caller_user_id)) - .await - .map_err(|e| Status::internal(format!("Failed to add member: {}", e)))?; - } - Err(e) => { - return Err(Status::internal(format!( - "Failed to check existing membership: {}", - e - ))); - } - } - - // Fetch and return the updated/created member - let member = server - .store - .get_organization_member(&org_id, &user_id) - .await - .map_err(|e| Status::internal(format!("Failed to get member: {}", e)))?; - - let user = server - .store - .get_user_by_id(&member.user_id) - .await - .map_err(|e| Status::internal(format!("Failed to get user: {}", e)))?; - - Ok(Response::new(OrganizationMember { - user_id: member.user_id.0.to_string(), - email: user.email, - role: storage_role_to_proto(member.role) as i32, - invited_by: member.invited_by.map(|id| id.0.to_string()), - joined_at: member.joined_at.to_rfc3339(), - })) -} - -pub async fn get_organization_member( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/GetOrganizationMember", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot access members"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - let user_id = parse_user_id(&req.user_id)?; - - // Check if requester is a member - server - .store - .get_organization_member(&org_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - let member = server - .store - .get_organization_member(&org_id, &user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Member not found"), - e => Status::internal(format!("Failed to get member: {}", e)), - })?; - - let user = server - .store - .get_user_by_id(&user_id) - .await - .map_err(|e| Status::internal(format!("Failed to get user: {}", e)))?; - - Ok(Response::new(member_to_proto(&member, &user.email))) -} - -pub async fn list_organization_members( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/ListOrganizationMembers", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot list members"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - - // Check if requester is a member - server - .store - .get_organization_member(&org_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - let members = server - .store - .list_organization_members(&org_id) - .await - .map_err(|e| Status::internal(format!("Failed to list members: {}", e)))?; - - let mut proto_members = Vec::with_capacity(members.len()); - for member in members { - let user = server.store.get_user_by_id(&member.user_id).await.ok(); - let email = user.map(|u| u.email).unwrap_or_default(); - proto_members.push(member_to_proto(&member, &email)); - } - - Ok(Response::new(OrganizationMemberList { - members: proto_members, - })) -} - -pub async fn update_organization_member_role( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/UpdateOrganizationMemberRole", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot update member roles"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - let user_id = parse_user_id(&req.user_id)?; - let new_role = proto_role_to_storage(req.role())?; - - // Check if requester is owner - let membership = server - .store - .get_organization_member(&org_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - // Only owner can change roles - if membership.role != zopp_storage::OrganizationRole::Owner { - return Err(Status::permission_denied( - "Only organization owner can change member roles", - )); - } - - // Can't change owner's own role - if user_id == caller_user_id { - return Err(Status::invalid_argument("Cannot change your own role")); - } - - // Can't promote to owner (must use owner transfer) - if new_role == zopp_storage::OrganizationRole::Owner { - return Err(Status::invalid_argument( - "Cannot promote member to owner directly", - )); - } - - server - .store - .update_organization_member_role(&org_id, &user_id, new_role) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Member not found"), - e => Status::internal(format!("Failed to update member role: {}", e)), - })?; - - Ok(Response::new(Empty {})) -} - -pub async fn remove_organization_member( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/RemoveOrganizationMember", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot remove members"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - let user_id = parse_user_id(&req.user_id)?; - - // Get requester's membership - let membership = server - .store - .get_organization_member(&org_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - // Users can remove themselves, but owner/admin can remove others - if user_id != caller_user_id - && membership.role != zopp_storage::OrganizationRole::Owner - && membership.role != zopp_storage::OrganizationRole::Admin - { - return Err(Status::permission_denied( - "Only organization owners and admins can remove other members", - )); - } - - // Get target member's membership - let target_membership = server - .store - .get_organization_member(&org_id, &user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Member not found"), - e => Status::internal(format!("Failed to get member: {}", e)), - })?; - - // Can't remove the owner - if target_membership.role == zopp_storage::OrganizationRole::Owner { - return Err(Status::invalid_argument( - "Cannot remove the organization owner", - )); - } - - server - .store - .remove_organization_member(&org_id, &user_id) - .await - .map_err(|e| Status::internal(format!("Failed to remove member: {}", e)))?; - - Ok(Response::new(Empty {})) -} - -// ───────────────────────────────────── Organization Invites ───────────────────────────────────── - -pub async fn create_organization_invite( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/CreateOrganizationInvite", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot create invites"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - let role = proto_role_to_storage(req.role())?; - - // Check if requester is admin or owner - let membership = server - .store - .get_organization_member(&org_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - if membership.role != zopp_storage::OrganizationRole::Owner - && membership.role != zopp_storage::OrganizationRole::Admin - { - return Err(Status::permission_denied( - "Only organization owners and admins can create invites", - )); - } - - // Can't invite as owner - if role == zopp_storage::OrganizationRole::Owner { - return Err(Status::invalid_argument("Cannot invite as owner role")); - } - - // Validate email - if req.email.is_empty() || !req.email.contains('@') { - return Err(Status::invalid_argument("Invalid email address")); - } - - // Generate secure random token - let token: String = rand::rng() - .sample_iter(&rand::distr::Alphanumeric) - .take(32) - .map(char::from) - .collect(); - - let token_hash = format!("{:x}", Sha256::digest(token.as_bytes())); - - // Calculate expiration (default 72 hours) - let expires_in_hours = req.expires_in_hours.unwrap_or(72); - let expires_at = Utc::now() + Duration::hours(expires_in_hours); - - let params = zopp_storage::CreateOrganizationInviteParams { - organization_id: org_id, - email: req.email.clone(), - role, - token_hash: token_hash.clone(), - invited_by: caller_user_id, - expires_at, - }; - - let invite = server - .store - .create_organization_invite(¶ms) - .await - .map_err(|e| match e { - zopp_storage::StoreError::AlreadyExists => { - Status::already_exists("An invite for this email already exists") - } - e => Status::internal(format!("Failed to create invite: {}", e)), - })?; - - // Return invite with the plaintext token (only returned once) - Ok(Response::new(invite_to_proto(&invite, Some(&token)))) -} - -pub async fn get_organization_invite( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - // This endpoint doesn't require auth - it's used to look up invites by token - let req = request.into_inner(); - - if req.token.is_empty() { - return Err(Status::invalid_argument("Token is required")); - } - - let token_hash = format!("{:x}", Sha256::digest(req.token.as_bytes())); - - let invite = server - .store - .get_organization_invite_by_token(&token_hash) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Invite not found"), - e => Status::internal(format!("Failed to get invite: {}", e)), - })?; - - // Check if expired - if invite.expires_at < Utc::now() { - return Err(Status::not_found("Invite has expired")); - } - - Ok(Response::new(invite_to_proto(&invite, None))) -} - -pub async fn list_organization_invites( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/ListOrganizationInvites", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot list invites"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - - // Check if requester is admin or owner - let membership = server - .store - .get_organization_member(&org_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - if membership.role != zopp_storage::OrganizationRole::Owner - && membership.role != zopp_storage::OrganizationRole::Admin - { - return Err(Status::permission_denied( - "Only organization owners and admins can view invites", - )); - } - - let invites = server - .store - .list_organization_invites(&org_id) - .await - .map_err(|e| Status::internal(format!("Failed to list invites: {}", e)))?; - - let proto_invites: Vec<_> = invites - .iter() - .filter(|i| i.expires_at > Utc::now()) // Filter out expired - .map(|i| invite_to_proto(i, None)) - .collect(); - - Ok(Response::new(OrganizationInviteList { - invites: proto_invites, - })) -} - -pub async fn accept_organization_invite( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/AcceptOrganizationInvite", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot accept invites"))?; - - if req.token.is_empty() { - return Err(Status::invalid_argument("Token is required")); - } - - let token_hash = format!("{:x}", Sha256::digest(req.token.as_bytes())); - - let invite = server - .store - .get_organization_invite_by_token(&token_hash) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Invite not found"), - e => Status::internal(format!("Failed to get invite: {}", e)), - })?; - - // Check if expired - if invite.expires_at < Utc::now() { - return Err(Status::not_found("Invite has expired")); - } - - // Verify email matches (optional - could allow any authenticated user) - let user = server - .store - .get_user_by_id(&caller_user_id) - .await - .map_err(|e| Status::internal(format!("Failed to get user: {}", e)))?; - - if user.email.to_lowercase() != invite.email.to_lowercase() { - return Err(Status::permission_denied( - "Invite was sent to a different email address", - )); - } - - // Add user as member - server - .store - .add_organization_member( - &invite.organization_id, - &caller_user_id, - invite.role, - Some(invite.invited_by), - ) - .await - .map_err(|e| match e { - zopp_storage::StoreError::AlreadyExists => { - Status::already_exists("You are already a member of this organization") - } - e => Status::internal(format!("Failed to add member: {}", e)), - })?; - - // Delete the invite (it's been used) - let _ = server.store.delete_organization_invite(&invite.id).await; - - // Return the new membership - let member = server - .store - .get_organization_member(&invite.organization_id, &caller_user_id) - .await - .map_err(|e| Status::internal(format!("Failed to get membership: {}", e)))?; - - Ok(Response::new(member_to_proto(&member, &user.email))) -} - -pub async fn delete_organization_invite( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/DeleteOrganizationInvite", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot delete invites"))?; - - let invite_id = parse_invite_id(&req.invite_id)?; - - // Get the invite to check organization - let invite = server - .store - .get_organization_invite(&invite_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Invite not found"), - e => Status::internal(format!("Failed to get invite: {}", e)), - })?; - - // Check if requester is admin or owner of the organization - let membership = server - .store - .get_organization_member(&invite.organization_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - if membership.role != zopp_storage::OrganizationRole::Owner - && membership.role != zopp_storage::OrganizationRole::Admin - { - return Err(Status::permission_denied( - "Only organization owners and admins can delete invites", - )); - } - - server - .store - .delete_organization_invite(&invite_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Invite not found"), - e => Status::internal(format!("Failed to delete invite: {}", e)), - })?; - - Ok(Response::new(Empty {})) -} - -// ───────────────────────────────────── Organization Workspaces ───────────────────────────────────── - -pub async fn link_workspace_to_organization( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/LinkWorkspaceToOrganization", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot link workspaces"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - let workspace_id = parse_workspace_id(&req.workspace_id)?; - - // Check if requester is org admin or owner - let org_membership = server - .store - .get_organization_member(&org_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - if org_membership.role != zopp_storage::OrganizationRole::Owner - && org_membership.role != zopp_storage::OrganizationRole::Admin - { - return Err(Status::permission_denied( - "Only organization owners and admins can link workspaces", - )); - } - - // Check if requester owns the workspace - let workspace = server - .store - .get_workspace(&workspace_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Workspace not found"), - e => Status::internal(format!("Failed to get workspace: {}", e)), - })?; - - if workspace.owner_user_id != caller_user_id { - return Err(Status::permission_denied( - "Only workspace owner can link it to an organization", - )); - } - - server - .store - .set_workspace_organization(&workspace_id, Some(org_id)) - .await - .map_err(|e| Status::internal(format!("Failed to link workspace: {}", e)))?; - - Ok(Response::new(Empty {})) -} - -pub async fn unlink_workspace_from_organization( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/UnlinkWorkspaceFromOrganization", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot unlink workspaces"))?; - - let workspace_id = parse_workspace_id(&req.workspace_id)?; - - // Check if requester owns the workspace - let workspace = server - .store - .get_workspace(&workspace_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => Status::not_found("Workspace not found"), - e => Status::internal(format!("Failed to get workspace: {}", e)), - })?; - - if workspace.owner_user_id != caller_user_id { - return Err(Status::permission_denied( - "Only workspace owner can unlink it from an organization", - )); - } - - server - .store - .set_workspace_organization(&workspace_id, None) - .await - .map_err(|e| Status::internal(format!("Failed to unlink workspace: {}", e)))?; - - Ok(Response::new(Empty {})) -} - -pub async fn list_organization_workspaces( - server: &ZoppServer, - request: Request, -) -> Result, Status> { - let (principal_id, timestamp, signature, request_hash) = extract_signature(&request)?; - let req_for_verify = request.get_ref().clone(); - let principal = server - .verify_signature_and_get_principal( - &principal_id, - timestamp, - &signature, - "/zopp.ZoppService/ListOrganizationWorkspaces", - &req_for_verify, - &request_hash, - ) - .await?; - let req = request.into_inner(); - - let caller_user_id = principal - .user_id - .ok_or_else(|| Status::unauthenticated("Service accounts cannot list workspaces"))?; - - let org_id = parse_organization_id(&req.organization_id)?; - - // Check if requester is a member - server - .store - .get_organization_member(&org_id, &caller_user_id) - .await - .map_err(|e| match e { - zopp_storage::StoreError::NotFound => { - Status::permission_denied("Not a member of this organization") - } - e => Status::internal(format!("Failed to check membership: {}", e)), - })?; - - let workspaces = server - .store - .list_organization_workspaces(&org_id) - .await - .map_err(|e| Status::internal(format!("Failed to list workspaces: {}", e)))?; - - let proto_workspaces: Vec<_> = workspaces.iter().map(workspace_to_proto).collect(); - - Ok(Response::new(WorkspaceList { - workspaces: proto_workspaces, - })) -} - -// ───────────────────────────────────── Helper Functions ───────────────────────────────────── - -fn parse_organization_id(id: &str) -> Result { - Uuid::parse_str(id) - .map(OrganizationId) - .map_err(|_| Status::invalid_argument("Invalid organization ID")) -} - -fn parse_user_id(id: &str) -> Result { - Uuid::parse_str(id) - .map(UserId) - .map_err(|_| Status::invalid_argument("Invalid user ID")) -} - -fn parse_workspace_id(id: &str) -> Result { - Uuid::parse_str(id) - .map(WorkspaceId) - .map_err(|_| Status::invalid_argument("Invalid workspace ID")) -} - -fn parse_invite_id(id: &str) -> Result { - Uuid::parse_str(id) - .map(OrganizationInviteId) - .map_err(|_| Status::invalid_argument("Invalid invite ID")) -} - -fn proto_role_to_storage(role: OrganizationRole) -> Result { - match role { - OrganizationRole::Unspecified => Err(Status::invalid_argument( - "Organization role must be specified", - )), - OrganizationRole::OrganizationOwner => Ok(zopp_storage::OrganizationRole::Owner), - OrganizationRole::OrganizationAdmin => Ok(zopp_storage::OrganizationRole::Admin), - OrganizationRole::OrganizationMember => Ok(zopp_storage::OrganizationRole::Member), - } -} - -fn storage_role_to_proto(role: zopp_storage::OrganizationRole) -> OrganizationRole { - match role { - zopp_storage::OrganizationRole::Owner => OrganizationRole::OrganizationOwner, - zopp_storage::OrganizationRole::Admin => OrganizationRole::OrganizationAdmin, - zopp_storage::OrganizationRole::Member => OrganizationRole::OrganizationMember, - } -} - -fn storage_plan_to_proto(plan: zopp_storage::Plan) -> Plan { - match plan { - zopp_storage::Plan::Free => Plan::Free, - zopp_storage::Plan::Pro => Plan::Pro, - zopp_storage::Plan::Enterprise => Plan::Enterprise, - } -} - -fn organization_to_proto(org: &zopp_storage::Organization, member_count: i32) -> Organization { - Organization { - id: org.id.0.to_string(), - name: org.name.clone(), - slug: org.slug.clone(), - plan: storage_plan_to_proto(org.plan).into(), - seat_limit: org.seat_limit, - member_count, - stripe_customer_id: org.stripe_customer_id.clone(), - trial_ends_at: org.trial_ends_at.map(|t| t.to_rfc3339()), - created_at: org.created_at.to_rfc3339(), - updated_at: org.updated_at.to_rfc3339(), - } -} - -fn member_to_proto(member: &zopp_storage::OrganizationMember, email: &str) -> OrganizationMember { - OrganizationMember { - user_id: member.user_id.0.to_string(), - email: email.to_string(), - role: storage_role_to_proto(member.role).into(), - invited_by: member.invited_by.as_ref().map(|u| u.0.to_string()), - joined_at: member.joined_at.to_rfc3339(), - } -} - -fn invite_to_proto( - invite: &zopp_storage::OrganizationInvite, - token: Option<&str>, -) -> OrganizationInvite { - OrganizationInvite { - id: invite.id.0.to_string(), - organization_id: invite.organization_id.0.to_string(), - email: invite.email.clone(), - role: storage_role_to_proto(invite.role).into(), - invited_by: invite.invited_by.0.to_string(), - expires_at: invite.expires_at.to_rfc3339(), - created_at: invite.created_at.to_rfc3339(), - token: token.map(|t| t.to_string()), - } -} - -fn workspace_to_proto(workspace: &zopp_storage::Workspace) -> Workspace { - Workspace { - id: workspace.id.0.to_string(), - name: workspace.name.clone(), - project_count: 0, // We could count projects but that's expensive - } -} diff --git a/crates/zopp-billing/Cargo.toml b/crates/zopp-billing/Cargo.toml deleted file mode 100644 index 00e75139..00000000 --- a/crates/zopp-billing/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "zopp-billing" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[features] -default = [] -stripe = ["dep:async-stripe"] - -[dependencies] -async-trait = "0.1" -chrono = { version = "0.4", features = ["serde"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -async-stripe = { version = "1.0.0-rc.0", optional = true } -thiserror = "2" -tokio = { version = "1", features = ["sync"] } -tracing = "0.1" -uuid = { version = "1", features = ["v4", "v7", "serde"] } - -# Internal crates -zopp-storage = { path = "../zopp-storage" } - -[dev-dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/crates/zopp-billing/src/lib.rs b/crates/zopp-billing/src/lib.rs deleted file mode 100644 index f690dffb..00000000 --- a/crates/zopp-billing/src/lib.rs +++ /dev/null @@ -1,470 +0,0 @@ -//! zopp-billing - Billing integration for zopp cloud -//! -//! This crate provides billing integration for: -//! - Customer management (organization -> billing customer) -//! - Subscription management (per-seat billing) -//! - Webhook handling for subscription events -//! -//! # Architecture -//! -//! The billing system is designed around seat-based pricing: -//! - Each organization has one billing customer -//! - Subscriptions are based on number of seats (organization members) -//! - Seat count is automatically updated when members join/leave -//! -//! # Feature Flags -//! -//! - `stripe`: Enable Stripe integration (adds async-stripe dependency) - -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use std::sync::Arc; -use thiserror::Error; -use zopp_storage::{OrganizationId, Plan, Store, StoreError}; - -mod webhook; -pub use webhook::{ - parse_webhook_event, BillingWebhookEvent, DefaultWebhookHandler, WebhookHandler, -}; - -/// Billing service errors -#[derive(Debug, Error)] -pub enum BillingError { - #[error("Billing provider error: {0}")] - Provider(String), - - #[error("Customer not found")] - CustomerNotFound, - - #[error("Subscription not found")] - SubscriptionNotFound, - - #[error("Invalid webhook signature")] - InvalidWebhookSignature, - - #[error("Organization not found")] - OrganizationNotFound, - - #[error("Storage error: {0}")] - Storage(#[from] StoreError), - - #[error("Configuration error: {0}")] - Config(String), -} - -/// Configuration for the billing service -#[derive(Clone)] -pub struct BillingConfig { - /// API secret key for the billing provider - pub api_key: String, - - /// Webhook secret for signature verification - pub webhook_secret: String, - - /// Price ID for the Pro plan (per seat) - pub pro_price_id: String, - - /// Price ID for the Enterprise plan (per seat) - pub enterprise_price_id: String, - - /// Trial period in days (default: 14) - pub trial_days: u32, -} - -impl BillingConfig { - /// Create a new billing configuration from environment variables - pub fn from_env() -> Result { - Ok(Self { - api_key: std::env::var("BILLING_API_KEY") - .or_else(|_| std::env::var("STRIPE_SECRET_KEY")) - .map_err(|_| { - BillingError::Config("BILLING_API_KEY or STRIPE_SECRET_KEY not set".into()) - })?, - webhook_secret: std::env::var("BILLING_WEBHOOK_SECRET") - .or_else(|_| std::env::var("STRIPE_WEBHOOK_SECRET")) - .map_err(|_| { - BillingError::Config( - "BILLING_WEBHOOK_SECRET or STRIPE_WEBHOOK_SECRET not set".into(), - ) - })?, - pro_price_id: std::env::var("BILLING_PRO_PRICE_ID") - .or_else(|_| std::env::var("STRIPE_PRO_PRICE_ID")) - .map_err(|_| { - BillingError::Config( - "BILLING_PRO_PRICE_ID or STRIPE_PRO_PRICE_ID not set".into(), - ) - })?, - enterprise_price_id: std::env::var("BILLING_ENTERPRISE_PRICE_ID") - .or_else(|_| std::env::var("STRIPE_ENTERPRISE_PRICE_ID")) - .map_err(|_| { - BillingError::Config( - "BILLING_ENTERPRISE_PRICE_ID or STRIPE_ENTERPRISE_PRICE_ID not set".into(), - ) - })?, - trial_days: match std::env::var("BILLING_TRIAL_DAYS") - .or_else(|_| std::env::var("STRIPE_TRIAL_DAYS")) - { - Ok(v) => v.parse().map_err(|_| { - BillingError::Config(format!( - "Invalid BILLING_TRIAL_DAYS value '{}': expected a number", - v - )) - })?, - Err(_) => 14, // Default to 14 days if not set - }, - }) - } - - /// Create a test configuration (for development/testing) - pub fn test() -> Self { - Self { - api_key: "test_api_key".into(), - webhook_secret: "test_webhook_secret".into(), - pro_price_id: "price_pro_test".into(), - enterprise_price_id: "price_enterprise_test".into(), - trial_days: 14, - } - } -} - -/// Result of creating a checkout session -#[derive(Debug, Clone)] -pub struct CheckoutSession { - /// Session ID - pub session_id: String, - - /// URL to redirect the user to for payment - pub checkout_url: String, -} - -/// Result of creating a billing portal session -#[derive(Debug, Clone)] -pub struct PortalSession { - /// URL to redirect the user to the billing portal - pub portal_url: String, -} - -/// Subscription status -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SubscriptionStatus { - /// Trial period (no payment required yet) - Trialing, - - /// Active subscription - Active, - - /// Past due (payment failed, but still in grace period) - PastDue, - - /// Canceled (scheduled to end) - Canceled, - - /// Unpaid (payment failed, subscription suspended) - Unpaid, - - /// Incomplete (initial payment incomplete) - Incomplete, -} - -impl std::fmt::Display for SubscriptionStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Trialing => write!(f, "trialing"), - Self::Active => write!(f, "active"), - Self::PastDue => write!(f, "past_due"), - Self::Canceled => write!(f, "canceled"), - Self::Unpaid => write!(f, "unpaid"), - Self::Incomplete => write!(f, "incomplete"), - } - } -} - -impl std::str::FromStr for SubscriptionStatus { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "trialing" => Ok(Self::Trialing), - "active" => Ok(Self::Active), - "past_due" => Ok(Self::PastDue), - "canceled" => Ok(Self::Canceled), - "unpaid" => Ok(Self::Unpaid), - "incomplete" => Ok(Self::Incomplete), - _ => Err(format!("Unknown subscription status: {}", s)), - } - } -} - -/// Subscription information -#[derive(Debug, Clone)] -pub struct Subscription { - /// Subscription ID - pub subscription_id: String, - - /// Current status - pub status: SubscriptionStatus, - - /// Current plan - pub plan: Plan, - - /// Number of seats in the subscription - pub seat_count: i32, - - /// Current period start - pub current_period_start: DateTime, - - /// Current period end - pub current_period_end: DateTime, - - /// Whether subscription will cancel at period end - pub cancel_at_period_end: bool, - - /// Trial end date if in trial - pub trial_end: Option>, -} - -/// Billing service trait for dependency injection -#[async_trait] -pub trait BillingService: Send + Sync { - /// Create or get a billing customer for an organization - async fn ensure_customer( - &self, - org_id: &OrganizationId, - email: &str, - name: &str, - ) -> Result; - - /// Create a checkout session for upgrading to a paid plan - async fn create_checkout_session( - &self, - org_id: &OrganizationId, - plan: Plan, - seat_count: i32, - success_url: &str, - cancel_url: &str, - ) -> Result; - - /// Create a billing portal session for managing subscription - async fn create_portal_session( - &self, - org_id: &OrganizationId, - return_url: &str, - ) -> Result; - - /// Get the current subscription for an organization - async fn get_subscription( - &self, - org_id: &OrganizationId, - ) -> Result, BillingError>; - - /// Update the seat count for a subscription - async fn update_seat_count( - &self, - org_id: &OrganizationId, - new_seat_count: i32, - ) -> Result<(), BillingError>; - - /// Cancel a subscription at period end - async fn cancel_subscription(&self, org_id: &OrganizationId) -> Result<(), BillingError>; - - /// Resume a canceled subscription - async fn resume_subscription(&self, org_id: &OrganizationId) -> Result<(), BillingError>; -} - -/// Mock billing service for development and testing -pub struct MockBillingService { - #[allow(dead_code)] - config: BillingConfig, - store: Arc, -} - -impl MockBillingService { - /// Create a new mock billing service - pub fn new(config: BillingConfig, store: Arc) -> Self { - Self { config, store } - } -} - -#[async_trait] -impl BillingService for MockBillingService { - async fn ensure_customer( - &self, - org_id: &OrganizationId, - _email: &str, - _name: &str, - ) -> Result { - // Check if organization already has a customer ID - let org = self.store.get_organization(org_id).await?; - if let Some(customer_id) = org.stripe_customer_id { - return Ok(customer_id); - } - - // Generate a mock customer ID - let customer_id = format!("cus_mock_{}", uuid::Uuid::new_v4()); - - // Save the customer ID to the organization - self.store - .set_organization_stripe_customer(org_id, &customer_id) - .await?; - - Ok(customer_id) - } - - async fn create_checkout_session( - &self, - org_id: &OrganizationId, - plan: Plan, - seat_count: i32, - success_url: &str, - _cancel_url: &str, - ) -> Result { - // Ensure customer exists - let org = self.store.get_organization(org_id).await?; - if org.stripe_customer_id.is_none() { - return Err(BillingError::CustomerNotFound); - } - - // Generate mock session - let session_id = format!("cs_mock_{}", uuid::Uuid::new_v4()); - - // In development, just redirect to success URL with session ID - let checkout_url = format!("{}?session_id={}", success_url, session_id); - - tracing::info!( - org_id = %org_id.0, - ?plan, - seat_count, - "Mock checkout session created" - ); - - Ok(CheckoutSession { - session_id, - checkout_url, - }) - } - - async fn create_portal_session( - &self, - org_id: &OrganizationId, - return_url: &str, - ) -> Result { - // Ensure customer exists - let org = self.store.get_organization(org_id).await?; - if org.stripe_customer_id.is_none() { - return Err(BillingError::CustomerNotFound); - } - - // In development, just redirect to return URL - Ok(PortalSession { - portal_url: return_url.to_string(), - }) - } - - async fn get_subscription( - &self, - org_id: &OrganizationId, - ) -> Result, BillingError> { - let org = self.store.get_organization(org_id).await?; - - // If not on free plan, simulate an active subscription - if org.plan != Plan::Free { - let now = Utc::now(); - let period_end = now + chrono::Duration::days(30); - - Ok(Some(Subscription { - subscription_id: format!("sub_mock_{}", org_id.0), - status: SubscriptionStatus::Active, - plan: org.plan, - seat_count: org.seat_limit, - current_period_start: now, - current_period_end: period_end, - cancel_at_period_end: false, - trial_end: org.trial_ends_at, - })) - } else { - Ok(None) - } - } - - async fn update_seat_count( - &self, - org_id: &OrganizationId, - new_seat_count: i32, - ) -> Result<(), BillingError> { - let org = self.store.get_organization(org_id).await?; - - // Update the seat count in the organization - self.store - .set_organization_plan(org_id, org.plan, new_seat_count) - .await?; - - tracing::info!( - org_id = %org_id.0, - new_seat_count, - "Mock seat count updated" - ); - - Ok(()) - } - - async fn cancel_subscription(&self, org_id: &OrganizationId) -> Result<(), BillingError> { - let org = self.store.get_organization(org_id).await?; - - if org.plan == Plan::Free { - return Err(BillingError::SubscriptionNotFound); - } - - tracing::info!( - org_id = %org_id.0, - "Mock subscription canceled" - ); - - Ok(()) - } - - async fn resume_subscription(&self, org_id: &OrganizationId) -> Result<(), BillingError> { - let org = self.store.get_organization(org_id).await?; - - if org.plan == Plan::Free { - return Err(BillingError::SubscriptionNotFound); - } - - tracing::info!( - org_id = %org_id.0, - "Mock subscription resumed" - ); - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_subscription_status_display() { - assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing"); - assert_eq!(SubscriptionStatus::Active.to_string(), "active"); - assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due"); - } - - #[test] - fn test_subscription_status_from_str() { - assert_eq!( - "trialing".parse::().unwrap(), - SubscriptionStatus::Trialing - ); - assert_eq!( - "active".parse::().unwrap(), - SubscriptionStatus::Active - ); - } - - #[test] - fn test_billing_config_test() { - let config = BillingConfig::test(); - assert_eq!(config.trial_days, 14); - } -} diff --git a/crates/zopp-billing/src/webhook.rs b/crates/zopp-billing/src/webhook.rs deleted file mode 100644 index 7a35d5d2..00000000 --- a/crates/zopp-billing/src/webhook.rs +++ /dev/null @@ -1,518 +0,0 @@ -//! Billing webhook handling -//! -//! Handles incoming billing provider webhook events to keep billing state in sync. - -use crate::{BillingError, SubscriptionStatus}; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use std::sync::Arc; -use tracing::{info, warn}; -use zopp_storage::{OrganizationId, Plan, Store}; - -/// Parsed billing webhook event -#[derive(Debug, Clone)] -pub enum BillingWebhookEvent { - /// Subscription was created - SubscriptionCreated { - subscription_id: String, - customer_id: String, - plan: Plan, - seat_count: i32, - status: SubscriptionStatus, - trial_end: Option>, - }, - - /// Subscription was updated (plan change, seat change, etc.) - SubscriptionUpdated { - subscription_id: String, - customer_id: String, - plan: Plan, - seat_count: i32, - status: SubscriptionStatus, - cancel_at_period_end: bool, - }, - - /// Subscription was deleted/canceled - SubscriptionDeleted { - subscription_id: String, - customer_id: String, - }, - - /// Invoice was paid successfully - InvoicePaid { - invoice_id: String, - customer_id: String, - amount_paid: i64, - }, - - /// Invoice payment failed - InvoicePaymentFailed { - invoice_id: String, - customer_id: String, - attempt_count: i32, - }, - - /// Checkout session completed - CheckoutCompleted { - session_id: String, - customer_id: String, - subscription_id: Option, - }, - - /// Unknown or unhandled event - Unknown { event_type: String }, -} - -/// Handler for billing webhook events -#[async_trait] -pub trait WebhookHandler: Send + Sync { - /// Handle an incoming webhook event - async fn handle_event(&self, event: BillingWebhookEvent) -> Result<(), BillingError>; -} - -/// Default webhook handler implementation -pub struct DefaultWebhookHandler { - store: Arc, -} - -impl DefaultWebhookHandler { - /// Create a new webhook handler - pub fn new(store: Arc) -> Self { - Self { store } - } - - /// Find organization by billing customer ID - /// - /// Note: In production, you'd want a lookup table/index for this - async fn find_org_by_customer( - &self, - _customer_id: &str, - ) -> Result { - // TODO: Implement customer_id -> org_id lookup - // For now, this would need to scan organizations - Err(BillingError::OrganizationNotFound) - } -} - -#[async_trait] -impl WebhookHandler for DefaultWebhookHandler { - async fn handle_event(&self, event: BillingWebhookEvent) -> Result<(), BillingError> { - match event { - BillingWebhookEvent::SubscriptionCreated { - subscription_id, - customer_id, - plan, - seat_count, - status, - trial_end: _, - } => { - info!( - %subscription_id, - %customer_id, - ?plan, - seat_count, - ?status, - "Subscription created" - ); - - // Find organization by customer ID and update plan - match self.find_org_by_customer(&customer_id).await { - Ok(org_id) => { - self.store - .set_organization_plan(&org_id, plan, seat_count) - .await?; - info!(?org_id, ?plan, "Organization plan updated"); - } - Err(BillingError::OrganizationNotFound) => { - warn!( - %customer_id, - "Could not find organization for customer" - ); - } - Err(e) => return Err(e), - } - - Ok(()) - } - - BillingWebhookEvent::SubscriptionUpdated { - subscription_id, - customer_id, - plan, - seat_count, - status, - cancel_at_period_end, - } => { - info!( - %subscription_id, - %customer_id, - ?plan, - seat_count, - ?status, - cancel_at_period_end, - "Subscription updated" - ); - - // Update organization plan if subscription is active - if status == SubscriptionStatus::Active || status == SubscriptionStatus::Trialing { - match self.find_org_by_customer(&customer_id).await { - Ok(org_id) => { - self.store - .set_organization_plan(&org_id, plan, seat_count) - .await?; - } - Err(BillingError::OrganizationNotFound) => { - warn!( - %customer_id, - "Could not find organization for customer" - ); - } - Err(e) => return Err(e), - } - } - - Ok(()) - } - - BillingWebhookEvent::SubscriptionDeleted { - subscription_id, - customer_id, - } => { - info!( - %subscription_id, - %customer_id, - "Subscription deleted" - ); - - // Downgrade organization to free plan - match self.find_org_by_customer(&customer_id).await { - Ok(org_id) => { - // Default to 5 seats for free plan - self.store - .set_organization_plan(&org_id, Plan::Free, 5) - .await?; - info!(?org_id, "Organization downgraded to free plan"); - } - Err(BillingError::OrganizationNotFound) => { - warn!( - %customer_id, - "Could not find organization for customer" - ); - } - Err(e) => return Err(e), - } - - Ok(()) - } - - BillingWebhookEvent::InvoicePaid { - invoice_id, - customer_id, - amount_paid, - } => { - info!( - %invoice_id, - %customer_id, - amount_paid, - "Invoice paid" - ); - // Could record payment for audit purposes - Ok(()) - } - - BillingWebhookEvent::InvoicePaymentFailed { - invoice_id, - customer_id, - attempt_count, - } => { - warn!( - %invoice_id, - %customer_id, - attempt_count, - "Invoice payment failed" - ); - // Could notify organization admins - Ok(()) - } - - BillingWebhookEvent::CheckoutCompleted { - session_id, - customer_id, - subscription_id, - } => { - info!( - %session_id, - %customer_id, - ?subscription_id, - "Checkout completed" - ); - // Subscription events will handle plan updates - Ok(()) - } - - BillingWebhookEvent::Unknown { event_type } => { - info!(%event_type, "Unhandled webhook event type"); - Ok(()) - } - } - } -} - -/// Parse a raw webhook payload into an event -/// -/// # Arguments -/// * `payload` - Raw webhook body -/// * `signature` - Webhook signature header value (e.g., Stripe-Signature header) -/// * `webhook_secret` - Your webhook endpoint secret (empty string to disable verification) -/// -/// # Returns -/// Parsed event or error -/// -/// # Security -/// When `webhook_secret` is configured, this function REQUIRES a valid signature. -/// Signature verification is not yet implemented, so providing a webhook_secret -/// will cause all requests to fail (fail-closed behavior for security). -/// -/// For development/testing, pass an empty `webhook_secret` to skip verification. -pub fn parse_webhook_event( - payload: &str, - signature: &str, - webhook_secret: &str, -) -> Result { - // SECURITY: When webhook_secret is configured, we MUST verify signatures. - // This prevents attackers from forging billing events. - if !webhook_secret.is_empty() { - // Webhook secret is configured - signature verification is REQUIRED - if signature.is_empty() { - // CRITICAL: Reject requests with missing signature when secret is configured. - // An attacker could bypass verification by omitting the signature header. - return Err(BillingError::Provider( - "Missing webhook signature. Signature verification is required when \ - webhook_secret is configured." - .into(), - )); - } - - // TODO: Implement proper HMAC-SHA256 signature verification - // For Stripe: verify using stripe-rust or manual HMAC verification - // - // Fail closed: reject events when signature verification is not implemented - // but credentials are provided (indicates production use) - return Err(BillingError::Provider( - "Webhook signature verification not implemented. \ - Remove webhook_secret for development, \ - or implement HMAC verification for production." - .into(), - )); - } - - // Parse JSON payload - let value: serde_json::Value = - serde_json::from_str(payload).map_err(|e| BillingError::Provider(e.to_string()))?; - - let event_type = value["type"] - .as_str() - .ok_or_else(|| BillingError::Provider("Missing event type".into()))?; - - match event_type { - "customer.subscription.created" => { - let sub = &value["data"]["object"]; - Ok(BillingWebhookEvent::SubscriptionCreated { - subscription_id: sub["id"].as_str().unwrap_or("").to_string(), - customer_id: sub["customer"].as_str().unwrap_or("").to_string(), - plan: parse_plan_from_price( - sub["items"]["data"][0]["price"]["id"] - .as_str() - .unwrap_or(""), - ), - seat_count: sub["items"]["data"][0]["quantity"].as_i64().unwrap_or(1) as i32, - status: parse_subscription_status(sub["status"].as_str().unwrap_or("active")), - trial_end: sub["trial_end"] - .as_i64() - .and_then(|ts| DateTime::from_timestamp(ts, 0)), - }) - } - - "customer.subscription.updated" => { - let sub = &value["data"]["object"]; - Ok(BillingWebhookEvent::SubscriptionUpdated { - subscription_id: sub["id"].as_str().unwrap_or("").to_string(), - customer_id: sub["customer"].as_str().unwrap_or("").to_string(), - plan: parse_plan_from_price( - sub["items"]["data"][0]["price"]["id"] - .as_str() - .unwrap_or(""), - ), - seat_count: sub["items"]["data"][0]["quantity"].as_i64().unwrap_or(1) as i32, - status: parse_subscription_status(sub["status"].as_str().unwrap_or("active")), - cancel_at_period_end: sub["cancel_at_period_end"].as_bool().unwrap_or(false), - }) - } - - "customer.subscription.deleted" => { - let sub = &value["data"]["object"]; - Ok(BillingWebhookEvent::SubscriptionDeleted { - subscription_id: sub["id"].as_str().unwrap_or("").to_string(), - customer_id: sub["customer"].as_str().unwrap_or("").to_string(), - }) - } - - "invoice.paid" => { - let invoice = &value["data"]["object"]; - Ok(BillingWebhookEvent::InvoicePaid { - invoice_id: invoice["id"].as_str().unwrap_or("").to_string(), - customer_id: invoice["customer"].as_str().unwrap_or("").to_string(), - amount_paid: invoice["amount_paid"].as_i64().unwrap_or(0), - }) - } - - "invoice.payment_failed" => { - let invoice = &value["data"]["object"]; - Ok(BillingWebhookEvent::InvoicePaymentFailed { - invoice_id: invoice["id"].as_str().unwrap_or("").to_string(), - customer_id: invoice["customer"].as_str().unwrap_or("").to_string(), - attempt_count: invoice["attempt_count"].as_i64().unwrap_or(0) as i32, - }) - } - - "checkout.session.completed" => { - let session = &value["data"]["object"]; - Ok(BillingWebhookEvent::CheckoutCompleted { - session_id: session["id"].as_str().unwrap_or("").to_string(), - customer_id: session["customer"].as_str().unwrap_or("").to_string(), - subscription_id: session["subscription"].as_str().map(|s| s.to_string()), - }) - } - - _ => Ok(BillingWebhookEvent::Unknown { - event_type: event_type.to_string(), - }), - } -} - -fn parse_subscription_status(status: &str) -> SubscriptionStatus { - match status { - "trialing" => SubscriptionStatus::Trialing, - "active" => SubscriptionStatus::Active, - "past_due" => SubscriptionStatus::PastDue, - "canceled" => SubscriptionStatus::Canceled, - "unpaid" => SubscriptionStatus::Unpaid, - "incomplete" => SubscriptionStatus::Incomplete, - // Default to Incomplete for unknown statuses to avoid granting unintended access - unknown => { - warn!(%unknown, "Unknown subscription status, defaulting to Incomplete"); - SubscriptionStatus::Incomplete - } - } -} - -fn parse_plan_from_price(price_id: &str) -> Plan { - // TODO: In production, implement proper price ID to Plan mapping using BillingConfig - // For now, default to Free to avoid granting unintended paid features - if !price_id.is_empty() { - warn!( - %price_id, - "Price ID to Plan mapping not implemented, defaulting to Free plan" - ); - } - Plan::Free -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_subscription_created() { - let payload = r#"{ - "type": "customer.subscription.created", - "data": { - "object": { - "id": "sub_123", - "customer": "cus_456", - "status": "trialing", - "trial_end": 1735689600, - "items": { - "data": [ - { - "price": {"id": "price_pro"}, - "quantity": 5 - } - ] - } - } - } - }"#; - - let event = parse_webhook_event(payload, "", "").unwrap(); - match event { - BillingWebhookEvent::SubscriptionCreated { - subscription_id, - customer_id, - seat_count, - status, - .. - } => { - assert_eq!(subscription_id, "sub_123"); - assert_eq!(customer_id, "cus_456"); - assert_eq!(seat_count, 5); - assert_eq!(status, SubscriptionStatus::Trialing); - } - _ => panic!("Expected SubscriptionCreated event"), - } - } - - #[test] - fn test_parse_unknown_event() { - let payload = r#"{"type": "some.unknown.event", "data": {}}"#; - let event = parse_webhook_event(payload, "", "").unwrap(); - match event { - BillingWebhookEvent::Unknown { event_type } => { - assert_eq!(event_type, "some.unknown.event"); - } - _ => panic!("Expected Unknown event"), - } - } - - #[test] - fn test_missing_signature_with_secret_configured_is_rejected() { - // SECURITY: When webhook_secret is configured, missing signature MUST be rejected - // to prevent attackers from bypassing signature verification - let payload = r#"{"type": "customer.subscription.created", "data": {"object": {}}}"#; - let result = parse_webhook_event(payload, "", "whsec_test_secret"); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.to_string().contains("Missing webhook signature"), - "Expected 'Missing webhook signature' error, got: {}", - err - ); - } - - #[test] - fn test_signature_verification_not_implemented_error() { - // When both signature and secret are provided, we should get "not implemented" error - // (until proper HMAC verification is added) - let payload = r#"{"type": "customer.subscription.created", "data": {"object": {}}}"#; - let result = parse_webhook_event(payload, "t=123,v1=abc", "whsec_test_secret"); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.to_string().contains("not implemented"), - "Expected 'not implemented' error, got: {}", - err - ); - } - - #[test] - fn test_no_secret_skips_verification() { - // When no webhook_secret is configured (development mode), verification is skipped - let payload = r#"{"type": "customer.subscription.created", "data": {"object": {"id": "sub_1", "customer": "cus_1", "status": "active", "items": {"data": [{"price": {"id": "p"}, "quantity": 1}]}}}}"#; - let result = parse_webhook_event(payload, "", ""); - assert!( - result.is_ok(), - "Expected success in dev mode, got: {:?}", - result - ); - } -} diff --git a/crates/zopp-proto/proto/zopp.proto b/crates/zopp-proto/proto/zopp.proto index a3846d91..094f7871 100644 --- a/crates/zopp-proto/proto/zopp.proto +++ b/crates/zopp-proto/proto/zopp.proto @@ -143,39 +143,6 @@ service ZoppService { rpc GetAuditLog(GetAuditLogRequest) returns (AuditLogEntry); // CountAuditLogs removed - use ListAuditLogs response.total_count instead - // Organizations (SaaS cloud feature) - rpc CreateOrganization(CreateOrganizationRequest) returns (Organization); - rpc GetOrganization(GetOrganizationRequest) returns (Organization); - rpc ListUserOrganizations(Empty) returns (OrganizationList); - rpc UpdateOrganization(UpdateOrganizationRequest) returns (Organization); - rpc DeleteOrganization(DeleteOrganizationRequest) returns (Empty); - - // Organization members - rpc UpsertOrganizationMember(UpsertOrganizationMemberRequest) returns (OrganizationMember); - rpc GetOrganizationMember(GetOrganizationMemberRequest) returns (OrganizationMember); - rpc ListOrganizationMembers(ListOrganizationMembersRequest) returns (OrganizationMemberList); - rpc RemoveOrganizationMember(RemoveOrganizationMemberRequest) returns (Empty); - // DEPRECATED: Use UpsertOrganizationMember instead (will be removed in v1.0) - rpc AddOrganizationMember(AddOrganizationMemberRequest) returns (Empty); // Deprecated: use UpsertOrganizationMember - rpc UpdateOrganizationMemberRole(UpdateOrganizationMemberRoleRequest) returns (Empty); // Deprecated: use UpsertOrganizationMember - - // Organization invites - rpc CreateOrganizationInvite(CreateOrganizationInviteRequest) returns (OrganizationInvite); - rpc GetOrganizationInvite(GetOrganizationInviteRequest) returns (OrganizationInvite); - rpc ListOrganizationInvites(ListOrganizationInvitesRequest) returns (OrganizationInviteList); - rpc AcceptOrganizationInvite(AcceptOrganizationInviteRequest) returns (OrganizationMember); - rpc DeleteOrganizationInvite(DeleteOrganizationInviteRequest) returns (Empty); - - // Organization workspaces - rpc LinkWorkspaceToOrganization(LinkWorkspaceToOrganizationRequest) returns (Empty); - rpc UnlinkWorkspaceFromOrganization(UnlinkWorkspaceFromOrganizationRequest) returns (Empty); - rpc ListOrganizationWorkspaces(ListOrganizationWorkspacesRequest) returns (WorkspaceList); - - // Billing (SaaS cloud feature) - rpc GetSubscription(GetSubscriptionRequest) returns (Subscription); - rpc ListPayments(ListPaymentsRequest) returns (PaymentList); - rpc CreateCheckoutSession(CreateCheckoutSessionRequest) returns (CheckoutSession); - rpc CreateBillingPortalSession(CreateBillingPortalSessionRequest) returns (BillingPortalSession); } message Empty {} @@ -1063,235 +1030,3 @@ message CountAuditLogsResponse { uint64 count = 1; } -// Organization messages (SaaS cloud feature) -enum OrganizationRole { - ORGANIZATION_ROLE_UNSPECIFIED = 0; - ORGANIZATION_OWNER = 1; // Full control, billing, can delete org - ORGANIZATION_ADMIN = 2; // Manage members, settings, but not billing - ORGANIZATION_MEMBER = 3; // Access to org workspaces based on permissions -} - -enum Plan { - PLAN_UNSPECIFIED = 0; - PLAN_FREE = 1; - PLAN_PRO = 2; - PLAN_ENTERPRISE = 3; -} - -message Organization { - string id = 1; - string name = 2; - string slug = 3; - Plan plan = 4; - int32 seat_limit = 5; - int32 member_count = 6; - optional string stripe_customer_id = 7; - optional string trial_ends_at = 8; // ISO8601 timestamp - string created_at = 9; - string updated_at = 10; -} - -message OrganizationList { - repeated Organization organizations = 1; -} - -message CreateOrganizationRequest { - string name = 1; - string slug = 2; // URL-friendly identifier -} - -message GetOrganizationRequest { - oneof identifier { - string id = 1; - string slug = 2; - } -} - -message UpdateOrganizationRequest { - string organization_id = 1; - optional string name = 2; - optional string slug = 3; -} - -message DeleteOrganizationRequest { - string organization_id = 1; -} - -// Organization member messages -message OrganizationMember { - string user_id = 1; - string email = 2; - OrganizationRole role = 3; - optional string invited_by = 4; // User ID of inviter - string joined_at = 5; // ISO8601 timestamp -} - -message OrganizationMemberList { - repeated OrganizationMember members = 1; -} - -message AddOrganizationMemberRequest { - string organization_id = 1; - string user_id = 2; - OrganizationRole role = 3; -} - -// UpsertOrganizationMemberRequest - add or update a member in one call -message UpsertOrganizationMemberRequest { - string organization_id = 1; - string user_id = 2; - OrganizationRole role = 3; -} - -message GetOrganizationMemberRequest { - string organization_id = 1; - string user_id = 2; -} - -message ListOrganizationMembersRequest { - string organization_id = 1; -} - -message UpdateOrganizationMemberRoleRequest { - string organization_id = 1; - string user_id = 2; - OrganizationRole role = 3; -} - -message RemoveOrganizationMemberRequest { - string organization_id = 1; - string user_id = 2; -} - -// Organization invite messages -message OrganizationInvite { - string id = 1; - string organization_id = 2; - string email = 3; - OrganizationRole role = 4; - string invited_by = 5; // User ID of inviter - string expires_at = 6; // ISO8601 timestamp - string created_at = 7; - // Token only returned on create - optional string token = 8; -} - -message OrganizationInviteList { - repeated OrganizationInvite invites = 1; -} - -message CreateOrganizationInviteRequest { - string organization_id = 1; - string email = 2; - OrganizationRole role = 3; - optional int64 expires_in_hours = 4; // Default 72 hours -} - -message GetOrganizationInviteRequest { - string token = 1; // Invite token for lookup -} - -message ListOrganizationInvitesRequest { - string organization_id = 1; -} - -message AcceptOrganizationInviteRequest { - string token = 1; -} - -message DeleteOrganizationInviteRequest { - string invite_id = 1; -} - -// Organization workspace messages -message LinkWorkspaceToOrganizationRequest { - string organization_id = 1; - string workspace_id = 2; -} - -message UnlinkWorkspaceFromOrganizationRequest { - string workspace_id = 1; -} - -message ListOrganizationWorkspacesRequest { - string organization_id = 1; -} - -// Billing messages (SaaS cloud feature) -enum SubscriptionStatus { - SUBSCRIPTION_STATUS_UNSPECIFIED = 0; - SUBSCRIPTION_ACTIVE = 1; - SUBSCRIPTION_PAST_DUE = 2; - SUBSCRIPTION_CANCELED = 3; - SUBSCRIPTION_TRIALING = 4; - SUBSCRIPTION_INCOMPLETE = 5; -} - -enum PaymentStatus { - PAYMENT_STATUS_UNSPECIFIED = 0; - PAYMENT_SUCCEEDED = 1; - PAYMENT_PENDING = 2; - PAYMENT_FAILED = 3; - PAYMENT_REFUNDED = 4; -} - -message Subscription { - string id = 1; - string organization_id = 2; - string stripe_subscription_id = 3; - string stripe_price_id = 4; - Plan plan = 5; - SubscriptionStatus status = 6; - string current_period_start = 7; // ISO8601 timestamp - string current_period_end = 8; // ISO8601 timestamp - bool cancel_at_period_end = 9; - optional string canceled_at = 10; // ISO8601 timestamp - string created_at = 11; - string updated_at = 12; -} - -message GetSubscriptionRequest { - string organization_id = 1; -} - -message Payment { - string id = 1; - string organization_id = 2; - optional string stripe_payment_intent_id = 3; - optional string stripe_invoice_id = 4; - int64 amount_cents = 5; - string currency = 6; - PaymentStatus status = 7; - optional string description = 8; - string created_at = 9; -} - -message PaymentList { - repeated Payment payments = 1; -} - -message ListPaymentsRequest { - string organization_id = 1; - optional int32 limit = 2; - optional int32 offset = 3; -} - -message CheckoutSession { - string url = 1; // Stripe Checkout URL -} - -message CreateCheckoutSessionRequest { - string organization_id = 1; - Plan plan = 2; - string success_url = 3; // Redirect URL on success - string cancel_url = 4; // Redirect URL on cancel -} - -message BillingPortalSession { - string url = 1; // Stripe Billing Portal URL -} - -message CreateBillingPortalSessionRequest { - string organization_id = 1; - string return_url = 2; // URL to return to after portal session -} diff --git a/crates/zopp-storage/src/lib.rs b/crates/zopp-storage/src/lib.rs index c7f5a0ed..e7e9e225 100644 --- a/crates/zopp-storage/src/lib.rs +++ b/crates/zopp-storage/src/lib.rs @@ -777,169 +777,6 @@ mod tests { ) -> Result<(), StoreError> { Ok(()) } - - async fn create_organization( - &self, - _params: &CreateOrganizationParams, - ) -> Result { - Ok(OrganizationId(Uuid::new_v4())) - } - - async fn get_organization( - &self, - _org_id: &OrganizationId, - ) -> Result { - Err(StoreError::NotFound) - } - - async fn get_organization_by_slug(&self, _slug: &str) -> Result { - Err(StoreError::NotFound) - } - - async fn list_user_organizations( - &self, - _user_id: &UserId, - ) -> Result, StoreError> { - Ok(vec![]) - } - - async fn update_organization( - &self, - _org_id: &OrganizationId, - _name: Option, - _slug: Option, - ) -> Result<(), StoreError> { - Ok(()) - } - - async fn set_organization_stripe_customer( - &self, - _org_id: &OrganizationId, - _stripe_customer_id: &str, - ) -> Result<(), StoreError> { - Ok(()) - } - - async fn set_organization_plan( - &self, - _org_id: &OrganizationId, - _plan: Plan, - _seat_limit: i32, - ) -> Result<(), StoreError> { - Ok(()) - } - - async fn delete_organization(&self, _org_id: &OrganizationId) -> Result<(), StoreError> { - Ok(()) - } - - async fn add_organization_member( - &self, - _org_id: &OrganizationId, - _user_id: &UserId, - _role: OrganizationRole, - _invited_by: Option, - ) -> Result<(), StoreError> { - Ok(()) - } - - async fn get_organization_member( - &self, - _org_id: &OrganizationId, - _user_id: &UserId, - ) -> Result { - Err(StoreError::NotFound) - } - - async fn list_organization_members( - &self, - _org_id: &OrganizationId, - ) -> Result, StoreError> { - Ok(vec![]) - } - - async fn update_organization_member_role( - &self, - _org_id: &OrganizationId, - _user_id: &UserId, - _role: OrganizationRole, - ) -> Result<(), StoreError> { - Ok(()) - } - - async fn remove_organization_member( - &self, - _org_id: &OrganizationId, - _user_id: &UserId, - ) -> Result<(), StoreError> { - Ok(()) - } - - async fn count_organization_members( - &self, - _org_id: &OrganizationId, - ) -> Result { - Ok(0) - } - - async fn create_organization_invite( - &self, - params: &CreateOrganizationInviteParams, - ) -> Result { - Ok(OrganizationInvite { - id: OrganizationInviteId(Uuid::new_v4()), - organization_id: params.organization_id.clone(), - email: params.email.clone(), - role: params.role, - token_hash: params.token_hash.clone(), - invited_by: params.invited_by.clone(), - expires_at: params.expires_at, - created_at: Utc::now(), - }) - } - - async fn get_organization_invite( - &self, - _invite_id: &OrganizationInviteId, - ) -> Result { - Err(StoreError::NotFound) - } - - async fn get_organization_invite_by_token( - &self, - _token_hash: &str, - ) -> Result { - Err(StoreError::NotFound) - } - - async fn list_organization_invites( - &self, - _org_id: &OrganizationId, - ) -> Result, StoreError> { - Ok(vec![]) - } - - async fn delete_organization_invite( - &self, - _invite_id: &OrganizationInviteId, - ) -> Result<(), StoreError> { - Ok(()) - } - - async fn set_workspace_organization( - &self, - _workspace_id: &WorkspaceId, - _org_id: Option, - ) -> Result<(), StoreError> { - Ok(()) - } - - async fn list_organization_workspaces( - &self, - _org_id: &OrganizationId, - ) -> Result, StoreError> { - Ok(vec![]) - } } #[tokio::test] diff --git a/crates/zopp-storage/src/store.rs b/crates/zopp-storage/src/store.rs index 5fbf1bac..e3bcb1d4 100644 --- a/crates/zopp-storage/src/store.rs +++ b/crates/zopp-storage/src/store.rs @@ -601,139 +601,4 @@ pub trait Store: Send + Sync { environment_id: &EnvironmentId, group_id: &GroupId, ) -> Result<(), StoreError>; - - // ────────────────────────────────────── Organizations ──────────────────────────────────────── - - /// Create a new organization (returns generated ID). - async fn create_organization( - &self, - params: &CreateOrganizationParams, - ) -> Result; - - /// Get organization by ID. - async fn get_organization(&self, org_id: &OrganizationId) -> Result; - - /// Get organization by slug. - async fn get_organization_by_slug(&self, slug: &str) -> Result; - - /// List all organizations a user is a member of. - async fn list_user_organizations( - &self, - user_id: &UserId, - ) -> Result, StoreError>; - - /// Update organization details. - async fn update_organization( - &self, - org_id: &OrganizationId, - name: Option, - slug: Option, - ) -> Result<(), StoreError>; - - /// Update organization's Stripe customer ID. - async fn set_organization_stripe_customer( - &self, - org_id: &OrganizationId, - stripe_customer_id: &str, - ) -> Result<(), StoreError>; - - /// Update organization's plan and seat limit. - async fn set_organization_plan( - &self, - org_id: &OrganizationId, - plan: Plan, - seat_limit: i32, - ) -> Result<(), StoreError>; - - /// Delete an organization (cascades to members, invites, settings). - async fn delete_organization(&self, org_id: &OrganizationId) -> Result<(), StoreError>; - - // ────────────────────────────────────── Organization Members ──────────────────────────────────────── - - /// Add a user to an organization with a role. - async fn add_organization_member( - &self, - org_id: &OrganizationId, - user_id: &UserId, - role: OrganizationRole, - invited_by: Option, - ) -> Result<(), StoreError>; - - /// Get a user's membership in an organization. - async fn get_organization_member( - &self, - org_id: &OrganizationId, - user_id: &UserId, - ) -> Result; - - /// List all members of an organization. - async fn list_organization_members( - &self, - org_id: &OrganizationId, - ) -> Result, StoreError>; - - /// Update a member's role in an organization. - async fn update_organization_member_role( - &self, - org_id: &OrganizationId, - user_id: &UserId, - role: OrganizationRole, - ) -> Result<(), StoreError>; - - /// Remove a user from an organization. - async fn remove_organization_member( - &self, - org_id: &OrganizationId, - user_id: &UserId, - ) -> Result<(), StoreError>; - - /// Count members in an organization. - async fn count_organization_members(&self, org_id: &OrganizationId) -> Result; - - // ────────────────────────────────────── Organization Invites ──────────────────────────────────────── - - /// Create an organization invite. - async fn create_organization_invite( - &self, - params: &CreateOrganizationInviteParams, - ) -> Result; - - /// Get an organization invite by ID. - async fn get_organization_invite( - &self, - invite_id: &OrganizationInviteId, - ) -> Result; - - /// Get an organization invite by token hash. - async fn get_organization_invite_by_token( - &self, - token_hash: &str, - ) -> Result; - - /// List pending invites for an organization. - async fn list_organization_invites( - &self, - org_id: &OrganizationId, - ) -> Result, StoreError>; - - /// Delete an organization invite (revoke or after consumption). - async fn delete_organization_invite( - &self, - invite_id: &OrganizationInviteId, - ) -> Result<(), StoreError>; - - // ────────────────────────────────────── Workspace-Organization Link ──────────────────────────────────────── - - /// Link a workspace to an organization. - async fn set_workspace_organization( - &self, - workspace_id: &WorkspaceId, - org_id: Option, - ) -> Result<(), StoreError>; - - /// List workspaces belonging to an organization. - async fn list_organization_workspaces( - &self, - org_id: &OrganizationId, - ) -> Result, StoreError>; } diff --git a/crates/zopp-storage/src/types/ids.rs b/crates/zopp-storage/src/types/ids.rs index f516acdf..606034aa 100644 --- a/crates/zopp-storage/src/types/ids.rs +++ b/crates/zopp-storage/src/types/ids.rs @@ -46,18 +46,6 @@ pub struct PrincipalExportId(pub Uuid); #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct EmailVerificationId(pub Uuid); -/// Organization identifier. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct OrganizationId(pub Uuid); - -/// Organization invite identifier. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct OrganizationInviteId(pub Uuid); - -/// Subscription identifier. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct SubscriptionId(pub Uuid); - #[cfg(test)] mod tests { use super::*; diff --git a/crates/zopp-storage/src/types/mod.rs b/crates/zopp-storage/src/types/mod.rs index 88f62b3a..34966abc 100644 --- a/crates/zopp-storage/src/types/mod.rs +++ b/crates/zopp-storage/src/types/mod.rs @@ -4,7 +4,6 @@ mod environments; mod groups; mod ids; mod invites; -mod organizations; mod permissions; mod principals; mod projects; @@ -18,7 +17,6 @@ pub use environments::*; pub use groups::*; pub use ids::*; pub use invites::*; -pub use organizations::*; pub use permissions::*; pub use principals::*; pub use projects::*; diff --git a/crates/zopp-storage/src/types/organizations.rs b/crates/zopp-storage/src/types/organizations.rs deleted file mode 100644 index fd1b59ea..00000000 --- a/crates/zopp-storage/src/types/organizations.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Organization types for billing and team management. - -use chrono::{DateTime, Utc}; - -use super::{ - OrganizationId, OrganizationInviteId, OrganizationRole, Plan, SubscriptionId, - SubscriptionStatus, UserId, -}; - -/// Organization record (billing unit) -#[derive(Clone, Debug)] -pub struct Organization { - pub id: OrganizationId, - pub name: String, - pub slug: String, - pub stripe_customer_id: Option, - pub stripe_subscription_id: Option, - pub plan: Plan, - pub seat_limit: i32, - pub trial_ends_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// Organization member record -#[derive(Clone, Debug)] -pub struct OrganizationMember { - pub organization_id: OrganizationId, - pub user_id: UserId, - pub role: OrganizationRole, - pub invited_by: Option, - pub joined_at: DateTime, -} - -/// Pending organization invite -#[derive(Clone, Debug)] -pub struct OrganizationInvite { - pub id: OrganizationInviteId, - pub organization_id: OrganizationId, - pub email: String, - pub role: OrganizationRole, - pub token_hash: String, - pub invited_by: UserId, - pub expires_at: DateTime, - pub created_at: DateTime, -} - -/// Organization settings -#[derive(Clone, Debug)] -pub struct OrganizationSettings { - pub organization_id: OrganizationId, - pub require_email_verification: bool, - pub require_2fa: bool, - pub allowed_email_domains: Option>, - pub sso_config: Option, // JSON string - pub updated_at: DateTime, -} - -/// Subscription record -#[derive(Clone, Debug)] -pub struct Subscription { - pub id: SubscriptionId, - pub organization_id: OrganizationId, - pub stripe_subscription_id: String, - pub stripe_price_id: String, - pub plan: Plan, - pub status: SubscriptionStatus, - pub current_period_start: DateTime, - pub current_period_end: DateTime, - pub cancel_at_period_end: bool, - pub canceled_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// Parameters for creating an organization -#[derive(Clone, Debug)] -pub struct CreateOrganizationParams { - pub name: String, - pub slug: String, - pub owner_user_id: UserId, - pub plan: Plan, -} - -/// Parameters for creating an organization invite -#[derive(Clone, Debug)] -pub struct CreateOrganizationInviteParams { - pub organization_id: OrganizationId, - pub email: String, - pub role: OrganizationRole, - pub token_hash: String, - pub invited_by: UserId, - pub expires_at: DateTime, -} diff --git a/crates/zopp-storage/src/types/roles.rs b/crates/zopp-storage/src/types/roles.rs index 694857c7..e887ca38 100644 --- a/crates/zopp-storage/src/types/roles.rs +++ b/crates/zopp-storage/src/types/roles.rs @@ -1,4 +1,4 @@ -//! Role types for RBAC permissions and organization membership. +//! Role types for RBAC permissions. use std::str::FromStr; @@ -54,137 +54,6 @@ impl Role { } } -/// Role within an organization -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum OrganizationRole { - Owner, // Full control, billing, can delete org - Admin, // Manage members, settings, but not billing - Member, // Access to org workspaces based on permissions -} - -/// Error type for parsing OrganizationRole from string -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParseOrganizationRoleError(pub String); - -impl std::fmt::Display for ParseOrganizationRoleError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "invalid organization role: {}", self.0) - } -} - -impl std::error::Error for ParseOrganizationRoleError {} - -impl FromStr for OrganizationRole { - type Err = ParseOrganizationRoleError; - - fn from_str(s: &str) -> Result { - match s { - "owner" => Ok(OrganizationRole::Owner), - "admin" => Ok(OrganizationRole::Admin), - "member" => Ok(OrganizationRole::Member), - _ => Err(ParseOrganizationRoleError(s.to_string())), - } - } -} - -impl OrganizationRole { - pub fn as_str(&self) -> &'static str { - match self { - OrganizationRole::Owner => "owner", - OrganizationRole::Admin => "admin", - OrganizationRole::Member => "member", - } - } - - /// Check if this role has at least the permissions of another role - pub fn includes(&self, other: &OrganizationRole) -> bool { - match self { - OrganizationRole::Owner => true, - OrganizationRole::Admin => { - matches!(other, OrganizationRole::Admin | OrganizationRole::Member) - } - OrganizationRole::Member => matches!(other, OrganizationRole::Member), - } - } -} - -/// Billing plan tier -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum Plan { - Free, - Pro, - Enterprise, -} - -impl FromStr for Plan { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "free" => Ok(Plan::Free), - "pro" => Ok(Plan::Pro), - "enterprise" => Ok(Plan::Enterprise), - _ => Err(format!("invalid plan: {}", s)), - } - } -} - -impl Plan { - pub fn as_str(&self) -> &'static str { - match self { - Plan::Free => "free", - Plan::Pro => "pro", - Plan::Enterprise => "enterprise", - } - } - - /// Get the default seat limit for this plan - pub fn default_seat_limit(&self) -> i32 { - match self { - Plan::Free => 3, - Plan::Pro => i32::MAX, // Unlimited (billed per seat) - Plan::Enterprise => i32::MAX, - } - } -} - -/// Subscription status -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum SubscriptionStatus { - Active, - PastDue, - Canceled, - Trialing, - Incomplete, -} - -impl FromStr for SubscriptionStatus { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "active" => Ok(SubscriptionStatus::Active), - "past_due" => Ok(SubscriptionStatus::PastDue), - "canceled" => Ok(SubscriptionStatus::Canceled), - "trialing" => Ok(SubscriptionStatus::Trialing), - "incomplete" => Ok(SubscriptionStatus::Incomplete), - _ => Err(format!("invalid subscription status: {}", s)), - } - } -} - -impl SubscriptionStatus { - pub fn as_str(&self) -> &'static str { - match self { - SubscriptionStatus::Active => "active", - SubscriptionStatus::PastDue => "past_due", - SubscriptionStatus::Canceled => "canceled", - SubscriptionStatus::Trialing => "trialing", - SubscriptionStatus::Incomplete => "incomplete", - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/zopp-store-postgres/.sqlx/query-0323e3b378f1c3c3922259d60e7191b813614b2317e1cda0bf7e2e472a56b056.json b/crates/zopp-store-postgres/.sqlx/query-0323e3b378f1c3c3922259d60e7191b813614b2317e1cda0bf7e2e472a56b056.json deleted file mode 100644 index 539ddc7c..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-0323e3b378f1c3c3922259d60e7191b813614b2317e1cda0bf7e2e472a56b056.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM organizations WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "0323e3b378f1c3c3922259d60e7191b813614b2317e1cda0bf7e2e472a56b056" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-0fc3859c236ee82c7e6b5cc477422884e9e8c65da042d8f78a9893c11faa71d2.json b/crates/zopp-store-postgres/.sqlx/query-0fc3859c236ee82c7e6b5cc477422884e9e8c65da042d8f78a9893c11faa71d2.json deleted file mode 100644 index 61178545..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-0fc3859c236ee82c7e6b5cc477422884e9e8c65da042d8f78a9893c11faa71d2.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at\n FROM organization_invites\n WHERE organization_id = $1\n ORDER BY created_at DESC", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "organization_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "email", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "role", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "token_hash", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "invited_by", - "type_info": "Uuid" - }, - { - "ordinal": 6, - "name": "expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "0fc3859c236ee82c7e6b5cc477422884e9e8c65da042d8f78a9893c11faa71d2" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-234c33a42f7ccb5fa04bee7ea5629a047a70d9d30c36074327c430d078f43499.json b/crates/zopp-store-postgres/.sqlx/query-234c33a42f7ccb5fa04bee7ea5629a047a70d9d30c36074327c430d078f43499.json deleted file mode 100644 index ff9df013..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-234c33a42f7ccb5fa04bee7ea5629a047a70d9d30c36074327c430d078f43499.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE organizations\n SET name = COALESCE($2, name),\n slug = COALESCE($3, slug),\n updated_at = NOW()\n WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "234c33a42f7ccb5fa04bee7ea5629a047a70d9d30c36074327c430d078f43499" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-288c6638a18c25f17a4a43bcb838f1e02c46bb6bb6205bcab15fb4101869d839.json b/crates/zopp-store-postgres/.sqlx/query-288c6638a18c25f17a4a43bcb838f1e02c46bb6bb6205bcab15fb4101869d839.json deleted file mode 100644 index 78dfe042..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-288c6638a18c25f17a4a43bcb838f1e02c46bb6bb6205bcab15fb4101869d839.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, name, slug, stripe_customer_id, stripe_subscription_id,\n plan, seat_limit, trial_ends_at, created_at, updated_at\n FROM organizations WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "slug", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "stripe_customer_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "stripe_subscription_id", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "plan", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "seat_limit", - "type_info": "Int4" - }, - { - "ordinal": 7, - "name": "trial_ends_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - true, - true, - false, - false, - true, - false, - false - ] - }, - "hash": "288c6638a18c25f17a4a43bcb838f1e02c46bb6bb6205bcab15fb4101869d839" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-3071ad0356f61eceb1e42e64a320189b8595dd6444b29bfce135ac0e1d24c0e3.json b/crates/zopp-store-postgres/.sqlx/query-3071ad0356f61eceb1e42e64a320189b8595dd6444b29bfce135ac0e1d24c0e3.json deleted file mode 100644 index 4a08cd24..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-3071ad0356f61eceb1e42e64a320189b8595dd6444b29bfce135ac0e1d24c0e3.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE workspaces SET organization_id = $2, updated_at = NOW()\n WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "3071ad0356f61eceb1e42e64a320189b8595dd6444b29bfce135ac0e1d24c0e3" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-37eb67948a676ba5281deece50ff5ffda8ffdf9c0e537ff57baa82fd579eda5f.json b/crates/zopp-store-postgres/.sqlx/query-37eb67948a676ba5281deece50ff5ffda8ffdf9c0e537ff57baa82fd579eda5f.json deleted file mode 100644 index dd92fe18..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-37eb67948a676ba5281deece50ff5ffda8ffdf9c0e537ff57baa82fd579eda5f.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, name, slug, stripe_customer_id, stripe_subscription_id,\n plan, seat_limit, trial_ends_at, created_at, updated_at\n FROM organizations WHERE slug = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "slug", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "stripe_customer_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "stripe_subscription_id", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "plan", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "seat_limit", - "type_info": "Int4" - }, - { - "ordinal": 7, - "name": "trial_ends_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - true, - true, - false, - false, - true, - false, - false - ] - }, - "hash": "37eb67948a676ba5281deece50ff5ffda8ffdf9c0e537ff57baa82fd579eda5f" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-3d9cbc31e075891029647633f2de4ff4e86dc5423bf839b05353caf181d9bccd.json b/crates/zopp-store-postgres/.sqlx/query-3d9cbc31e075891029647633f2de4ff4e86dc5423bf839b05353caf181d9bccd.json deleted file mode 100644 index fe2e2150..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-3d9cbc31e075891029647633f2de4ff4e86dc5423bf839b05353caf181d9bccd.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO organization_members (organization_id, user_id, role)\n VALUES ($1, $2, 'owner')", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "3d9cbc31e075891029647633f2de4ff4e86dc5423bf839b05353caf181d9bccd" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-48df595a5dae55478d32b8f334b850fdbfaf034b259291253107b82d1b56b491.json b/crates/zopp-store-postgres/.sqlx/query-48df595a5dae55478d32b8f334b850fdbfaf034b259291253107b82d1b56b491.json deleted file mode 100644 index 83a814bc..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-48df595a5dae55478d32b8f334b850fdbfaf034b259291253107b82d1b56b491.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, name, owner_user_id, kdf_salt, kdf_m_cost_kib, kdf_t_cost, kdf_p_cost,\n created_at, updated_at\n FROM workspaces\n WHERE organization_id = $1\n ORDER BY name", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "owner_user_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "kdf_salt", - "type_info": "Bytea" - }, - { - "ordinal": 4, - "name": "kdf_m_cost_kib", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "kdf_t_cost", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "kdf_p_cost", - "type_info": "Int4" - }, - { - "ordinal": 7, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "48df595a5dae55478d32b8f334b850fdbfaf034b259291253107b82d1b56b491" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-579b59f0642fcbb1438d8364ac13c7a9ee928fe196ee417e53b79b3348c7e220.json b/crates/zopp-store-postgres/.sqlx/query-579b59f0642fcbb1438d8364ac13c7a9ee928fe196ee417e53b79b3348c7e220.json deleted file mode 100644 index 58c90651..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-579b59f0642fcbb1438d8364ac13c7a9ee928fe196ee417e53b79b3348c7e220.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) as count FROM organization_members\n WHERE organization_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "579b59f0642fcbb1438d8364ac13c7a9ee928fe196ee417e53b79b3348c7e220" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-5bba3a91287006d65af3ffa875532984c37b16fee891de4a6188dbbdf090ee80.json b/crates/zopp-store-postgres/.sqlx/query-5bba3a91287006d65af3ffa875532984c37b16fee891de4a6188dbbdf090ee80.json deleted file mode 100644 index 056e6eb7..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-5bba3a91287006d65af3ffa875532984c37b16fee891de4a6188dbbdf090ee80.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO organization_invites\n (id, organization_id, email, role, token_hash, invited_by, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Text", - "Text", - "Text", - "Uuid", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "5bba3a91287006d65af3ffa875532984c37b16fee891de4a6188dbbdf090ee80" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-5d3f3262271112bf7c32cce870a7d91ab0f410c09c1e8235a77e2a5992a37610.json b/crates/zopp-store-postgres/.sqlx/query-5d3f3262271112bf7c32cce870a7d91ab0f410c09c1e8235a77e2a5992a37610.json deleted file mode 100644 index d0623649..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-5d3f3262271112bf7c32cce870a7d91ab0f410c09c1e8235a77e2a5992a37610.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT o.id, o.name, o.slug, o.stripe_customer_id, o.stripe_subscription_id,\n o.plan, o.seat_limit, o.trial_ends_at, o.created_at, o.updated_at\n FROM organizations o\n INNER JOIN organization_members m ON o.id = m.organization_id\n WHERE m.user_id = $1\n ORDER BY o.name", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "slug", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "stripe_customer_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "stripe_subscription_id", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "plan", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "seat_limit", - "type_info": "Int4" - }, - { - "ordinal": 7, - "name": "trial_ends_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - true, - true, - false, - false, - true, - false, - false - ] - }, - "hash": "5d3f3262271112bf7c32cce870a7d91ab0f410c09c1e8235a77e2a5992a37610" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-665591f178d1215811de911224a521d8896f3634e9f022e8f3d14e98793dffe5.json b/crates/zopp-store-postgres/.sqlx/query-665591f178d1215811de911224a521d8896f3634e9f022e8f3d14e98793dffe5.json deleted file mode 100644 index 2a5aff9e..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-665591f178d1215811de911224a521d8896f3634e9f022e8f3d14e98793dffe5.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT organization_id, user_id, role, invited_by, joined_at\n FROM organization_members\n WHERE organization_id = $1 AND user_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "organization_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "role", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "invited_by", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "joined_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - true, - false - ] - }, - "hash": "665591f178d1215811de911224a521d8896f3634e9f022e8f3d14e98793dffe5" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-78500b2685c1261b573092bbd790b0a47cc197f67f923122462d65125d07518c.json b/crates/zopp-store-postgres/.sqlx/query-78500b2685c1261b573092bbd790b0a47cc197f67f923122462d65125d07518c.json deleted file mode 100644 index 035eef5d..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-78500b2685c1261b573092bbd790b0a47cc197f67f923122462d65125d07518c.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE organizations\n SET plan = $2, seat_limit = $3, updated_at = NOW()\n WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "78500b2685c1261b573092bbd790b0a47cc197f67f923122462d65125d07518c" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-79d8ab9c23370c8e6781981fa13e4189660d77b4f57cb2ea17e048678c915e13.json b/crates/zopp-store-postgres/.sqlx/query-79d8ab9c23370c8e6781981fa13e4189660d77b4f57cb2ea17e048678c915e13.json deleted file mode 100644 index bfec48ae..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-79d8ab9c23370c8e6781981fa13e4189660d77b4f57cb2ea17e048678c915e13.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at\n FROM organization_invites\n WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "organization_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "email", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "role", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "token_hash", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "invited_by", - "type_info": "Uuid" - }, - { - "ordinal": 6, - "name": "expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "79d8ab9c23370c8e6781981fa13e4189660d77b4f57cb2ea17e048678c915e13" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-994504c9b8fa461ccd2a4828b78dd6121cc7bff093c917563024fb35112dff4b.json b/crates/zopp-store-postgres/.sqlx/query-994504c9b8fa461ccd2a4828b78dd6121cc7bff093c917563024fb35112dff4b.json deleted file mode 100644 index 59b60e42..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-994504c9b8fa461ccd2a4828b78dd6121cc7bff093c917563024fb35112dff4b.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at\n FROM organization_invites\n WHERE token_hash = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "organization_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "email", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "role", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "token_hash", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "invited_by", - "type_info": "Uuid" - }, - { - "ordinal": 6, - "name": "expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "994504c9b8fa461ccd2a4828b78dd6121cc7bff093c917563024fb35112dff4b" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-996715d534c8d8d8078fc6b7cee6465943038b15dff6df3e8a9b2220c9fbb4dc.json b/crates/zopp-store-postgres/.sqlx/query-996715d534c8d8d8078fc6b7cee6465943038b15dff6df3e8a9b2220c9fbb4dc.json deleted file mode 100644 index c469c780..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-996715d534c8d8d8078fc6b7cee6465943038b15dff6df3e8a9b2220c9fbb4dc.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM organization_members\n WHERE organization_id = $1 AND user_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "996715d534c8d8d8078fc6b7cee6465943038b15dff6df3e8a9b2220c9fbb4dc" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-a993a55a83bd3d4c599c73f345824b5b43a3078b74ff59fa1609563c073be1a3.json b/crates/zopp-store-postgres/.sqlx/query-a993a55a83bd3d4c599c73f345824b5b43a3078b74ff59fa1609563c073be1a3.json deleted file mode 100644 index caf70a7b..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-a993a55a83bd3d4c599c73f345824b5b43a3078b74ff59fa1609563c073be1a3.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE organizations\n SET stripe_customer_id = $2, updated_at = NOW()\n WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [] - }, - "hash": "a993a55a83bd3d4c599c73f345824b5b43a3078b74ff59fa1609563c073be1a3" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-acb1203700a78db86cfd2545741b3bc215c98b60f92b64f25c9e168c7969abe7.json b/crates/zopp-store-postgres/.sqlx/query-acb1203700a78db86cfd2545741b3bc215c98b60f92b64f25c9e168c7969abe7.json deleted file mode 100644 index 526d29fc..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-acb1203700a78db86cfd2545741b3bc215c98b60f92b64f25c9e168c7969abe7.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO organizations (id, name, slug, plan, seat_limit)\n VALUES ($1, $2, $3, $4, $5)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "Text", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "acb1203700a78db86cfd2545741b3bc215c98b60f92b64f25c9e168c7969abe7" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-bba328782be5a31b3e57d458702b7fb138c8f5a7c911c8b301958f65aad5cb19.json b/crates/zopp-store-postgres/.sqlx/query-bba328782be5a31b3e57d458702b7fb138c8f5a7c911c8b301958f65aad5cb19.json deleted file mode 100644 index 6ad65fce..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-bba328782be5a31b3e57d458702b7fb138c8f5a7c911c8b301958f65aad5cb19.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO organization_members (organization_id, user_id, role, invited_by)\n VALUES ($1, $2, $3, $4)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Text", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "bba328782be5a31b3e57d458702b7fb138c8f5a7c911c8b301958f65aad5cb19" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-d4279ef0eff95d844b2aa9e6b07ccd5366db85d4fa58bbef2b75c10286a3d6eb.json b/crates/zopp-store-postgres/.sqlx/query-d4279ef0eff95d844b2aa9e6b07ccd5366db85d4fa58bbef2b75c10286a3d6eb.json deleted file mode 100644 index a81949bb..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-d4279ef0eff95d844b2aa9e6b07ccd5366db85d4fa58bbef2b75c10286a3d6eb.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT organization_id, user_id, role, invited_by, joined_at\n FROM organization_members\n WHERE organization_id = $1\n ORDER BY joined_at", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "organization_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "role", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "invited_by", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "joined_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - true, - false - ] - }, - "hash": "d4279ef0eff95d844b2aa9e6b07ccd5366db85d4fa58bbef2b75c10286a3d6eb" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-e9253499dbde0e721d64e3db492de5a0267639734482a625ab632b8306f463c2.json b/crates/zopp-store-postgres/.sqlx/query-e9253499dbde0e721d64e3db492de5a0267639734482a625ab632b8306f463c2.json deleted file mode 100644 index c95412d0..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-e9253499dbde0e721d64e3db492de5a0267639734482a625ab632b8306f463c2.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM organization_invites WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "e9253499dbde0e721d64e3db492de5a0267639734482a625ab632b8306f463c2" -} diff --git a/crates/zopp-store-postgres/.sqlx/query-edb780fa09a163aecefe3b5e5977e74257753529ee3fd5aed0648d1a2ed39310.json b/crates/zopp-store-postgres/.sqlx/query-edb780fa09a163aecefe3b5e5977e74257753529ee3fd5aed0648d1a2ed39310.json deleted file mode 100644 index e84593f9..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-edb780fa09a163aecefe3b5e5977e74257753529ee3fd5aed0648d1a2ed39310.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE organization_members SET role = $3\n WHERE organization_id = $1 AND user_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Text" - ] - }, - "nullable": [] - }, - "hash": "edb780fa09a163aecefe3b5e5977e74257753529ee3fd5aed0648d1a2ed39310" -} diff --git a/crates/zopp-store-postgres/migrations/20260302000001_drop_cloud_features.sql b/crates/zopp-store-postgres/migrations/20260302000001_drop_cloud_features.sql new file mode 100644 index 00000000..3ade2e59 --- /dev/null +++ b/crates/zopp-store-postgres/migrations/20260302000001_drop_cloud_features.sql @@ -0,0 +1,16 @@ +-- Drop cloud features: organizations, billing, and related infrastructure +-- This migration reverses 20260126000001_add_organizations.sql and 20260126000002_add_billing.sql + +-- Drop tables in FK order (children before parents) +-- CASCADE automatically handles triggers, indexes, and FK constraints +DROP TABLE IF EXISTS payments CASCADE; +DROP TABLE IF EXISTS subscriptions CASCADE; +DROP TABLE IF EXISTS organization_settings CASCADE; +DROP TABLE IF EXISTS organization_invites CASCADE; +DROP TABLE IF EXISTS organization_members CASCADE; + +-- Drop organization_id column from workspaces +ALTER TABLE workspaces DROP COLUMN IF EXISTS organization_id; + +-- Drop organizations table last (parent) +DROP TABLE IF EXISTS organizations CASCADE; diff --git a/crates/zopp-store-postgres/src/lib.rs b/crates/zopp-store-postgres/src/lib.rs index 39ed8f46..b9f0e998 100644 --- a/crates/zopp-store-postgres/src/lib.rs +++ b/crates/zopp-store-postgres/src/lib.rs @@ -2531,606 +2531,6 @@ impl Store for PostgresStore { Ok(()) } - - // ────────────────────────────────────── Organizations ────────────────────────────────────── - - async fn create_organization( - &self, - params: &zopp_storage::CreateOrganizationParams, - ) -> Result { - let org_id = zopp_storage::OrganizationId(uuid::Uuid::now_v7()); - let seat_limit = params.plan.default_seat_limit(); - let plan_str = params.plan.as_str(); - - // Use a transaction to ensure atomicity - both org and owner membership must succeed - let mut tx = self - .pool - .begin() - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - sqlx::query!( - r#"INSERT INTO organizations (id, name, slug, plan, seat_limit) - VALUES ($1, $2, $3, $4, $5)"#, - org_id.0, - params.name, - params.slug, - plan_str, - seat_limit - ) - .execute(&mut *tx) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { - StoreError::AlreadyExists - } - e => StoreError::Backend(e.to_string()), - })?; - - // Add the owner as a member - sqlx::query!( - r#"INSERT INTO organization_members (organization_id, user_id, role) - VALUES ($1, $2, 'owner')"#, - org_id.0, - params.owner_user_id.0 - ) - .execute(&mut *tx) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - tx.commit() - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - Ok(org_id) - } - - async fn get_organization( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result { - let row = sqlx::query!( - r#"SELECT id, name, slug, stripe_customer_id, stripe_subscription_id, - plan, seat_limit, trial_ends_at, created_at, updated_at - FROM organizations WHERE id = $1"#, - org_id.0 - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))? - .ok_or(StoreError::NotFound)?; - - Ok(zopp_storage::Organization { - id: zopp_storage::OrganizationId(row.id), - name: row.name, - slug: row.slug, - stripe_customer_id: row.stripe_customer_id, - stripe_subscription_id: row.stripe_subscription_id, - plan: row.plan.parse().map_err(|_| { - StoreError::Backend(format!("invalid plan in database: {}", row.plan)) - })?, - seat_limit: row.seat_limit, - trial_ends_at: row.trial_ends_at, - created_at: row.created_at, - updated_at: row.updated_at, - }) - } - - async fn get_organization_by_slug( - &self, - slug: &str, - ) -> Result { - let row = sqlx::query!( - r#"SELECT id, name, slug, stripe_customer_id, stripe_subscription_id, - plan, seat_limit, trial_ends_at, created_at, updated_at - FROM organizations WHERE slug = $1"#, - slug - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))? - .ok_or(StoreError::NotFound)?; - - Ok(zopp_storage::Organization { - id: zopp_storage::OrganizationId(row.id), - name: row.name, - slug: row.slug, - stripe_customer_id: row.stripe_customer_id, - stripe_subscription_id: row.stripe_subscription_id, - plan: row.plan.parse().map_err(|_| { - StoreError::Backend(format!("invalid plan in database: {}", row.plan)) - })?, - seat_limit: row.seat_limit, - trial_ends_at: row.trial_ends_at, - created_at: row.created_at, - updated_at: row.updated_at, - }) - } - - async fn list_user_organizations( - &self, - user_id: &UserId, - ) -> Result, StoreError> { - let rows = sqlx::query!( - r#"SELECT o.id, o.name, o.slug, o.stripe_customer_id, o.stripe_subscription_id, - o.plan, o.seat_limit, o.trial_ends_at, o.created_at, o.updated_at - FROM organizations o - INNER JOIN organization_members m ON o.id = m.organization_id - WHERE m.user_id = $1 - ORDER BY o.name"#, - user_id.0 - ) - .fetch_all(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - let mut orgs = Vec::with_capacity(rows.len()); - for row in rows { - let plan = row.plan.parse().map_err(|_| { - StoreError::Backend(format!("invalid plan in database: {}", row.plan)) - })?; - orgs.push(zopp_storage::Organization { - id: zopp_storage::OrganizationId(row.id), - name: row.name, - slug: row.slug, - stripe_customer_id: row.stripe_customer_id, - stripe_subscription_id: row.stripe_subscription_id, - plan, - seat_limit: row.seat_limit, - trial_ends_at: row.trial_ends_at, - created_at: row.created_at, - updated_at: row.updated_at, - }); - } - Ok(orgs) - } - - async fn update_organization( - &self, - org_id: &zopp_storage::OrganizationId, - name: Option, - slug: Option, - ) -> Result<(), StoreError> { - let result = sqlx::query!( - r#"UPDATE organizations - SET name = COALESCE($2, name), - slug = COALESCE($3, slug), - updated_at = NOW() - WHERE id = $1"#, - org_id.0, - name, - slug - ) - .execute(&self.pool) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { - StoreError::AlreadyExists - } - e => StoreError::Backend(e.to_string()), - })?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn set_organization_stripe_customer( - &self, - org_id: &zopp_storage::OrganizationId, - stripe_customer_id: &str, - ) -> Result<(), StoreError> { - let result = sqlx::query!( - r#"UPDATE organizations - SET stripe_customer_id = $2, updated_at = NOW() - WHERE id = $1"#, - org_id.0, - stripe_customer_id - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn set_organization_plan( - &self, - org_id: &zopp_storage::OrganizationId, - plan: zopp_storage::Plan, - seat_limit: i32, - ) -> Result<(), StoreError> { - let plan_str = plan.as_str(); - let result = sqlx::query!( - r#"UPDATE organizations - SET plan = $2, seat_limit = $3, updated_at = NOW() - WHERE id = $1"#, - org_id.0, - plan_str, - seat_limit - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn delete_organization( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result<(), StoreError> { - let result = sqlx::query!(r#"DELETE FROM organizations WHERE id = $1"#, org_id.0) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn add_organization_member( - &self, - org_id: &zopp_storage::OrganizationId, - user_id: &UserId, - role: zopp_storage::OrganizationRole, - invited_by: Option, - ) -> Result<(), StoreError> { - let role_str = role.as_str(); - let invited_by_id = invited_by.map(|u| u.0); - - sqlx::query!( - r#"INSERT INTO organization_members (organization_id, user_id, role, invited_by) - VALUES ($1, $2, $3, $4)"#, - org_id.0, - user_id.0, - role_str, - invited_by_id - ) - .execute(&self.pool) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { - StoreError::AlreadyExists - } - e => StoreError::Backend(e.to_string()), - })?; - - Ok(()) - } - - async fn get_organization_member( - &self, - org_id: &zopp_storage::OrganizationId, - user_id: &UserId, - ) -> Result { - let row = sqlx::query!( - r#"SELECT organization_id, user_id, role, invited_by, joined_at - FROM organization_members - WHERE organization_id = $1 AND user_id = $2"#, - org_id.0, - user_id.0 - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))? - .ok_or(StoreError::NotFound)?; - - Ok(zopp_storage::OrganizationMember { - organization_id: zopp_storage::OrganizationId(row.organization_id), - user_id: UserId(row.user_id), - role: row.role.parse().map_err(|_| { - StoreError::Backend(format!("invalid role in database: {}", row.role)) - })?, - invited_by: row.invited_by.map(UserId), - joined_at: row.joined_at, - }) - } - - async fn list_organization_members( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result, StoreError> { - let rows = sqlx::query!( - r#"SELECT organization_id, user_id, role, invited_by, joined_at - FROM organization_members - WHERE organization_id = $1 - ORDER BY joined_at"#, - org_id.0 - ) - .fetch_all(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - let mut members = Vec::with_capacity(rows.len()); - for row in rows { - let role = row.role.parse().map_err(|_| { - StoreError::Backend(format!("invalid role in database: {}", row.role)) - })?; - members.push(zopp_storage::OrganizationMember { - organization_id: zopp_storage::OrganizationId(row.organization_id), - user_id: UserId(row.user_id), - role, - invited_by: row.invited_by.map(UserId), - joined_at: row.joined_at, - }); - } - Ok(members) - } - - async fn update_organization_member_role( - &self, - org_id: &zopp_storage::OrganizationId, - user_id: &UserId, - role: zopp_storage::OrganizationRole, - ) -> Result<(), StoreError> { - let role_str = role.as_str(); - let result = sqlx::query!( - r#"UPDATE organization_members SET role = $3 - WHERE organization_id = $1 AND user_id = $2"#, - org_id.0, - user_id.0, - role_str - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn remove_organization_member( - &self, - org_id: &zopp_storage::OrganizationId, - user_id: &UserId, - ) -> Result<(), StoreError> { - let result = sqlx::query!( - r#"DELETE FROM organization_members - WHERE organization_id = $1 AND user_id = $2"#, - org_id.0, - user_id.0 - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn count_organization_members( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result { - let row = sqlx::query!( - r#"SELECT COUNT(*) as count FROM organization_members - WHERE organization_id = $1"#, - org_id.0 - ) - .fetch_one(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - Ok(row.count.unwrap_or(0) as i32) - } - - async fn create_organization_invite( - &self, - params: &zopp_storage::CreateOrganizationInviteParams, - ) -> Result { - let invite_id = zopp_storage::OrganizationInviteId(uuid::Uuid::now_v7()); - let role_str = params.role.as_str(); - - sqlx::query!( - r#"INSERT INTO organization_invites - (id, organization_id, email, role, token_hash, invited_by, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7)"#, - invite_id.0, - params.organization_id.0, - params.email, - role_str, - params.token_hash, - params.invited_by.0, - params.expires_at - ) - .execute(&self.pool) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { - StoreError::AlreadyExists - } - e => StoreError::Backend(e.to_string()), - })?; - - Ok(zopp_storage::OrganizationInvite { - id: invite_id, - organization_id: zopp_storage::OrganizationId(params.organization_id.0), - email: params.email.clone(), - role: params.role, - token_hash: params.token_hash.clone(), - invited_by: UserId(params.invited_by.0), - expires_at: params.expires_at, - created_at: chrono::Utc::now(), - }) - } - - async fn get_organization_invite( - &self, - invite_id: &zopp_storage::OrganizationInviteId, - ) -> Result { - let row = sqlx::query!( - r#"SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at - FROM organization_invites - WHERE id = $1"#, - invite_id.0 - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))? - .ok_or(StoreError::NotFound)?; - - Ok(zopp_storage::OrganizationInvite { - id: zopp_storage::OrganizationInviteId(row.id), - organization_id: zopp_storage::OrganizationId(row.organization_id), - email: row.email, - role: row.role.parse().map_err(|_| { - StoreError::Backend(format!("invalid role in database: {}", row.role)) - })?, - token_hash: row.token_hash, - invited_by: UserId(row.invited_by), - expires_at: row.expires_at, - created_at: row.created_at, - }) - } - - async fn get_organization_invite_by_token( - &self, - token_hash: &str, - ) -> Result { - let row = sqlx::query!( - r#"SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at - FROM organization_invites - WHERE token_hash = $1"#, - token_hash - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))? - .ok_or(StoreError::NotFound)?; - - Ok(zopp_storage::OrganizationInvite { - id: zopp_storage::OrganizationInviteId(row.id), - organization_id: zopp_storage::OrganizationId(row.organization_id), - email: row.email, - role: row.role.parse().map_err(|_| { - StoreError::Backend(format!("invalid role in database: {}", row.role)) - })?, - token_hash: row.token_hash, - invited_by: UserId(row.invited_by), - expires_at: row.expires_at, - created_at: row.created_at, - }) - } - - async fn list_organization_invites( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result, StoreError> { - let rows = sqlx::query!( - r#"SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at - FROM organization_invites - WHERE organization_id = $1 - ORDER BY created_at DESC"#, - org_id.0 - ) - .fetch_all(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - let mut invites = Vec::with_capacity(rows.len()); - for row in rows { - let role = row.role.parse().map_err(|_| { - StoreError::Backend(format!("invalid role in database: {}", row.role)) - })?; - invites.push(zopp_storage::OrganizationInvite { - id: zopp_storage::OrganizationInviteId(row.id), - organization_id: zopp_storage::OrganizationId(row.organization_id), - email: row.email, - role, - token_hash: row.token_hash, - invited_by: UserId(row.invited_by), - expires_at: row.expires_at, - created_at: row.created_at, - }); - } - Ok(invites) - } - - async fn delete_organization_invite( - &self, - invite_id: &zopp_storage::OrganizationInviteId, - ) -> Result<(), StoreError> { - let result = sqlx::query!( - r#"DELETE FROM organization_invites WHERE id = $1"#, - invite_id.0 - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn set_workspace_organization( - &self, - workspace_id: &WorkspaceId, - org_id: Option, - ) -> Result<(), StoreError> { - let org_uuid = org_id.map(|o| o.0); - let result = sqlx::query!( - r#"UPDATE workspaces SET organization_id = $2, updated_at = NOW() - WHERE id = $1"#, - workspace_id.0, - org_uuid - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn list_organization_workspaces( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result, StoreError> { - let rows = sqlx::query!( - r#"SELECT id, name, owner_user_id, kdf_salt, kdf_m_cost_kib, kdf_t_cost, kdf_p_cost, - created_at, updated_at - FROM workspaces - WHERE organization_id = $1 - ORDER BY name"#, - org_id.0 - ) - .fetch_all(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - Ok(rows - .into_iter() - .map(|row| Workspace { - id: WorkspaceId(row.id), - name: row.name, - owner_user_id: UserId(row.owner_user_id), - kdf_salt: row.kdf_salt, - m_cost_kib: row.kdf_m_cost_kib as u32, - t_cost: row.kdf_t_cost as u32, - p_cost: row.kdf_p_cost as u32, - created_at: row.created_at, - updated_at: row.updated_at, - }) - .collect()) - } } // ────────────────────────────────────── Audit Log ────────────────────────────────────── diff --git a/crates/zopp-store-sqlite/.sqlx/query-036d2de3888d62b322e38d7fa36061e5047c43b46456a7604bcaf814d38d64f8.json b/crates/zopp-store-sqlite/.sqlx/query-036d2de3888d62b322e38d7fa36061e5047c43b46456a7604bcaf814d38d64f8.json deleted file mode 100644 index fb485e38..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-036d2de3888d62b322e38d7fa36061e5047c43b46456a7604bcaf814d38d64f8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE workspaces SET organization_id = ? WHERE id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "036d2de3888d62b322e38d7fa36061e5047c43b46456a7604bcaf814d38d64f8" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-0676e5e1e83b862473ecc43f1be1d941a9c9c19f64581ad380355812ca4f69bf.json b/crates/zopp-store-sqlite/.sqlx/query-0676e5e1e83b862473ecc43f1be1d941a9c9c19f64581ad380355812ca4f69bf.json deleted file mode 100644 index b09a0594..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-0676e5e1e83b862473ecc43f1be1d941a9c9c19f64581ad380355812ca4f69bf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE organizations SET plan = ?, seat_limit = ? WHERE id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "0676e5e1e83b862473ecc43f1be1d941a9c9c19f64581ad380355812ca4f69bf" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-0dbb693ba593b161cf0668134e7db40c6c412f2b30a1adfc0930531c971773c4.json b/crates/zopp-store-sqlite/.sqlx/query-0dbb693ba593b161cf0668134e7db40c6c412f2b30a1adfc0930531c971773c4.json deleted file mode 100644 index 1fb0f623..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-0dbb693ba593b161cf0668134e7db40c6c412f2b30a1adfc0930531c971773c4.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, name, slug, stripe_customer_id, stripe_subscription_id,\n plan, seat_limit, trial_ends_at, created_at, updated_at\n FROM organizations WHERE id = ?", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "slug", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "stripe_customer_id", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "stripe_subscription_id", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "plan", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "seat_limit", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "trial_ends_at", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "updated_at", - "ordinal": 9, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - true, - true, - false, - false, - true, - false, - false - ] - }, - "hash": "0dbb693ba593b161cf0668134e7db40c6c412f2b30a1adfc0930531c971773c4" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-0e8bb6c925e8ec92f01c3df397fc0b74672bbc8ec39650cf447ad6493e1ebf7e.json b/crates/zopp-store-sqlite/.sqlx/query-0e8bb6c925e8ec92f01c3df397fc0b74672bbc8ec39650cf447ad6493e1ebf7e.json deleted file mode 100644 index 53063df2..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-0e8bb6c925e8ec92f01c3df397fc0b74672bbc8ec39650cf447ad6493e1ebf7e.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, name, slug, stripe_customer_id, stripe_subscription_id,\n plan, seat_limit, trial_ends_at, created_at, updated_at\n FROM organizations WHERE slug = ?", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "slug", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "stripe_customer_id", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "stripe_subscription_id", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "plan", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "seat_limit", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "trial_ends_at", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "updated_at", - "ordinal": 9, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - true, - true, - false, - false, - true, - false, - false - ] - }, - "hash": "0e8bb6c925e8ec92f01c3df397fc0b74672bbc8ec39650cf447ad6493e1ebf7e" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-316835da6207799cc8ff385634123bfded0197085cf9f20ab509542ffe6c31cd.json b/crates/zopp-store-sqlite/.sqlx/query-316835da6207799cc8ff385634123bfded0197085cf9f20ab509542ffe6c31cd.json deleted file mode 100644 index d10839f3..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-316835da6207799cc8ff385634123bfded0197085cf9f20ab509542ffe6c31cd.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO organization_invites\n (id, organization_id, email, role, token_hash, invited_by, expires_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "316835da6207799cc8ff385634123bfded0197085cf9f20ab509542ffe6c31cd" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-4bce265d78122889745e155285b494f5c33ac2379059d8bb83a5ec9353e2791b.json b/crates/zopp-store-sqlite/.sqlx/query-4bce265d78122889745e155285b494f5c33ac2379059d8bb83a5ec9353e2791b.json deleted file mode 100644 index 90bbce92..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-4bce265d78122889745e155285b494f5c33ac2379059d8bb83a5ec9353e2791b.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT o.id, o.name, o.slug, o.stripe_customer_id, o.stripe_subscription_id,\n o.plan, o.seat_limit, o.trial_ends_at, o.created_at, o.updated_at\n FROM organizations o\n INNER JOIN organization_members m ON o.id = m.organization_id\n WHERE m.user_id = ?\n ORDER BY o.name", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "slug", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "stripe_customer_id", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "stripe_subscription_id", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "plan", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "seat_limit", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "trial_ends_at", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "updated_at", - "ordinal": 9, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - true, - true, - false, - false, - true, - false, - false - ] - }, - "hash": "4bce265d78122889745e155285b494f5c33ac2379059d8bb83a5ec9353e2791b" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-55269f2170144a2a8261fd8880fd1955fe66592cda6d3a635a372939612f5d8b.json b/crates/zopp-store-sqlite/.sqlx/query-55269f2170144a2a8261fd8880fd1955fe66592cda6d3a635a372939612f5d8b.json deleted file mode 100644 index aa31e130..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-55269f2170144a2a8261fd8880fd1955fe66592cda6d3a635a372939612f5d8b.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT organization_id, user_id, role, invited_by, joined_at\n FROM organization_members\n WHERE organization_id = ? AND user_id = ?", - "describe": { - "columns": [ - { - "name": "organization_id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "user_id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "role", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "invited_by", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "joined_at", - "ordinal": 4, - "type_info": "Text" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false, - false, - true, - false - ] - }, - "hash": "55269f2170144a2a8261fd8880fd1955fe66592cda6d3a635a372939612f5d8b" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-5cdf1c6a1da42f987ca02d6d0df8a1c943faeace5956904ecefe2d6db36d6532.json b/crates/zopp-store-sqlite/.sqlx/query-5cdf1c6a1da42f987ca02d6d0df8a1c943faeace5956904ecefe2d6db36d6532.json deleted file mode 100644 index 21d6d0b1..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-5cdf1c6a1da42f987ca02d6d0df8a1c943faeace5956904ecefe2d6db36d6532.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO organizations (id, name, slug, plan, seat_limit)\n VALUES (?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "5cdf1c6a1da42f987ca02d6d0df8a1c943faeace5956904ecefe2d6db36d6532" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-6917cbde3fbed67b4e94d7d6e3fb3d340fae28c6f7edfbcfe1ec7118f8caf93d.json b/crates/zopp-store-sqlite/.sqlx/query-6917cbde3fbed67b4e94d7d6e3fb3d340fae28c6f7edfbcfe1ec7118f8caf93d.json deleted file mode 100644 index 128464a6..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-6917cbde3fbed67b4e94d7d6e3fb3d340fae28c6f7edfbcfe1ec7118f8caf93d.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at\n FROM organization_invites\n WHERE id = ?", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "organization_id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "email", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "role", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "token_hash", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "invited_by", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "expires_at", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 7, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "6917cbde3fbed67b4e94d7d6e3fb3d340fae28c6f7edfbcfe1ec7118f8caf93d" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-6ee071fde6627e2f07d5f6dc156f32c34c8912c5b0525942fcb8135d4516162f.json b/crates/zopp-store-sqlite/.sqlx/query-6ee071fde6627e2f07d5f6dc156f32c34c8912c5b0525942fcb8135d4516162f.json deleted file mode 100644 index f5ca0e69..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-6ee071fde6627e2f07d5f6dc156f32c34c8912c5b0525942fcb8135d4516162f.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at\n FROM organization_invites\n WHERE organization_id = ?\n ORDER BY created_at DESC", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "organization_id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "email", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "role", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "token_hash", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "invited_by", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "expires_at", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 7, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "6ee071fde6627e2f07d5f6dc156f32c34c8912c5b0525942fcb8135d4516162f" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-6f0e5c2f79a68c0cfafb3efb077dc95eb03aee63afdefec430ea6feb3bf19c3e.json b/crates/zopp-store-sqlite/.sqlx/query-6f0e5c2f79a68c0cfafb3efb077dc95eb03aee63afdefec430ea6feb3bf19c3e.json deleted file mode 100644 index dc6f9004..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-6f0e5c2f79a68c0cfafb3efb077dc95eb03aee63afdefec430ea6feb3bf19c3e.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "DELETE FROM organizations WHERE id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "6f0e5c2f79a68c0cfafb3efb077dc95eb03aee63afdefec430ea6feb3bf19c3e" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-8c8ff0e1d6acd92232be184277b70279bc47e260647a4d8917599f8d8639feed.json b/crates/zopp-store-sqlite/.sqlx/query-8c8ff0e1d6acd92232be184277b70279bc47e260647a4d8917599f8d8639feed.json deleted file mode 100644 index 1b50f901..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-8c8ff0e1d6acd92232be184277b70279bc47e260647a4d8917599f8d8639feed.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at\n FROM organization_invites\n WHERE token_hash = ?", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "organization_id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "email", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "role", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "token_hash", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "invited_by", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "expires_at", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "created_at", - "ordinal": 7, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "8c8ff0e1d6acd92232be184277b70279bc47e260647a4d8917599f8d8639feed" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-9ae0ed0cb620449b334cf45acbe080a6f313626eb4e4374221ee79fc39f3213a.json b/crates/zopp-store-sqlite/.sqlx/query-9ae0ed0cb620449b334cf45acbe080a6f313626eb4e4374221ee79fc39f3213a.json deleted file mode 100644 index 43551f1e..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-9ae0ed0cb620449b334cf45acbe080a6f313626eb4e4374221ee79fc39f3213a.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE organization_members SET role = ?\n WHERE organization_id = ? AND user_id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "9ae0ed0cb620449b334cf45acbe080a6f313626eb4e4374221ee79fc39f3213a" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-a63d7083fac3fac35fc6a0feaf7b474c8012dd805372c5f08e07b21b32bccee0.json b/crates/zopp-store-sqlite/.sqlx/query-a63d7083fac3fac35fc6a0feaf7b474c8012dd805372c5f08e07b21b32bccee0.json deleted file mode 100644 index 82ae8ff9..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-a63d7083fac3fac35fc6a0feaf7b474c8012dd805372c5f08e07b21b32bccee0.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO organization_members (organization_id, user_id, role)\n VALUES (?, ?, 'owner')", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "a63d7083fac3fac35fc6a0feaf7b474c8012dd805372c5f08e07b21b32bccee0" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-a6d1f898c2b47c66f22b91cce12ee0f6a50cef08c900d4b70e31bf35f14f26d6.json b/crates/zopp-store-sqlite/.sqlx/query-a6d1f898c2b47c66f22b91cce12ee0f6a50cef08c900d4b70e31bf35f14f26d6.json deleted file mode 100644 index df9684e1..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-a6d1f898c2b47c66f22b91cce12ee0f6a50cef08c900d4b70e31bf35f14f26d6.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE organizations\n SET name = COALESCE(?, name),\n slug = COALESCE(?, slug)\n WHERE id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "a6d1f898c2b47c66f22b91cce12ee0f6a50cef08c900d4b70e31bf35f14f26d6" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-acb58415c77e3bca7630e521940c574e8104446613848092deb0ffc5e35bab29.json b/crates/zopp-store-sqlite/.sqlx/query-acb58415c77e3bca7630e521940c574e8104446613848092deb0ffc5e35bab29.json deleted file mode 100644 index 31804a50..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-acb58415c77e3bca7630e521940c574e8104446613848092deb0ffc5e35bab29.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO organization_members (organization_id, user_id, role, invited_by)\n VALUES (?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "acb58415c77e3bca7630e521940c574e8104446613848092deb0ffc5e35bab29" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-acb8d6d573abb34601be447d99f56e827b42ea700f93b285e8749772d22eb07c.json b/crates/zopp-store-sqlite/.sqlx/query-acb8d6d573abb34601be447d99f56e827b42ea700f93b285e8749772d22eb07c.json deleted file mode 100644 index d481ecba..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-acb8d6d573abb34601be447d99f56e827b42ea700f93b285e8749772d22eb07c.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT organization_id, user_id, role, invited_by, joined_at\n FROM organization_members\n WHERE organization_id = ?\n ORDER BY joined_at", - "describe": { - "columns": [ - { - "name": "organization_id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "user_id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "role", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "invited_by", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "joined_at", - "ordinal": 4, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - true, - false - ] - }, - "hash": "acb8d6d573abb34601be447d99f56e827b42ea700f93b285e8749772d22eb07c" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-b36dc06040bb472a7d25806638712088e3209102cbd29c75f0c9790b7de1c31b.json b/crates/zopp-store-sqlite/.sqlx/query-b36dc06040bb472a7d25806638712088e3209102cbd29c75f0c9790b7de1c31b.json deleted file mode 100644 index 845cee0b..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-b36dc06040bb472a7d25806638712088e3209102cbd29c75f0c9790b7de1c31b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "DELETE FROM organization_invites WHERE id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "b36dc06040bb472a7d25806638712088e3209102cbd29c75f0c9790b7de1c31b" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-b7ab843f8c32d3819a29f0e1ed42292e87c55f41ca59f6f4bbaa4cc04e48f0ca.json b/crates/zopp-store-sqlite/.sqlx/query-b7ab843f8c32d3819a29f0e1ed42292e87c55f41ca59f6f4bbaa4cc04e48f0ca.json deleted file mode 100644 index 9bdefd43..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-b7ab843f8c32d3819a29f0e1ed42292e87c55f41ca59f6f4bbaa4cc04e48f0ca.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, name, owner_user_id, kdf_salt, kdf_m_cost_kib, kdf_t_cost, kdf_p_cost,\n created_at, updated_at\n FROM workspaces\n WHERE organization_id = ?\n ORDER BY name", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "owner_user_id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "kdf_salt", - "ordinal": 3, - "type_info": "Blob" - }, - { - "name": "kdf_m_cost_kib", - "ordinal": 4, - "type_info": "Integer" - }, - { - "name": "kdf_t_cost", - "ordinal": 5, - "type_info": "Integer" - }, - { - "name": "kdf_p_cost", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "created_at", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "updated_at", - "ordinal": 8, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "b7ab843f8c32d3819a29f0e1ed42292e87c55f41ca59f6f4bbaa4cc04e48f0ca" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-da369dff40cd81f942e0d88aa0815d32e0b6d19743738083f9f387469fbce5e5.json b/crates/zopp-store-sqlite/.sqlx/query-da369dff40cd81f942e0d88aa0815d32e0b6d19743738083f9f387469fbce5e5.json deleted file mode 100644 index 2749c998..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-da369dff40cd81f942e0d88aa0815d32e0b6d19743738083f9f387469fbce5e5.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT COUNT(*) as count FROM organization_members\n WHERE organization_id = ?", - "describe": { - "columns": [ - { - "name": "count", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "da369dff40cd81f942e0d88aa0815d32e0b6d19743738083f9f387469fbce5e5" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-e8fedfc80eaa72c6affe6ac8b6300eb247399959559e4b9a37a85107e53c8deb.json b/crates/zopp-store-sqlite/.sqlx/query-e8fedfc80eaa72c6affe6ac8b6300eb247399959559e4b9a37a85107e53c8deb.json deleted file mode 100644 index c0cb9db9..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-e8fedfc80eaa72c6affe6ac8b6300eb247399959559e4b9a37a85107e53c8deb.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "DELETE FROM organization_members\n WHERE organization_id = ? AND user_id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "e8fedfc80eaa72c6affe6ac8b6300eb247399959559e4b9a37a85107e53c8deb" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-f93f6680e0a7f7a81ec2d62c3435cb2c6d3a8015d8071335b0d5c92765266198.json b/crates/zopp-store-sqlite/.sqlx/query-f93f6680e0a7f7a81ec2d62c3435cb2c6d3a8015d8071335b0d5c92765266198.json deleted file mode 100644 index c1fc6132..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-f93f6680e0a7f7a81ec2d62c3435cb2c6d3a8015d8071335b0d5c92765266198.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE organizations SET stripe_customer_id = ? WHERE id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "f93f6680e0a7f7a81ec2d62c3435cb2c6d3a8015d8071335b0d5c92765266198" -} diff --git a/crates/zopp-store-sqlite/migrations/20260302000001_drop_cloud_features.sql b/crates/zopp-store-sqlite/migrations/20260302000001_drop_cloud_features.sql new file mode 100644 index 00000000..99c565ea --- /dev/null +++ b/crates/zopp-store-sqlite/migrations/20260302000001_drop_cloud_features.sql @@ -0,0 +1,27 @@ +-- Drop cloud features: organizations, billing, and related infrastructure +-- This migration reverses 20260126000001_add_organizations.sql and 20260126000002_add_billing.sql + +-- Drop triggers first +DROP TRIGGER IF EXISTS subscriptions_updated_at; +DROP TRIGGER IF EXISTS organization_settings_updated_at; +DROP TRIGGER IF EXISTS organizations_updated_at; + +-- Drop indexes +DROP INDEX IF EXISTS idx_payments_created_at; +DROP INDEX IF EXISTS idx_payments_organization; +DROP INDEX IF EXISTS idx_subscriptions_stripe_id; +DROP INDEX IF EXISTS idx_subscriptions_organization; +DROP INDEX IF EXISTS idx_organization_invites_email; +DROP INDEX IF EXISTS idx_workspaces_organization; +DROP INDEX IF EXISTS idx_organization_members_user; + +-- Drop tables in FK order (children before parents) +DROP TABLE IF EXISTS payments; +DROP TABLE IF EXISTS subscriptions; +DROP TABLE IF EXISTS organization_settings; +DROP TABLE IF EXISTS organization_invites; +DROP TABLE IF EXISTS organization_members; +DROP TABLE IF EXISTS organizations; + +-- Drop organization_id column from workspaces (supported in SQLite 3.35.0+) +ALTER TABLE workspaces DROP COLUMN organization_id; diff --git a/crates/zopp-store-sqlite/src/lib.rs b/crates/zopp-store-sqlite/src/lib.rs index 96bc1125..a5ba3ca6 100644 --- a/crates/zopp-store-sqlite/src/lib.rs +++ b/crates/zopp-store-sqlite/src/lib.rs @@ -3083,734 +3083,6 @@ impl Store for SqliteStore { Ok(()) } - - // ────────────────────────────────────── Organizations ────────────────────────────────────── - - async fn create_organization( - &self, - params: &zopp_storage::CreateOrganizationParams, - ) -> Result { - let org_id = zopp_storage::OrganizationId(Uuid::now_v7()); - let org_id_str = org_id.0.to_string(); - let owner_id_str = params.owner_user_id.0.to_string(); - let seat_limit = params.plan.default_seat_limit(); - let plan_str = params.plan.as_str(); - - // Use a transaction to ensure atomicity - both org and owner membership must succeed - let mut tx = self - .pool - .begin() - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - sqlx::query!( - r#"INSERT INTO organizations (id, name, slug, plan, seat_limit) - VALUES (?, ?, ?, ?, ?)"#, - org_id_str, - params.name, - params.slug, - plan_str, - seat_limit - ) - .execute(&mut *tx) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) - if db_err.message().contains("UNIQUE constraint failed") => - { - StoreError::AlreadyExists - } - e => StoreError::Backend(e.to_string()), - })?; - - // Add the owner as a member - sqlx::query!( - r#"INSERT INTO organization_members (organization_id, user_id, role) - VALUES (?, ?, 'owner')"#, - org_id_str, - owner_id_str - ) - .execute(&mut *tx) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - tx.commit() - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - Ok(org_id) - } - - async fn get_organization( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result { - let org_id_str = org_id.0.to_string(); - let row = sqlx::query!( - r#"SELECT id, name, slug, stripe_customer_id, stripe_subscription_id, - plan, seat_limit, trial_ends_at, created_at, updated_at - FROM organizations WHERE id = ?"#, - org_id_str - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))? - .ok_or(StoreError::NotFound)?; - - let plan = row - .plan - .parse() - .map_err(|_| StoreError::Backend(format!("invalid plan in database: {}", row.plan)))?; - - Ok(zopp_storage::Organization { - id: zopp_storage::OrganizationId(Uuid::parse_str(&row.id).unwrap()), - name: row.name, - slug: row.slug, - stripe_customer_id: row.stripe_customer_id, - stripe_subscription_id: row.stripe_subscription_id, - plan, - seat_limit: row.seat_limit as i32, - trial_ends_at: row.trial_ends_at.map(|t| { - DateTime::parse_from_rfc3339(&t) - .unwrap() - .with_timezone(&Utc) - }), - created_at: DateTime::parse_from_rfc3339(&row.created_at) - .unwrap() - .with_timezone(&Utc), - updated_at: DateTime::parse_from_rfc3339(&row.updated_at) - .unwrap() - .with_timezone(&Utc), - }) - } - - async fn get_organization_by_slug( - &self, - slug: &str, - ) -> Result { - let row = sqlx::query!( - r#"SELECT id, name, slug, stripe_customer_id, stripe_subscription_id, - plan, seat_limit, trial_ends_at, created_at, updated_at - FROM organizations WHERE slug = ?"#, - slug - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))? - .ok_or(StoreError::NotFound)?; - - let plan = row - .plan - .parse() - .map_err(|_| StoreError::Backend(format!("invalid plan in database: {}", row.plan)))?; - - Ok(zopp_storage::Organization { - id: zopp_storage::OrganizationId(Uuid::parse_str(&row.id).unwrap()), - name: row.name, - slug: row.slug, - stripe_customer_id: row.stripe_customer_id, - stripe_subscription_id: row.stripe_subscription_id, - plan, - seat_limit: row.seat_limit as i32, - trial_ends_at: row.trial_ends_at.map(|t| { - DateTime::parse_from_rfc3339(&t) - .unwrap() - .with_timezone(&Utc) - }), - created_at: DateTime::parse_from_rfc3339(&row.created_at) - .unwrap() - .with_timezone(&Utc), - updated_at: DateTime::parse_from_rfc3339(&row.updated_at) - .unwrap() - .with_timezone(&Utc), - }) - } - - async fn list_user_organizations( - &self, - user_id: &UserId, - ) -> Result, StoreError> { - let user_id_str = user_id.0.to_string(); - let rows = sqlx::query!( - r#"SELECT o.id, o.name, o.slug, o.stripe_customer_id, o.stripe_subscription_id, - o.plan, o.seat_limit, o.trial_ends_at, o.created_at, o.updated_at - FROM organizations o - INNER JOIN organization_members m ON o.id = m.organization_id - WHERE m.user_id = ? - ORDER BY o.name"#, - user_id_str - ) - .fetch_all(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - let mut orgs = Vec::with_capacity(rows.len()); - for row in rows { - let plan = row.plan.parse().map_err(|_| { - StoreError::Backend(format!("invalid plan in database: {}", row.plan)) - })?; - orgs.push(zopp_storage::Organization { - id: zopp_storage::OrganizationId(Uuid::parse_str(&row.id).unwrap()), - name: row.name, - slug: row.slug, - stripe_customer_id: row.stripe_customer_id, - stripe_subscription_id: row.stripe_subscription_id, - plan, - seat_limit: row.seat_limit as i32, - trial_ends_at: row.trial_ends_at.map(|t| { - DateTime::parse_from_rfc3339(&t) - .unwrap() - .with_timezone(&Utc) - }), - created_at: DateTime::parse_from_rfc3339(&row.created_at) - .unwrap() - .with_timezone(&Utc), - updated_at: DateTime::parse_from_rfc3339(&row.updated_at) - .unwrap() - .with_timezone(&Utc), - }); - } - Ok(orgs) - } - - async fn update_organization( - &self, - org_id: &zopp_storage::OrganizationId, - name: Option, - slug: Option, - ) -> Result<(), StoreError> { - let org_id_str = org_id.0.to_string(); - let result = sqlx::query!( - r#"UPDATE organizations - SET name = COALESCE(?, name), - slug = COALESCE(?, slug) - WHERE id = ?"#, - name, - slug, - org_id_str - ) - .execute(&self.pool) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) - if db_err.message().contains("UNIQUE constraint failed") => - { - StoreError::AlreadyExists - } - e => StoreError::Backend(e.to_string()), - })?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn set_organization_stripe_customer( - &self, - org_id: &zopp_storage::OrganizationId, - stripe_customer_id: &str, - ) -> Result<(), StoreError> { - let org_id_str = org_id.0.to_string(); - let result = sqlx::query!( - r#"UPDATE organizations SET stripe_customer_id = ? WHERE id = ?"#, - stripe_customer_id, - org_id_str - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn set_organization_plan( - &self, - org_id: &zopp_storage::OrganizationId, - plan: zopp_storage::Plan, - seat_limit: i32, - ) -> Result<(), StoreError> { - let org_id_str = org_id.0.to_string(); - let plan_str = plan.as_str(); - let result = sqlx::query!( - r#"UPDATE organizations SET plan = ?, seat_limit = ? WHERE id = ?"#, - plan_str, - seat_limit, - org_id_str - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn delete_organization( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result<(), StoreError> { - let org_id_str = org_id.0.to_string(); - let result = sqlx::query!(r#"DELETE FROM organizations WHERE id = ?"#, org_id_str) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn add_organization_member( - &self, - org_id: &zopp_storage::OrganizationId, - user_id: &UserId, - role: zopp_storage::OrganizationRole, - invited_by: Option, - ) -> Result<(), StoreError> { - let org_id_str = org_id.0.to_string(); - let user_id_str = user_id.0.to_string(); - let role_str = role.as_str(); - let invited_by_str = invited_by.map(|u| u.0.to_string()); - - sqlx::query!( - r#"INSERT INTO organization_members (organization_id, user_id, role, invited_by) - VALUES (?, ?, ?, ?)"#, - org_id_str, - user_id_str, - role_str, - invited_by_str - ) - .execute(&self.pool) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) - if db_err.message().contains("UNIQUE constraint failed") => - { - StoreError::AlreadyExists - } - e => StoreError::Backend(e.to_string()), - })?; - - Ok(()) - } - - async fn get_organization_member( - &self, - org_id: &zopp_storage::OrganizationId, - user_id: &UserId, - ) -> Result { - let org_id_str = org_id.0.to_string(); - let user_id_str = user_id.0.to_string(); - let row = sqlx::query!( - r#"SELECT organization_id, user_id, role, invited_by, joined_at - FROM organization_members - WHERE organization_id = ? AND user_id = ?"#, - org_id_str, - user_id_str - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))? - .ok_or(StoreError::NotFound)?; - - let role = row - .role - .parse() - .map_err(|_| StoreError::Backend(format!("invalid role in database: {}", row.role)))?; - - Ok(zopp_storage::OrganizationMember { - organization_id: zopp_storage::OrganizationId( - Uuid::parse_str(&row.organization_id).unwrap(), - ), - user_id: UserId(Uuid::parse_str(&row.user_id).unwrap()), - role, - invited_by: row.invited_by.map(|u| UserId(Uuid::parse_str(&u).unwrap())), - joined_at: DateTime::parse_from_rfc3339(&row.joined_at) - .unwrap() - .with_timezone(&Utc), - }) - } - - async fn list_organization_members( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result, StoreError> { - let org_id_str = org_id.0.to_string(); - let rows = sqlx::query!( - r#"SELECT organization_id, user_id, role, invited_by, joined_at - FROM organization_members - WHERE organization_id = ? - ORDER BY joined_at"#, - org_id_str - ) - .fetch_all(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - let mut members = Vec::with_capacity(rows.len()); - for row in rows { - let role = row.role.parse().map_err(|_| { - StoreError::Backend(format!("invalid role in database: {}", row.role)) - })?; - members.push(zopp_storage::OrganizationMember { - organization_id: zopp_storage::OrganizationId( - Uuid::parse_str(&row.organization_id).unwrap(), - ), - user_id: UserId(Uuid::parse_str(&row.user_id).unwrap()), - role, - invited_by: row.invited_by.map(|u| UserId(Uuid::parse_str(&u).unwrap())), - joined_at: DateTime::parse_from_rfc3339(&row.joined_at) - .unwrap() - .with_timezone(&Utc), - }); - } - Ok(members) - } - - async fn update_organization_member_role( - &self, - org_id: &zopp_storage::OrganizationId, - user_id: &UserId, - role: zopp_storage::OrganizationRole, - ) -> Result<(), StoreError> { - let org_id_str = org_id.0.to_string(); - let user_id_str = user_id.0.to_string(); - let role_str = role.as_str(); - let result = sqlx::query!( - r#"UPDATE organization_members SET role = ? - WHERE organization_id = ? AND user_id = ?"#, - role_str, - org_id_str, - user_id_str - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn remove_organization_member( - &self, - org_id: &zopp_storage::OrganizationId, - user_id: &UserId, - ) -> Result<(), StoreError> { - let org_id_str = org_id.0.to_string(); - let user_id_str = user_id.0.to_string(); - let result = sqlx::query!( - r#"DELETE FROM organization_members - WHERE organization_id = ? AND user_id = ?"#, - org_id_str, - user_id_str - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn count_organization_members( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result { - let org_id_str = org_id.0.to_string(); - let row = sqlx::query!( - r#"SELECT COUNT(*) as count FROM organization_members - WHERE organization_id = ?"#, - org_id_str - ) - .fetch_one(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - Ok(row.count as i32) - } - - async fn create_organization_invite( - &self, - params: &zopp_storage::CreateOrganizationInviteParams, - ) -> Result { - let invite_id = zopp_storage::OrganizationInviteId(Uuid::now_v7()); - let invite_id_str = invite_id.0.to_string(); - let org_id_str = params.organization_id.0.to_string(); - let invited_by_str = params.invited_by.0.to_string(); - let role_str = params.role.as_str(); - let expires_at_str = params.expires_at.to_rfc3339(); - - sqlx::query!( - r#"INSERT INTO organization_invites - (id, organization_id, email, role, token_hash, invited_by, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?)"#, - invite_id_str, - org_id_str, - params.email, - role_str, - params.token_hash, - invited_by_str, - expires_at_str - ) - .execute(&self.pool) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) - if db_err.message().contains("UNIQUE constraint failed") => - { - StoreError::AlreadyExists - } - e => StoreError::Backend(e.to_string()), - })?; - - Ok(zopp_storage::OrganizationInvite { - id: invite_id, - organization_id: zopp_storage::OrganizationId(params.organization_id.0), - email: params.email.clone(), - role: params.role, - token_hash: params.token_hash.clone(), - invited_by: UserId(params.invited_by.0), - expires_at: params.expires_at, - created_at: chrono::Utc::now(), - }) - } - - async fn get_organization_invite( - &self, - invite_id: &zopp_storage::OrganizationInviteId, - ) -> Result { - let invite_id_str = invite_id.0.to_string(); - let row = sqlx::query!( - r#"SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at - FROM organization_invites - WHERE id = ?"#, - invite_id_str - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))? - .ok_or(StoreError::NotFound)?; - - let role = row - .role - .parse() - .map_err(|_| StoreError::Backend(format!("invalid role in database: {}", row.role)))?; - - Ok(zopp_storage::OrganizationInvite { - id: zopp_storage::OrganizationInviteId( - Uuid::parse_str(&row.id) - .map_err(|e| StoreError::Backend(format!("invalid invite id: {}", e)))?, - ), - organization_id: zopp_storage::OrganizationId( - Uuid::parse_str(&row.organization_id) - .map_err(|e| StoreError::Backend(format!("invalid organization_id: {}", e)))?, - ), - email: row.email, - role, - token_hash: row.token_hash, - invited_by: UserId( - Uuid::parse_str(&row.invited_by) - .map_err(|e| StoreError::Backend(format!("invalid invited_by: {}", e)))?, - ), - expires_at: DateTime::parse_from_rfc3339(&row.expires_at) - .map_err(|e| StoreError::Backend(format!("invalid expires_at: {}", e)))? - .with_timezone(&Utc), - created_at: DateTime::parse_from_rfc3339(&row.created_at) - .map_err(|e| StoreError::Backend(format!("invalid created_at: {}", e)))? - .with_timezone(&Utc), - }) - } - - async fn get_organization_invite_by_token( - &self, - token_hash: &str, - ) -> Result { - let row = sqlx::query!( - r#"SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at - FROM organization_invites - WHERE token_hash = ?"#, - token_hash - ) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))? - .ok_or(StoreError::NotFound)?; - - let role = row - .role - .parse() - .map_err(|_| StoreError::Backend(format!("invalid role in database: {}", row.role)))?; - - Ok(zopp_storage::OrganizationInvite { - id: zopp_storage::OrganizationInviteId( - Uuid::parse_str(&row.id) - .map_err(|e| StoreError::Backend(format!("invalid invite id: {}", e)))?, - ), - organization_id: zopp_storage::OrganizationId( - Uuid::parse_str(&row.organization_id) - .map_err(|e| StoreError::Backend(format!("invalid organization_id: {}", e)))?, - ), - email: row.email, - role, - token_hash: row.token_hash, - invited_by: UserId( - Uuid::parse_str(&row.invited_by) - .map_err(|e| StoreError::Backend(format!("invalid invited_by: {}", e)))?, - ), - expires_at: DateTime::parse_from_rfc3339(&row.expires_at) - .map_err(|e| StoreError::Backend(format!("invalid expires_at: {}", e)))? - .with_timezone(&Utc), - created_at: DateTime::parse_from_rfc3339(&row.created_at) - .map_err(|e| StoreError::Backend(format!("invalid created_at: {}", e)))? - .with_timezone(&Utc), - }) - } - - async fn list_organization_invites( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result, StoreError> { - let org_id_str = org_id.0.to_string(); - let rows = sqlx::query!( - r#"SELECT id, organization_id, email, role, token_hash, invited_by, expires_at, created_at - FROM organization_invites - WHERE organization_id = ? - ORDER BY created_at DESC"#, - org_id_str - ) - .fetch_all(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - let mut invites = Vec::with_capacity(rows.len()); - for row in rows { - let role = row.role.parse().map_err(|_| { - StoreError::Backend(format!("invalid role in database: {}", row.role)) - })?; - invites.push(zopp_storage::OrganizationInvite { - id: zopp_storage::OrganizationInviteId( - Uuid::parse_str(&row.id) - .map_err(|e| StoreError::Backend(format!("invalid invite id: {}", e)))?, - ), - organization_id: zopp_storage::OrganizationId( - Uuid::parse_str(&row.organization_id).map_err(|e| { - StoreError::Backend(format!("invalid organization_id: {}", e)) - })?, - ), - email: row.email, - role, - token_hash: row.token_hash, - invited_by: UserId( - Uuid::parse_str(&row.invited_by) - .map_err(|e| StoreError::Backend(format!("invalid invited_by: {}", e)))?, - ), - expires_at: DateTime::parse_from_rfc3339(&row.expires_at) - .map_err(|e| StoreError::Backend(format!("invalid expires_at: {}", e)))? - .with_timezone(&Utc), - created_at: DateTime::parse_from_rfc3339(&row.created_at) - .map_err(|e| StoreError::Backend(format!("invalid created_at: {}", e)))? - .with_timezone(&Utc), - }); - } - Ok(invites) - } - - async fn delete_organization_invite( - &self, - invite_id: &zopp_storage::OrganizationInviteId, - ) -> Result<(), StoreError> { - let invite_id_str = invite_id.0.to_string(); - let result = sqlx::query!( - r#"DELETE FROM organization_invites WHERE id = ?"#, - invite_id_str - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn set_workspace_organization( - &self, - workspace_id: &WorkspaceId, - org_id: Option, - ) -> Result<(), StoreError> { - let ws_id_str = workspace_id.0.to_string(); - let org_id_str = org_id.map(|o| o.0.to_string()); - let result = sqlx::query!( - r#"UPDATE workspaces SET organization_id = ? WHERE id = ?"#, - org_id_str, - ws_id_str - ) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - async fn list_organization_workspaces( - &self, - org_id: &zopp_storage::OrganizationId, - ) -> Result, StoreError> { - let org_id_str = org_id.0.to_string(); - let rows = sqlx::query!( - r#"SELECT id, name, owner_user_id, kdf_salt, kdf_m_cost_kib, kdf_t_cost, kdf_p_cost, - created_at, updated_at - FROM workspaces - WHERE organization_id = ? - ORDER BY name"#, - org_id_str - ) - .fetch_all(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; - - let mut workspaces = Vec::with_capacity(rows.len()); - for row in rows { - workspaces.push(Workspace { - id: WorkspaceId( - Uuid::parse_str(&row.id) - .map_err(|e| StoreError::Backend(format!("invalid workspace id: {}", e)))?, - ), - name: row.name, - owner_user_id: UserId( - Uuid::parse_str(&row.owner_user_id).map_err(|e| { - StoreError::Backend(format!("invalid owner_user_id: {}", e)) - })?, - ), - kdf_salt: row.kdf_salt, - m_cost_kib: row.kdf_m_cost_kib as u32, - t_cost: row.kdf_t_cost as u32, - p_cost: row.kdf_p_cost as u32, - created_at: DateTime::parse_from_rfc3339(&row.created_at) - .map_err(|e| StoreError::Backend(format!("invalid created_at: {}", e)))? - .with_timezone(&Utc), - updated_at: DateTime::parse_from_rfc3339(&row.updated_at) - .map_err(|e| StoreError::Backend(format!("invalid updated_at: {}", e)))? - .with_timezone(&Utc), - }); - } - Ok(workspaces) - } } // ────────────────────────────────────── Audit Log ────────────────────────────────────── diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore deleted file mode 100644 index 78560fa8..00000000 --- a/infra/terraform/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# Terraform -.terraform/ -.terraform.lock.hcl -*.tfstate -*.tfstate.* -*.tfplan -crash.log -crash.*.log - -# Sensitive variable files -*.tfvars -!environments/*.tfvars - -# Backend configuration (may contain secrets) -*.backend.hcl - -# Override files -override.tf -override.tf.json -*_override.tf -*_override.tf.json - -# CLI configuration files -.terraformrc -terraform.rc - -# IDE -.idea/ -.vscode/ -*.swp -*.swo diff --git a/infra/terraform/README.md b/infra/terraform/README.md deleted file mode 100644 index 674ac02e..00000000 --- a/infra/terraform/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# Zopp Cloud Infrastructure - -Terraform configuration for deploying zopp as a SaaS offering on AWS. - -## Architecture - -- **VPC**: Multi-AZ VPC with public, private, and database subnets -- **EKS**: Managed Kubernetes cluster for running zopp workloads -- **RDS**: PostgreSQL database for persistent storage -- **ECR**: Container registry for zopp images -- **Secrets Manager**: Secure storage for database credentials -- **Route53**: DNS management (optional) -- **ACM**: SSL/TLS certificates (optional) - -## Prerequisites - -1. AWS CLI configured with appropriate credentials -2. Terraform >= 1.5 -3. kubectl (for EKS management) -4. Helm 3 (for deploying zopp chart) - -## Quick Start - -### 1. Initialize Terraform - -```bash -cd infra/terraform -terraform init -``` - -### 2. Deploy Staging Environment - -```bash -terraform plan -var-file=environments/staging.tfvars -terraform apply -var-file=environments/staging.tfvars -``` - -### 3. Configure kubectl - -```bash -aws eks update-kubeconfig --region us-east-1 --name zopp-staging -``` - -### 4. Deploy zopp - -```bash -helm upgrade --install zopp ../charts/zopp \ - --namespace zopp --create-namespace \ - --set server.database.type=postgres \ - --set server.database.existingSecret=zopp-db-credentials -``` - -## State Management - -For team collaboration, configure remote state in S3: - -```bash -# Create S3 bucket for state -aws s3 mb s3://zopp-terraform-state --region us-east-1 - -# Create DynamoDB table for locking -aws dynamodb create-table \ - --table-name zopp-terraform-lock \ - --attribute-definitions AttributeName=LockID,AttributeType=S \ - --key-schema AttributeName=LockID,KeyType=HASH \ - --billing-mode PAY_PER_REQUEST \ - --region us-east-1 - -# Initialize with backend -terraform init -backend-config="bucket=zopp-terraform-state" \ - -backend-config="key=staging/terraform.tfstate" \ - -backend-config="region=us-east-1" \ - -backend-config="encrypt=true" \ - -backend-config="dynamodb_table=zopp-terraform-lock" -``` - -## Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `environment` | staging or production | required | -| `aws_region` | AWS region | us-east-1 | -| `vpc_cidr` | VPC CIDR block | 10.0.0.0/16 | -| `eks_cluster_version` | Kubernetes version | 1.29 | -| `eks_node_instance_types` | Node instance types | ["t3.medium"] | -| `db_instance_class` | RDS instance class | db.t3.medium | -| `db_multi_az` | Enable Multi-AZ RDS | false | -| `create_dns` | Create Route53 zone | false | -| `domain_name` | Domain for the app | "" | - -## Outputs - -After applying, these outputs are available: - -- `eks_cluster_name`: Name of the EKS cluster -- `eks_kubeconfig_command`: Command to configure kubectl -- `rds_endpoint`: Database endpoint -- `rds_credentials_secret_arn`: ARN of Secrets Manager secret -- `ecr_*_repository_url`: ECR repository URLs -- `github_actions_role_arn`: IAM role for CI/CD - -## Costs - -Estimated monthly costs (staging): -- EKS: ~$75 (control plane + 2x t3.medium) -- RDS: ~$25 (db.t3.small, single-AZ) -- VPC NAT Gateway: ~$35 -- Total: ~$135/month - -Estimated monthly costs (production): -- EKS: ~$250 (control plane + 3x t3.large) -- RDS: ~$100 (db.t3.medium, Multi-AZ) -- VPC NAT Gateways: ~$100 (3x for HA) -- Total: ~$450/month - -## Security - -- All data is encrypted at rest (EBS, RDS, S3) -- Database credentials stored in Secrets Manager -- IRSA (IAM Roles for Service Accounts) for pod permissions -- Private subnets for workloads -- Security groups restrict traffic flow - -## Maintenance - -### Updating EKS Version - -1. Update `eks_cluster_version` in tfvars -2. Run `terraform apply` -3. EKS will perform a rolling update of the control plane -4. Update managed node groups via Terraform - -### Database Backups - -RDS automated backups are enabled with configurable retention. -To restore from a snapshot: - -```bash -terraform import module.rds.aws_db_instance.this -``` - -## Cleanup - -To destroy all resources: - -```bash -# Disable deletion protection first (for production) -terraform apply -var-file=environments/production.tfvars \ - -var="db_deletion_protection=false" - -# Then destroy -terraform destroy -var-file=environments/production.tfvars -``` - -**Warning**: This will delete all data. Take backups first! diff --git a/infra/terraform/ecr.tf b/infra/terraform/ecr.tf deleted file mode 100644 index 792abc75..00000000 --- a/infra/terraform/ecr.tf +++ /dev/null @@ -1,92 +0,0 @@ -# ECR Repositories for zopp container images - -resource "aws_ecr_repository" "server" { - name = "zopp/server" - image_tag_mutability = "MUTABLE" - - image_scanning_configuration { - scan_on_push = true - } - - encryption_configuration { - encryption_type = "AES256" - } - - tags = local.common_tags -} - -resource "aws_ecr_repository" "operator" { - name = "zopp/operator" - image_tag_mutability = "MUTABLE" - - image_scanning_configuration { - scan_on_push = true - } - - encryption_configuration { - encryption_type = "AES256" - } - - tags = local.common_tags -} - -resource "aws_ecr_repository" "web" { - name = "zopp/web" - image_tag_mutability = "MUTABLE" - - image_scanning_configuration { - scan_on_push = true - } - - encryption_configuration { - encryption_type = "AES256" - } - - tags = local.common_tags -} - -# Lifecycle policy to clean up old images -resource "aws_ecr_lifecycle_policy" "server" { - repository = aws_ecr_repository.server.name - - policy = jsonencode({ - rules = [ - { - rulePriority = 1 - description = "Keep last 30 images" - selection = { - tagStatus = "tagged" - tagPrefixList = ["v"] - countType = "imageCountMoreThan" - countNumber = 30 - } - action = { - type = "expire" - } - }, - { - rulePriority = 2 - description = "Expire untagged images older than 14 days" - selection = { - tagStatus = "untagged" - countType = "sinceImagePushed" - countUnit = "days" - countNumber = 14 - } - action = { - type = "expire" - } - } - ] - }) -} - -resource "aws_ecr_lifecycle_policy" "operator" { - repository = aws_ecr_repository.operator.name - policy = aws_ecr_lifecycle_policy.server.policy -} - -resource "aws_ecr_lifecycle_policy" "web" { - repository = aws_ecr_repository.web.name - policy = aws_ecr_lifecycle_policy.server.policy -} diff --git a/infra/terraform/eks.tf b/infra/terraform/eks.tf deleted file mode 100644 index da74bca6..00000000 --- a/infra/terraform/eks.tf +++ /dev/null @@ -1,177 +0,0 @@ -# EKS Cluster for zopp cloud -# Uses AWS EKS module for best practices - -module "eks" { - source = "terraform-aws-modules/eks/aws" - version = "~> 20.0" - - cluster_name = local.name_prefix - cluster_version = var.eks_cluster_version - - # Networking - vpc_id = module.vpc.vpc_id - subnet_ids = module.vpc.private_subnets - - # Cluster endpoint access - # Public access is enabled for CI/CD and remote management - # SECURITY: In production, restrict to specific CIDRs (VPN, CI runners, etc.) - cluster_endpoint_public_access = true - cluster_endpoint_private_access = true - cluster_endpoint_public_access_cidrs = var.eks_public_access_cidrs - - # IRSA (IAM Roles for Service Accounts) - enable_irsa = true - - # Cluster addons - cluster_addons = { - coredns = { - most_recent = true - } - kube-proxy = { - most_recent = true - } - vpc-cni = { - most_recent = true - before_compute = true - configuration_values = jsonencode({ - env = { - ENABLE_PREFIX_DELEGATION = "true" - WARM_PREFIX_TARGET = "1" - } - }) - } - aws-ebs-csi-driver = { - most_recent = true - service_account_role_arn = module.ebs_csi_irsa_role.iam_role_arn - } - } - - # Managed node groups - eks_managed_node_groups = { - general = { - name = "${local.name_prefix}-general" - - instance_types = var.eks_node_instance_types - capacity_type = "ON_DEMAND" - - min_size = var.eks_node_min_size - max_size = var.eks_node_max_size - desired_size = var.eks_node_desired_size - - # Use latest EKS optimized AMI - ami_type = "AL2_x86_64" - - # Node labels - labels = { - Environment = var.environment - } - - # Node taints (none by default) - taints = [] - - # Update config - update_config = { - max_unavailable_percentage = 33 - } - - # Disk configuration - block_device_mappings = { - xvda = { - device_name = "/dev/xvda" - ebs = { - volume_size = 50 - volume_type = "gp3" - encrypted = true - delete_on_termination = true - } - } - } - - tags = local.common_tags - } - } - - # Cluster security group rules - cluster_security_group_additional_rules = { - ingress_nodes_ephemeral_ports_tcp = { - description = "Nodes on ephemeral ports" - protocol = "tcp" - from_port = 1025 - to_port = 65535 - type = "ingress" - source_node_security_group = true - } - } - - # Node security group rules - node_security_group_additional_rules = { - ingress_self_all = { - description = "Node to node all ports/protocols" - protocol = "-1" - from_port = 0 - to_port = 0 - type = "ingress" - self = true - } - } - - # Access entries for cluster administration - enable_cluster_creator_admin_permissions = true - - tags = local.common_tags -} - -# EBS CSI Driver IAM Role -module "ebs_csi_irsa_role" { - source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks" - version = "~> 5.0" - - role_name = "${local.name_prefix}-ebs-csi" - attach_ebs_csi_policy = true - - oidc_providers = { - main = { - provider_arn = module.eks.oidc_provider_arn - namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"] - } - } - - tags = local.common_tags -} - -# AWS Load Balancer Controller IAM Role -module "load_balancer_controller_irsa_role" { - source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks" - version = "~> 5.0" - - role_name = "${local.name_prefix}-aws-load-balancer-controller" - attach_load_balancer_controller_policy = true - - oidc_providers = { - main = { - provider_arn = module.eks.oidc_provider_arn - namespace_service_accounts = ["kube-system:aws-load-balancer-controller"] - } - } - - tags = local.common_tags -} - -# External DNS IAM Role (for Route53 management) -module "external_dns_irsa_role" { - source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks" - version = "~> 5.0" - - role_name = "${local.name_prefix}-external-dns" - attach_external_dns_policy = true - external_dns_hosted_zone_arns = var.create_dns && var.domain_name != "" ? [aws_route53_zone.main[0].arn] : [] - - oidc_providers = { - main = { - provider_arn = module.eks.oidc_provider_arn - namespace_service_accounts = ["kube-system:external-dns"] - } - } - - tags = local.common_tags -} diff --git a/infra/terraform/environments/production.tfvars b/infra/terraform/environments/production.tfvars deleted file mode 100644 index 32beb636..00000000 --- a/infra/terraform/environments/production.tfvars +++ /dev/null @@ -1,34 +0,0 @@ -# Production environment configuration - -environment = "production" -aws_region = "us-east-1" - -# VPC -vpc_cidr = "10.1.0.0/16" - -# EKS - larger for production -eks_cluster_version = "1.29" -eks_node_instance_types = ["t3.large", "t3.xlarge"] -eks_node_min_size = 3 -eks_node_max_size = 10 -eks_node_desired_size = 3 -# SECURITY: MUST configure with your VPN/office/CI runner IP ranges before deployment -# Default uses RFC 5737 TEST-NET addresses (routed nowhere) as safe placeholder -# Replace with actual CIDRs, e.g.: ["10.0.0.0/8", "YOUR.VPN.IP/32"] -eks_public_access_cidrs = ["192.0.2.0/24"] # TEST-NET-1 - replace before deployment - -# RDS - production-grade -db_instance_class = "db.t3.medium" -db_allocated_storage = 50 -db_max_allocated_storage = 200 -db_multi_az = true -db_backup_retention_period = 30 -db_deletion_protection = true - -# DNS - configure your domain -create_dns = true -domain_name = "zopp.dev" - -# Monitoring -enable_monitoring = true -log_retention_days = 90 diff --git a/infra/terraform/environments/staging.tfvars b/infra/terraform/environments/staging.tfvars deleted file mode 100644 index e4cacc44..00000000 --- a/infra/terraform/environments/staging.tfvars +++ /dev/null @@ -1,34 +0,0 @@ -# Staging environment configuration - -environment = "staging" -aws_region = "us-east-1" - -# VPC -vpc_cidr = "10.0.0.0/16" - -# EKS - smaller for staging -eks_cluster_version = "1.29" -eks_node_instance_types = ["t3.medium"] -eks_node_min_size = 2 -eks_node_max_size = 4 -eks_node_desired_size = 2 -# SECURITY: MUST configure with your VPN/office/CI runner IP ranges before deployment -# Default uses RFC 5737 TEST-NET addresses (routed nowhere) as safe placeholder -# Replace with actual CIDRs, e.g.: ["10.0.0.0/8", "YOUR.VPN.IP/32"] -eks_public_access_cidrs = ["192.0.2.0/24"] # TEST-NET-1 - replace before deployment - -# RDS - smaller for staging -db_instance_class = "db.t3.small" -db_allocated_storage = 20 -db_max_allocated_storage = 50 -db_multi_az = false -db_backup_retention_period = 7 -db_deletion_protection = false - -# DNS - disabled for staging by default -create_dns = false -domain_name = "" - -# Monitoring -enable_monitoring = true -log_retention_days = 14 diff --git a/infra/terraform/iam.tf b/infra/terraform/iam.tf deleted file mode 100644 index f37ca84d..00000000 --- a/infra/terraform/iam.tf +++ /dev/null @@ -1,156 +0,0 @@ -# IAM Roles and Policies for zopp cloud applications - -# IAM Role for zopp-server (for accessing Secrets Manager, etc.) -module "zopp_server_irsa_role" { - source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks" - version = "~> 5.0" - - role_name = "${local.name_prefix}-server" - - oidc_providers = { - main = { - provider_arn = module.eks.oidc_provider_arn - namespace_service_accounts = ["zopp:zopp-server"] - } - } - - tags = local.common_tags -} - -# Policy for zopp-server to access Secrets Manager -resource "aws_iam_role_policy" "zopp_server_secrets" { - name = "${local.name_prefix}-server-secrets" - role = module.zopp_server_irsa_role.iam_role_name - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = [ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret" - ] - Resource = [ - aws_secretsmanager_secret.db_credentials.arn - ] - } - ] - }) -} - -# IAM Role for zopp-operator (for Kubernetes operations) -module "zopp_operator_irsa_role" { - source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks" - version = "~> 5.0" - - role_name = "${local.name_prefix}-operator" - - oidc_providers = { - main = { - provider_arn = module.eks.oidc_provider_arn - namespace_service_accounts = ["zopp:zopp-operator"] - } - } - - tags = local.common_tags -} - -# Policy for zopp-operator to manage secrets (if needed for external secrets) -resource "aws_iam_role_policy" "zopp_operator_secrets" { - name = "${local.name_prefix}-operator-secrets" - role = module.zopp_operator_irsa_role.iam_role_name - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = [ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret" - ] - Resource = [ - "arn:aws:secretsmanager:${var.aws_region}:${data.aws_caller_identity.current.account_id}:secret:${local.name_prefix}/*" - ] - } - ] - }) -} - -# CI/CD Role for GitHub Actions -# SECURITY: Restricted to trusted refs (main branch, release tags) to prevent -# untrusted branches from gaining deployment permissions -resource "aws_iam_role" "github_actions" { - name = "${local.name_prefix}-github-actions" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Principal = { - Federated = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/token.actions.githubusercontent.com" - } - Action = "sts:AssumeRoleWithWebIdentity" - Condition = { - StringEquals = { - "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" - } - # Only allow deployments from main branch and release tags - # This prevents untrusted feature branches from deploying - "ForAnyValue:StringLike" = { - "token.actions.githubusercontent.com:sub" = [ - "repo:faiscadev/zopp:ref:refs/heads/main", - "repo:faiscadev/zopp:ref:refs/tags/v*" - ] - } - } - } - ] - }) - - tags = local.common_tags -} - -# Policy for GitHub Actions to push to ECR and update EKS -resource "aws_iam_role_policy" "github_actions" { - name = "${local.name_prefix}-github-actions" - role = aws_iam_role.github_actions.name - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - # GetAuthorizationToken requires "*" resource - it's account-level - { - Effect = "Allow" - Action = ["ecr:GetAuthorizationToken"] - Resource = "*" - }, - # Scope image operations to zopp repositories only - { - Effect = "Allow" - Action = [ - "ecr:BatchCheckLayerAvailability", - "ecr:GetDownloadUrlForLayer", - "ecr:BatchGetImage", - "ecr:InitiateLayerUpload", - "ecr:UploadLayerPart", - "ecr:CompleteLayerUpload", - "ecr:PutImage" - ] - Resource = [ - "arn:aws:ecr:${var.aws_region}:${data.aws_caller_identity.current.account_id}:repository/${local.name_prefix}-*" - ] - }, - { - Effect = "Allow" - Action = [ - "eks:DescribeCluster", - "eks:ListClusters" - ] - Resource = module.eks.cluster_arn - } - ] - }) -} diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf deleted file mode 100644 index 4a6b25f7..00000000 --- a/infra/terraform/main.tf +++ /dev/null @@ -1,90 +0,0 @@ -# Zopp Cloud Infrastructure -# AWS deployment for zopp SaaS offering - -terraform { - required_version = ">= 1.5" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - kubernetes = { - source = "hashicorp/kubernetes" - version = "~> 2.25" - } - helm = { - source = "hashicorp/helm" - version = "~> 2.12" - } - random = { - source = "hashicorp/random" - version = "~> 3.6" - } - } - - backend "s3" { - # Configure via backend config file or CLI flags: - # terraform init -backend-config=environments/staging.backend.hcl - # bucket = "zopp-terraform-state" - # key = "staging/terraform.tfstate" - # region = "us-east-1" - # encrypt = true - # dynamodb_table = "zopp-terraform-lock" - } -} - -provider "aws" { - region = var.aws_region - - default_tags { - tags = { - Project = "zopp" - Environment = var.environment - ManagedBy = "terraform" - } - } -} - -# Configure Kubernetes provider after EKS is created -provider "kubernetes" { - host = module.eks.cluster_endpoint - cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data) - - exec { - api_version = "client.authentication.k8s.io/v1beta1" - command = "aws" - args = ["eks", "get-token", "--cluster-name", module.eks.cluster_name] - } -} - -provider "helm" { - kubernetes { - host = module.eks.cluster_endpoint - cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data) - - exec { - api_version = "client.authentication.k8s.io/v1beta1" - command = "aws" - args = ["eks", "get-token", "--cluster-name", module.eks.cluster_name] - } - } -} - -# Data sources -data "aws_availability_zones" "available" { - state = "available" -} - -data "aws_caller_identity" "current" {} - -# Local values -locals { - name_prefix = "zopp-${var.environment}" - azs = slice(data.aws_availability_zones.available.names, 0, 3) - - common_tags = { - Project = "zopp" - Environment = var.environment - } -} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf deleted file mode 100644 index 7d0b9be9..00000000 --- a/infra/terraform/outputs.tf +++ /dev/null @@ -1,139 +0,0 @@ -# Terraform outputs for zopp cloud infrastructure - -# VPC Outputs -output "vpc_id" { - description = "VPC ID" - value = module.vpc.vpc_id -} - -output "vpc_cidr" { - description = "VPC CIDR block" - value = module.vpc.vpc_cidr_block -} - -output "private_subnets" { - description = "Private subnet IDs" - value = module.vpc.private_subnets -} - -output "public_subnets" { - description = "Public subnet IDs" - value = module.vpc.public_subnets -} - -# EKS Outputs -output "eks_cluster_name" { - description = "EKS cluster name" - value = module.eks.cluster_name -} - -output "eks_cluster_endpoint" { - description = "EKS cluster API endpoint" - value = module.eks.cluster_endpoint -} - -output "eks_cluster_arn" { - description = "EKS cluster ARN" - value = module.eks.cluster_arn -} - -output "eks_oidc_provider_arn" { - description = "EKS OIDC provider ARN for IRSA" - value = module.eks.oidc_provider_arn -} - -output "eks_kubeconfig_command" { - description = "Command to update kubeconfig" - value = "aws eks update-kubeconfig --region ${var.aws_region} --name ${module.eks.cluster_name}" -} - -# RDS Outputs -output "rds_endpoint" { - description = "RDS instance endpoint" - value = module.rds.db_instance_endpoint -} - -output "rds_address" { - description = "RDS instance address" - value = module.rds.db_instance_address -} - -output "rds_port" { - description = "RDS instance port" - value = module.rds.db_instance_port -} - -output "rds_database_name" { - description = "RDS database name" - value = module.rds.db_instance_name -} - -output "rds_credentials_secret_arn" { - description = "ARN of Secrets Manager secret containing database credentials" - value = aws_secretsmanager_secret.db_credentials.arn -} - -# ECR Outputs -output "ecr_server_repository_url" { - description = "ECR repository URL for zopp-server" - value = aws_ecr_repository.server.repository_url -} - -output "ecr_operator_repository_url" { - description = "ECR repository URL for zopp-operator" - value = aws_ecr_repository.operator.repository_url -} - -output "ecr_web_repository_url" { - description = "ECR repository URL for zopp-web" - value = aws_ecr_repository.web.repository_url -} - -# IAM Outputs -output "zopp_server_role_arn" { - description = "IAM role ARN for zopp-server pod" - value = module.zopp_server_irsa_role.iam_role_arn -} - -output "zopp_operator_role_arn" { - description = "IAM role ARN for zopp-operator pod" - value = module.zopp_operator_irsa_role.iam_role_arn -} - -output "github_actions_role_arn" { - description = "IAM role ARN for GitHub Actions CI/CD" - value = aws_iam_role.github_actions.arn -} - -output "load_balancer_controller_role_arn" { - description = "IAM role ARN for AWS Load Balancer Controller" - value = module.load_balancer_controller_irsa_role.iam_role_arn -} - -output "external_dns_role_arn" { - description = "IAM role ARN for External DNS" - value = module.external_dns_irsa_role.iam_role_arn -} - -# DNS Outputs (conditional) -output "route53_zone_id" { - description = "Route53 hosted zone ID" - value = var.create_dns && var.domain_name != "" ? aws_route53_zone.main[0].zone_id : null -} - -output "route53_nameservers" { - description = "Route53 nameservers for the hosted zone" - value = var.create_dns && var.domain_name != "" ? aws_route53_zone.main[0].name_servers : null -} - -output "acm_certificate_arn" { - description = "ACM certificate ARN for HTTPS" - value = var.create_dns && var.domain_name != "" ? aws_acm_certificate.main[0].arn : null -} - -# Database URL (for local development reference) -output "database_url_template" { - description = "Template for DATABASE_URL (retrieve password from Secrets Manager)" - value = "postgres://zopp:@${module.rds.db_instance_address}:${module.rds.db_instance_port}/zopp" - sensitive = false -} diff --git a/infra/terraform/rds.tf b/infra/terraform/rds.tf deleted file mode 100644 index 36a69239..00000000 --- a/infra/terraform/rds.tf +++ /dev/null @@ -1,128 +0,0 @@ -# RDS PostgreSQL for zopp cloud -# Stores workspace data, user accounts, and audit logs - -# Generate random password for database -resource "random_password" "db_password" { - length = 32 - special = true - override_special = "!#$%&*()-_=+[]{}<>:?" -} - -# Store database credentials in Secrets Manager -resource "aws_secretsmanager_secret" "db_credentials" { - name = "${local.name_prefix}/db-credentials" - description = "Database credentials for zopp ${var.environment}" - recovery_window_in_days = var.environment == "production" ? 30 : 0 - - tags = local.common_tags -} - -resource "aws_secretsmanager_secret_version" "db_credentials" { - secret_id = aws_secretsmanager_secret.db_credentials.id - secret_string = jsonencode({ - username = "zopp" - password = random_password.db_password.result - engine = "postgres" - host = module.rds.db_instance_address - port = module.rds.db_instance_port - dbname = "zopp" - }) -} - -# RDS Security Group -resource "aws_security_group" "rds" { - name = "${local.name_prefix}-rds" - description = "Security group for RDS PostgreSQL" - vpc_id = module.vpc.vpc_id - - ingress { - description = "PostgreSQL from EKS nodes" - from_port = 5432 - to_port = 5432 - protocol = "tcp" - security_groups = [module.eks.node_security_group_id] - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-rds" - }) -} - -# RDS PostgreSQL Instance -module "rds" { - source = "terraform-aws-modules/rds/aws" - version = "~> 6.0" - - identifier = "${local.name_prefix}-postgres" - - # Engine configuration - engine = "postgres" - engine_version = "16.3" - family = "postgres16" - major_engine_version = "16" - instance_class = var.db_instance_class - - # Storage - allocated_storage = var.db_allocated_storage - max_allocated_storage = var.db_max_allocated_storage - storage_type = "gp3" - storage_encrypted = true - - # Database - db_name = "zopp" - username = "zopp" - password = random_password.db_password.result - port = 5432 - - # Network - db_subnet_group_name = module.vpc.database_subnet_group_name - vpc_security_group_ids = [aws_security_group.rds.id] - publicly_accessible = false - - # High availability - multi_az = var.db_multi_az - - # Backup - backup_retention_period = var.db_backup_retention_period - backup_window = "03:00-04:00" - maintenance_window = "Mon:04:00-Mon:05:00" - - # Protection - deletion_protection = var.db_deletion_protection - skip_final_snapshot = var.environment != "production" - final_snapshot_identifier_prefix = var.environment == "production" ? "${local.name_prefix}-final" : null - - # Performance Insights - performance_insights_enabled = var.enable_monitoring - performance_insights_retention_period = var.enable_monitoring ? 7 : 0 - - # Enhanced Monitoring - monitoring_interval = var.enable_monitoring ? 60 : 0 - monitoring_role_name = var.enable_monitoring ? "${local.name_prefix}-rds-monitoring" : null - create_monitoring_role = var.enable_monitoring - monitoring_role_use_name_prefix = false - - # CloudWatch Logs - enabled_cloudwatch_logs_exports = var.enable_monitoring ? ["postgresql", "upgrade"] : [] - - # Parameter group - parameters = [ - { - name = "log_statement" - value = "ddl" - }, - { - name = "log_min_duration_statement" - value = "1000" # Log queries taking more than 1 second - } - ] - - tags = local.common_tags -} diff --git a/infra/terraform/route53.tf b/infra/terraform/route53.tf deleted file mode 100644 index 205f70d9..00000000 --- a/infra/terraform/route53.tf +++ /dev/null @@ -1,53 +0,0 @@ -# Route53 DNS Configuration for zopp cloud -# Creates hosted zone and records for the application domain - -# Hosted zone (only created if domain is provided) -resource "aws_route53_zone" "main" { - count = var.create_dns && var.domain_name != "" ? 1 : 0 - - name = var.domain_name - comment = "Hosted zone for zopp ${var.environment}" - - tags = local.common_tags -} - -# ACM Certificate for HTTPS -resource "aws_acm_certificate" "main" { - count = var.create_dns && var.domain_name != "" ? 1 : 0 - - domain_name = var.domain_name - subject_alternative_names = ["*.${var.domain_name}"] - validation_method = "DNS" - - lifecycle { - create_before_destroy = true - } - - tags = local.common_tags -} - -# DNS validation records for ACM certificate -resource "aws_route53_record" "cert_validation" { - for_each = var.create_dns && var.domain_name != "" ? { - for dvo in aws_acm_certificate.main[0].domain_validation_options : dvo.domain_name => { - name = dvo.resource_record_name - record = dvo.resource_record_value - type = dvo.resource_record_type - } - } : {} - - allow_overwrite = true - name = each.value.name - records = [each.value.record] - ttl = 60 - type = each.value.type - zone_id = aws_route53_zone.main[0].zone_id -} - -# Certificate validation -resource "aws_acm_certificate_validation" "main" { - count = var.create_dns && var.domain_name != "" ? 1 : 0 - - certificate_arn = aws_acm_certificate.main[0].arn - validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn] -} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf deleted file mode 100644 index 3a9f43f2..00000000 --- a/infra/terraform/variables.tf +++ /dev/null @@ -1,124 +0,0 @@ -# Input variables for zopp cloud infrastructure - -variable "aws_region" { - description = "AWS region to deploy resources" - type = string - default = "us-east-1" -} - -variable "environment" { - description = "Environment name (staging, production)" - type = string - - validation { - condition = contains(["staging", "production"], var.environment) - error_message = "Environment must be 'staging' or 'production'." - } -} - -# VPC Configuration -variable "vpc_cidr" { - description = "CIDR block for VPC" - type = string - default = "10.0.0.0/16" -} - -# EKS Configuration -variable "eks_cluster_version" { - description = "Kubernetes version for EKS cluster" - type = string - default = "1.29" -} - -variable "eks_node_instance_types" { - description = "Instance types for EKS managed node groups" - type = list(string) - default = ["t3.medium"] -} - -variable "eks_node_min_size" { - description = "Minimum number of nodes in EKS node group" - type = number - default = 2 -} - -variable "eks_node_max_size" { - description = "Maximum number of nodes in EKS node group" - type = number - default = 5 -} - -variable "eks_node_desired_size" { - description = "Desired number of nodes in EKS node group" - type = number - default = 2 -} - -variable "eks_public_access_cidrs" { - description = "CIDR blocks allowed to access EKS cluster public endpoint. Must be explicitly configured - no default to prevent accidental public exposure." - type = list(string) - # No default - must be explicitly set to prevent accidental public exposure -} - -# RDS Configuration -variable "db_instance_class" { - description = "RDS instance class" - type = string - default = "db.t3.medium" -} - -variable "db_allocated_storage" { - description = "Allocated storage for RDS (GB)" - type = number - default = 20 -} - -variable "db_max_allocated_storage" { - description = "Maximum allocated storage for RDS autoscaling (GB)" - type = number - default = 100 -} - -variable "db_multi_az" { - description = "Enable Multi-AZ for RDS" - type = bool - default = false -} - -variable "db_backup_retention_period" { - description = "Number of days to retain RDS backups" - type = number - default = 7 -} - -variable "db_deletion_protection" { - description = "Enable deletion protection for RDS" - type = bool - default = true -} - -# Domain Configuration -variable "domain_name" { - description = "Domain name for the application (e.g., zopp.cloud)" - type = string - default = "" -} - -variable "create_dns" { - description = "Whether to create Route53 DNS records" - type = bool - default = false -} - -# Monitoring Configuration -variable "enable_monitoring" { - description = "Enable CloudWatch monitoring and alarms" - type = bool - default = true -} - -variable "log_retention_days" { - description = "Number of days to retain CloudWatch logs" - type = number - default = 30 -} diff --git a/infra/terraform/vpc.tf b/infra/terraform/vpc.tf deleted file mode 100644 index 45562435..00000000 --- a/infra/terraform/vpc.tf +++ /dev/null @@ -1,49 +0,0 @@ -# VPC Configuration for zopp cloud -# Uses AWS VPC module for best practices - -module "vpc" { - source = "terraform-aws-modules/vpc/aws" - version = "~> 5.0" - - name = "${local.name_prefix}-vpc" - cidr = var.vpc_cidr - - azs = local.azs - private_subnets = [for k, v in local.azs : cidrsubnet(var.vpc_cidr, 4, k)] - public_subnets = [for k, v in local.azs : cidrsubnet(var.vpc_cidr, 8, k + 48)] - intra_subnets = [for k, v in local.azs : cidrsubnet(var.vpc_cidr, 8, k + 52)] - - # Database subnets for RDS - database_subnets = [for k, v in local.azs : cidrsubnet(var.vpc_cidr, 8, k + 56)] - create_database_subnet_group = true - create_database_subnet_route_table = true - - # NAT Gateway for private subnet internet access - enable_nat_gateway = true - single_nat_gateway = var.environment == "staging" ? true : false - one_nat_gateway_per_az = var.environment == "production" ? true : false - - # DNS settings - enable_dns_hostnames = true - enable_dns_support = true - - # VPC Flow Logs - enable_flow_log = var.enable_monitoring - create_flow_log_cloudwatch_log_group = var.enable_monitoring - create_flow_log_cloudwatch_iam_role = var.enable_monitoring - flow_log_cloudwatch_log_group_retention_in_days = var.log_retention_days - flow_log_max_aggregation_interval = 60 - - # Tags required for EKS - public_subnet_tags = { - "kubernetes.io/role/elb" = 1 - "kubernetes.io/cluster/${local.name_prefix}" = "owned" - } - - private_subnet_tags = { - "kubernetes.io/role/internal-elb" = 1 - "kubernetes.io/cluster/${local.name_prefix}" = "owned" - } - - tags = local.common_tags -}