169 lines
5.6 KiB
Go
169 lines
5.6 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/kandev/kandev/internal/office/models"
|
|
)
|
|
|
|
// CreateProject creates a new project.
|
|
func (r *Repository) CreateProject(ctx context.Context, project *models.Project) error {
|
|
if project.ID == "" {
|
|
project.ID = uuid.New().String()
|
|
}
|
|
now := time.Now().UTC()
|
|
project.CreatedAt = now
|
|
project.UpdatedAt = now
|
|
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO office_projects (
|
|
id, workspace_id, name, description, status, lead_agent_profile_id,
|
|
color, budget_cents, repositories, executor_config, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), project.ID, project.WorkspaceID, project.Name, project.Description,
|
|
project.Status, project.LeadAgentProfileID, project.Color, project.BudgetCents,
|
|
project.Repositories, project.ExecutorConfig, project.CreatedAt, project.UpdatedAt)
|
|
return err
|
|
}
|
|
|
|
// GetProject returns a project by ID.
|
|
func (r *Repository) GetProject(ctx context.Context, id string) (*models.Project, error) {
|
|
var project models.Project
|
|
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(
|
|
`SELECT * FROM office_projects WHERE id = ?`), id).StructScan(&project)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("project not found: %s", id)
|
|
}
|
|
return &project, err
|
|
}
|
|
|
|
// GetProjectByName returns a project by workspace+name.
|
|
func (r *Repository) GetProjectByName(
|
|
ctx context.Context, workspaceID, name string,
|
|
) (*models.Project, error) {
|
|
var project models.Project
|
|
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(
|
|
`SELECT * FROM office_projects WHERE workspace_id = ? AND name = ?`),
|
|
workspaceID, name).StructScan(&project)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("project not found: %s", name)
|
|
}
|
|
return &project, err
|
|
}
|
|
|
|
// ListProjects returns all projects for a workspace.
|
|
// An empty workspaceID returns rows from all workspaces.
|
|
func (r *Repository) ListProjects(ctx context.Context, workspaceID string) ([]*models.Project, error) {
|
|
var (
|
|
projects []*models.Project
|
|
err error
|
|
)
|
|
if workspaceID == "" {
|
|
err = r.ro.SelectContext(ctx, &projects,
|
|
`SELECT * FROM office_projects ORDER BY name`)
|
|
} else {
|
|
err = r.ro.SelectContext(ctx, &projects, r.ro.Rebind(
|
|
`SELECT * FROM office_projects WHERE workspace_id = ? ORDER BY name`), workspaceID)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if projects == nil {
|
|
projects = []*models.Project{}
|
|
}
|
|
return projects, nil
|
|
}
|
|
|
|
// UpdateProject updates an existing project.
|
|
func (r *Repository) UpdateProject(ctx context.Context, project *models.Project) error {
|
|
project.UpdatedAt = time.Now().UTC()
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE office_projects SET
|
|
name = ?, description = ?, status = ?, lead_agent_profile_id = ?,
|
|
color = ?, budget_cents = ?, repositories = ?, executor_config = ?,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
`), project.Name, project.Description, project.Status, project.LeadAgentProfileID,
|
|
project.Color, project.BudgetCents, project.Repositories, project.ExecutorConfig,
|
|
project.UpdatedAt, project.ID)
|
|
return err
|
|
}
|
|
|
|
// DeleteProject deletes a project by ID.
|
|
func (r *Repository) DeleteProject(ctx context.Context, id string) error {
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(
|
|
`DELETE FROM office_projects WHERE id = ?`), id)
|
|
return err
|
|
}
|
|
|
|
// projectCountsRow holds the per-project task aggregate from the batched
|
|
// LEFT JOIN query in ListProjectsWithCounts.
|
|
type projectCountsRow struct {
|
|
ProjectID string `db:"project_id"`
|
|
Total int `db:"total"`
|
|
InProgress int `db:"in_progress"`
|
|
Done int `db:"done"`
|
|
Blocked int `db:"blocked"`
|
|
}
|
|
|
|
// ListProjectsWithCounts returns all projects for a workspace with task
|
|
// counts. Counts are fetched in a single batched query (one round-trip
|
|
// regardless of project count) — was N+1.
|
|
func (r *Repository) ListProjectsWithCounts(
|
|
ctx context.Context, workspaceID string,
|
|
) ([]*models.ProjectWithCounts, error) {
|
|
projects, err := r.ListProjects(ctx, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(projects) == 0 {
|
|
return []*models.ProjectWithCounts{}, nil
|
|
}
|
|
|
|
// Batch task counts grouped by project_id. The LEFT JOIN ensures
|
|
// projects with zero tasks still appear in the result with all
|
|
// counts at 0.
|
|
placeholders := make([]string, len(projects))
|
|
args := make([]interface{}, 0, len(projects))
|
|
for i, p := range projects {
|
|
placeholders[i] = "?"
|
|
args = append(args, p.ID)
|
|
}
|
|
query := `SELECT p.id AS project_id,
|
|
COUNT(t.id) AS total,
|
|
COALESCE(SUM(CASE WHEN t.state IN ('IN_PROGRESS', 'SCHEDULING') THEN 1 ELSE 0 END), 0) AS in_progress,
|
|
COALESCE(SUM(CASE WHEN t.state = 'COMPLETED' THEN 1 ELSE 0 END), 0) AS done,
|
|
COALESCE(SUM(CASE WHEN t.state = 'BLOCKED' THEN 1 ELSE 0 END), 0) AS blocked
|
|
FROM office_projects p
|
|
LEFT JOIN tasks t ON t.project_id = p.id
|
|
WHERE p.id IN (` + strings.Join(placeholders, ",") + `)
|
|
GROUP BY p.id`
|
|
var rows []projectCountsRow
|
|
countsByProject := make(map[string]models.TaskCounts, len(projects))
|
|
if err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(query), args...); err == nil {
|
|
for _, row := range rows {
|
|
countsByProject[row.ProjectID] = models.TaskCounts{
|
|
Total: row.Total,
|
|
InProgress: row.InProgress,
|
|
Done: row.Done,
|
|
Blocked: row.Blocked,
|
|
}
|
|
}
|
|
}
|
|
|
|
result := make([]*models.ProjectWithCounts, 0, len(projects))
|
|
for _, p := range projects {
|
|
result = append(result, &models.ProjectWithCounts{
|
|
Project: *p,
|
|
TaskCounts: countsByProject[p.ID],
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|