package store import ( "database/sql" "errors" "fmt" "github.com/google/uuid" ) // projectCols is the canonical column list for projects queries. const projectCols = `id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, webhook_secret, created_at, updated_at` // CreateProject inserts a new project and returns it. A webhook secret is // generated automatically if one is not already set on the input. func (s *Store) CreateProject(p Project) (Project, error) { p.ID = uuid.New().String() p.CreatedAt = Now() p.UpdatedAt = p.CreatedAt if p.WebhookSecret == "" { p.WebhookSecret = uuid.New().String() } _, err := s.db.Exec( `INSERT INTO projects (`+projectCols+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.NpmAccessListID, p.WebhookSecret, p.CreatedAt, p.UpdatedAt, ) if err != nil { return Project{}, fmt.Errorf("insert project: %w", err) } return p, nil } // GetProjectByID returns a single project by its ID. func (s *Store) GetProjectByID(id string) (Project, error) { var p Project err := s.db.QueryRow( `SELECT `+projectCols+` FROM projects WHERE id = ?`, id, ).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound) } if err != nil { return Project{}, fmt.Errorf("query project: %w", err) } return p, nil } // GetProjectByWebhookSecret looks up a project by its webhook secret. // Returns ErrNotFound if no project has this secret (including empty). func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) { if secret == "" { return Project{}, ErrNotFound } var p Project err := s.db.QueryRow( `SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret, ).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return Project{}, ErrNotFound } if err != nil { return Project{}, fmt.Errorf("query project by webhook secret: %w", err) } return p, nil } // GetAllProjects returns every project ordered by name. func (s *Store) GetAllProjects() ([]Project, error) { rows, err := s.db.Query( `SELECT ` + projectCols + ` FROM projects ORDER BY name`, ) if err != nil { return nil, fmt.Errorf("query projects: %w", err) } defer rows.Close() projects := []Project{} for rows.Next() { var p Project if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } projects = append(projects, p) } return projects, rows.Err() } // GetProjectsByImage returns all projects using the given image, newest first. func (s *Store) GetProjectsByImage(image string) ([]Project, error) { rows, err := s.db.Query( `SELECT `+projectCols+` FROM projects WHERE image = ? ORDER BY created_at DESC`, image, ) if err != nil { return nil, fmt.Errorf("query projects by image: %w", err) } defer rows.Close() projects := []Project{} for rows.Next() { var p Project if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } projects = append(projects, p) } return projects, rows.Err() } // UpdateProject updates an existing project's mutable fields. Webhook secret // is intentionally not updated here — use SetProjectWebhookSecret instead. func (s *Store) UpdateProject(p Project) error { p.UpdatedAt = Now() result, err := s.db.Exec( `UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, npm_access_list_id=?, updated_at=? WHERE id=?`, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.NpmAccessListID, p.UpdatedAt, p.ID, ) if err != nil { return fmt.Errorf("update project: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("project %s: %w", p.ID, ErrNotFound) } return nil } // SetProjectWebhookSecret assigns a webhook secret to a project. // Pass an empty string to disable webhook access for the project. func (s *Store) SetProjectWebhookSecret(id, secret string) error { result, err := s.db.Exec( `UPDATE projects SET webhook_secret=?, updated_at=? WHERE id=?`, secret, Now(), id, ) if err != nil { return fmt.Errorf("set project webhook secret: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("project %s: %w", id, ErrNotFound) } return nil } // EnsureProjectWebhookSecret returns the current webhook secret for a project, // generating one on the fly if the stored value is empty (lazy backfill for // projects created before the per-project webhook migration). func (s *Store) EnsureProjectWebhookSecret(id string) (string, error) { project, err := s.GetProjectByID(id) if err != nil { return "", err } if project.WebhookSecret != "" { return project.WebhookSecret, nil } secret := uuid.New().String() if err := s.SetProjectWebhookSecret(id, secret); err != nil { return "", err } return secret, nil } // DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys. func (s *Store) DeleteProject(id string) error { result, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete project: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("project %s: %w", id, ErrNotFound) } return nil }