package db import ( "context" "time" "github.com/jackc/pgx/v5" ) // Project represents a project in the database. type Project struct { ID string Code string Name string Description string CreatedAt time.Time CreatedBy *string } // ProjectRepository provides project database operations. type ProjectRepository struct { db *DB } // NewProjectRepository creates a new project repository. func NewProjectRepository(db *DB) *ProjectRepository { return &ProjectRepository{db: db} } // List returns all projects. func (r *ProjectRepository) List(ctx context.Context) ([]*Project, error) { rows, err := r.db.pool.Query(ctx, ` SELECT id, code, name, description, created_at FROM projects ORDER BY code `) if err != nil { return nil, err } defer rows.Close() var projects []*Project for rows.Next() { p := &Project{} var name, desc *string if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil { return nil, err } if name != nil { p.Name = *name } if desc != nil { p.Description = *desc } projects = append(projects, p) } return projects, rows.Err() } // GetByCode returns a project by its code. func (r *ProjectRepository) GetByCode(ctx context.Context, code string) (*Project, error) { p := &Project{} var name, desc *string err := r.db.pool.QueryRow(ctx, ` SELECT id, code, name, description, created_at FROM projects WHERE code = $1 `, code).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } if name != nil { p.Name = *name } if desc != nil { p.Description = *desc } return p, nil } // GetByID returns a project by its ID. func (r *ProjectRepository) GetByID(ctx context.Context, id string) (*Project, error) { p := &Project{} var name, desc *string err := r.db.pool.QueryRow(ctx, ` SELECT id, code, name, description, created_at FROM projects WHERE id = $1 `, id).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } if name != nil { p.Name = *name } if desc != nil { p.Description = *desc } return p, nil } // Create inserts a new project. func (r *ProjectRepository) Create(ctx context.Context, p *Project) error { return r.db.pool.QueryRow(ctx, ` INSERT INTO projects (code, name, description, created_by) VALUES ($1, $2, $3, $4) RETURNING id, created_at `, p.Code, nullIfEmpty(p.Name), nullIfEmpty(p.Description), p.CreatedBy).Scan(&p.ID, &p.CreatedAt) } // Update updates a project's name and description. func (r *ProjectRepository) Update(ctx context.Context, code string, name, description string) error { _, err := r.db.pool.Exec(ctx, ` UPDATE projects SET name = $2, description = $3 WHERE code = $1 `, code, nullIfEmpty(name), nullIfEmpty(description)) return err } // Delete removes a project (will cascade to item_projects). func (r *ProjectRepository) Delete(ctx context.Context, code string) error { _, err := r.db.pool.Exec(ctx, `DELETE FROM projects WHERE code = $1`, code) return err } // AddItemToProject associates an item with a project. func (r *ProjectRepository) AddItemToProject(ctx context.Context, itemID, projectID string) error { _, err := r.db.pool.Exec(ctx, ` INSERT INTO item_projects (item_id, project_id) VALUES ($1, $2) ON CONFLICT (item_id, project_id) DO NOTHING `, itemID, projectID) return err } // AddItemToProjectByCode associates an item with a project by code. func (r *ProjectRepository) AddItemToProjectByCode(ctx context.Context, itemID, projectCode string) error { _, err := r.db.pool.Exec(ctx, ` INSERT INTO item_projects (item_id, project_id) SELECT $1, id FROM projects WHERE code = $2 ON CONFLICT (item_id, project_id) DO NOTHING `, itemID, projectCode) return err } // RemoveItemFromProject removes an item's association with a project. func (r *ProjectRepository) RemoveItemFromProject(ctx context.Context, itemID, projectID string) error { _, err := r.db.pool.Exec(ctx, ` DELETE FROM item_projects WHERE item_id = $1 AND project_id = $2 `, itemID, projectID) return err } // RemoveItemFromProjectByCode removes an item's association with a project by code. func (r *ProjectRepository) RemoveItemFromProjectByCode(ctx context.Context, itemID, projectCode string) error { _, err := r.db.pool.Exec(ctx, ` DELETE FROM item_projects WHERE item_id = $1 AND project_id = (SELECT id FROM projects WHERE code = $2) `, itemID, projectCode) return err } // GetProjectsForItem returns all projects associated with an item. func (r *ProjectRepository) GetProjectsForItem(ctx context.Context, itemID string) ([]*Project, error) { rows, err := r.db.pool.Query(ctx, ` SELECT p.id, p.code, p.name, p.description, p.created_at FROM projects p JOIN item_projects ip ON ip.project_id = p.id WHERE ip.item_id = $1 ORDER BY p.code `, itemID) if err != nil { return nil, err } defer rows.Close() var projects []*Project for rows.Next() { p := &Project{} var name, desc *string if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil { return nil, err } if name != nil { p.Name = *name } if desc != nil { p.Description = *desc } projects = append(projects, p) } return projects, rows.Err() } // GetProjectCodesForItem returns project codes for an item (convenience method). func (r *ProjectRepository) GetProjectCodesForItem(ctx context.Context, itemID string) ([]string, error) { rows, err := r.db.pool.Query(ctx, ` SELECT p.code FROM projects p JOIN item_projects ip ON ip.project_id = p.id WHERE ip.item_id = $1 ORDER BY p.code `, itemID) if err != nil { return nil, err } defer rows.Close() var codes []string for rows.Next() { var code string if err := rows.Scan(&code); err != nil { return nil, err } codes = append(codes, code) } return codes, rows.Err() } // GetItemsForProject returns all items associated with a project. func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID string) ([]*Item, error) { rows, err := r.db.pool.Query(ctx, ` SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description, i.created_at, i.updated_at, i.archived_at, i.current_revision, i.cad_synced_at, i.cad_file_path FROM items i JOIN item_projects ip ON ip.item_id = i.id WHERE ip.project_id = $1 AND i.archived_at IS NULL ORDER BY i.part_number `, projectID) if err != nil { return nil, err } defer rows.Close() var items []*Item for rows.Next() { item := &Item{} if err := rows.Scan( &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CADSyncedAt, &item.CADFilePath, ); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } // SetItemProjects replaces all project associations for an item. func (r *ProjectRepository) SetItemProjects(ctx context.Context, itemID string, projectCodes []string) error { return r.db.Tx(ctx, func(tx pgx.Tx) error { // Remove existing associations _, err := tx.Exec(ctx, `DELETE FROM item_projects WHERE item_id = $1`, itemID) if err != nil { return err } // Add new associations for _, code := range projectCodes { _, err := tx.Exec(ctx, ` INSERT INTO item_projects (item_id, project_id) SELECT $1, id FROM projects WHERE code = $2 ON CONFLICT (item_id, project_id) DO NOTHING `, itemID, code) if err != nil { return err } } return nil }) } // helper function func nullIfEmpty(s string) *string { if s == "" { return nil } return &s }