Vapora/crates/vapora-backend/src/services/project_service.rs
Jesús Pérez dd68d190ef ci: Update pre-commit hooks configuration
- Exclude problematic markdown files from linting (existing legacy issues)
- Make clippy check less aggressive (warnings only, not -D warnings)
- Move cargo test to manual stage (too slow for pre-commit)
- Exclude SVG files from end-of-file-fixer and trailing-whitespace
- Add markdown linting exclusions for existing documentation

This allows pre-commit hooks to run successfully on new code without
blocking commits due to existing issues in legacy documentation files.
2026-01-11 21:32:56 +00:00

208 lines
6.5 KiB
Rust

// Project service - CRUD operations for projects
use chrono::Utc;
use surrealdb::engine::remote::ws::Client;
use surrealdb::Surreal;
use vapora_shared::models::{Project, ProjectStatus};
use vapora_shared::{Result, VaporaError};
/// Service for managing projects
#[derive(Clone)]
pub struct ProjectService {
db: Surreal<Client>,
}
impl ProjectService {
/// Create a new ProjectService instance
pub fn new(db: Surreal<Client>) -> Self {
Self { db }
}
/// Create a new project
pub async fn create_project(&self, mut project: Project) -> Result<Project> {
// Set timestamps
let now = Utc::now();
project.created_at = now;
project.updated_at = now;
// Create project in database
let created: Option<Project> = self
.db
.create("projects")
.content(project)
.await?
.into_iter()
.next();
created.ok_or_else(|| VaporaError::DatabaseError("Failed to create project".to_string()))
}
/// List all projects for a tenant
pub async fn list_projects(&self, tenant_id: &str) -> Result<Vec<Project>> {
let mut response = self
.db
.query("SELECT * FROM projects WHERE tenant_id = $tenant_id ORDER BY created_at DESC")
.bind(("tenant_id", tenant_id.to_string()))
.await?;
let projects: Vec<Project> = response.take(0)?;
Ok(projects)
}
/// List projects by status for a tenant
pub async fn list_projects_by_status(
&self,
tenant_id: &str,
status: ProjectStatus,
) -> Result<Vec<Project>> {
let status_str = match status {
ProjectStatus::Active => "active",
ProjectStatus::Archived => "archived",
ProjectStatus::Completed => "completed",
};
let mut response = self
.db
.query("SELECT * FROM projects WHERE tenant_id = $tenant_id AND status = $status ORDER BY created_at DESC")
.bind(("tenant_id", tenant_id.to_string()))
.bind(("status", status_str.to_string()))
.await?;
let projects: Vec<Project> = response.take(0)?;
Ok(projects)
}
/// Get a project by ID
pub async fn get_project(&self, id: &str, tenant_id: &str) -> Result<Project> {
let project: Option<Project> = self.db.select(("projects", id)).await?;
let project = project
.ok_or_else(|| VaporaError::NotFound(format!("Project with id '{}' not found", id)))?;
// Verify tenant ownership
if project.tenant_id != tenant_id {
return Err(VaporaError::Unauthorized(
"Project does not belong to this tenant".to_string(),
));
}
Ok(project)
}
/// Update a project
pub async fn update_project(
&self,
id: &str,
tenant_id: &str,
mut updates: Project,
) -> Result<Project> {
// Verify project exists and belongs to tenant
let existing = self.get_project(id, tenant_id).await?;
// Preserve certain fields
updates.id = existing.id;
updates.tenant_id = existing.tenant_id;
updates.created_at = existing.created_at;
updates.updated_at = Utc::now();
// Update in database
let updated: Option<Project> = self.db.update(("projects", id)).content(updates).await?;
updated.ok_or_else(|| VaporaError::DatabaseError("Failed to update project".to_string()))
}
/// Delete a project
pub async fn delete_project(&self, id: &str, tenant_id: &str) -> Result<()> {
// Verify project exists and belongs to tenant
self.get_project(id, tenant_id).await?;
// Delete from database
let _: Option<Project> = self.db.delete(("projects", id)).await?;
Ok(())
}
/// Add a feature to a project
pub async fn add_feature(&self, id: &str, tenant_id: &str, feature: String) -> Result<Project> {
let mut project = self.get_project(id, tenant_id).await?;
// Add feature if not already present
if !project.features.contains(&feature) {
project.features.push(feature);
project.updated_at = Utc::now();
let updated: Option<Project> = self
.db
.update(("projects", id))
.merge(serde_json::json!({
"features": project.features,
"updated_at": project.updated_at
}))
.await?;
return updated
.ok_or_else(|| VaporaError::DatabaseError("Failed to add feature".to_string()));
}
Ok(project)
}
/// Remove a feature from a project
pub async fn remove_feature(
&self,
id: &str,
tenant_id: &str,
feature: &str,
) -> Result<Project> {
let mut project = self.get_project(id, tenant_id).await?;
// Remove feature
project.features.retain(|f| f != feature);
project.updated_at = Utc::now();
let updated: Option<Project> = self
.db
.update(("projects", id))
.merge(serde_json::json!({
"features": project.features,
"updated_at": project.updated_at
}))
.await?;
updated.ok_or_else(|| VaporaError::DatabaseError("Failed to remove feature".to_string()))
}
/// Archive a project (set status to archived)
pub async fn archive_project(&self, id: &str, tenant_id: &str) -> Result<Project> {
let mut project = self.get_project(id, tenant_id).await?;
project.status = ProjectStatus::Archived;
project.updated_at = Utc::now();
let updated: Option<Project> = self
.db
.update(("projects", id))
.merge(serde_json::json!({
"status": project.status,
"updated_at": project.updated_at
}))
.await?;
updated.ok_or_else(|| VaporaError::DatabaseError("Failed to archive project".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use vapora_shared::models::ProjectStatus;
// Note: These are placeholder tests. Real tests require a running SurrealDB instance
// or mocking. For Phase 1, we'll add integration tests that use a test database.
#[test]
fn test_project_service_creation() {
// This test just verifies the service can be created
// Real database tests will be in integration tests
}
}