package projectindex import ( "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "strings" "time" ) // Project is a project index entry (design ยง3): the project marker that links // its chapters, together with the project's description. It is layer 1 (index // entry) and layer 2 (description) of the hierarchy. The zero value is not a // valid project: ID and Title are required. type Project struct { ID string Title string Desc string CreatedAt time.Time UpdatedAt time.Time } // PutProject upserts p by id. On an existing id it updates title, desc, and // updated_at while preserving the original created_at; on a new id it assigns // missing timestamps and inserts. A blank id or title returns ErrMissingProject // or ErrMissingTitle. func (s *Store) PutProject(ctx context.Context, p Project) error { if strings.TrimSpace(p.ID) == "" { return ErrMissingProject } if strings.TrimSpace(p.Title) == "" { return ErrMissingTitle } now := time.Now().UTC() if p.CreatedAt.IsZero() { p.CreatedAt = now } if p.UpdatedAt.IsZero() { p.UpdatedAt = now } _, err := s.db.ExecContext(ctx, `INSERT INTO project (id, title, desc, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, desc = excluded.desc, updated_at = excluded.updated_at`, p.ID, p.Title, p.Desc, formatTime(p.CreatedAt), formatTime(p.UpdatedAt)) if err != nil { return fmt.Errorf("projectindex: upsert project: %w", err) } s.log.Debug("project upserted", "id", p.ID) return nil } // GetProject returns the project with the given id. It returns ErrProjectNotFound // when the id is blank or does not exist. func (s *Store) GetProject(ctx context.Context, id string) (*Project, error) { if strings.TrimSpace(id) == "" { return nil, ErrProjectNotFound } row := s.db.QueryRowContext(ctx, `SELECT id, title, desc, created_at, updated_at FROM project WHERE id = ?`, id) p, err := scanProject(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrProjectNotFound } if err != nil { return nil, fmt.Errorf("projectindex: get project: %w", err) } return p, nil } // DeleteProject removes the project by id and, as a cascade, every chapter that // references it. It returns ErrProjectNotFound when the id is blank or no // project row was removed. The project removal and the chapter removal happen in // one transaction so a failure never leaves an orphaned project or chapter. func (s *Store) DeleteProject(ctx context.Context, id string) error { if strings.TrimSpace(id) == "" { return ErrProjectNotFound } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("projectindex: begin delete tx: %w", err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `DELETE FROM project_chapter WHERE project_id = ?`, id) if err != nil { return fmt.Errorf("projectindex: delete chapters: %w", err) } if _, err := res.RowsAffected(); err != nil { return fmt.Errorf("projectindex: delete chapters row count: %w", err) } res, err = tx.ExecContext(ctx, `DELETE FROM project WHERE id = ?`, id) if err != nil { return fmt.Errorf("projectindex: delete project: %w", err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("projectindex: delete row count: %w", err) } if n == 0 { return ErrProjectNotFound } if err := tx.Commit(); err != nil { return fmt.Errorf("projectindex: commit delete tx: %w", err) } s.log.Debug("project deleted", "id", id) return nil } // scanProject reads one project row. func scanProject(sc rowScanner) (*Project, error) { var ( p Project createdAt, updatedAt string ) if err := sc.Scan(&p.ID, &p.Title, &p.Desc, &createdAt, &updatedAt); err != nil { return nil, err } var err error if p.CreatedAt, err = parseTime(createdAt); err != nil { return nil, err } if p.UpdatedAt, err = parseTime(updatedAt); err != nil { return nil, err } return &p, nil } // newID returns a 128-bit cryptographically random id, hex-encoded. It avoids a // UUID dependency so the package stays self-contained. rand.Read fails only if // the platform entropy source is broken, so the error is propagated. func newID() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("projectindex: generate id: %w", err) } return hex.EncodeToString(b), nil } // formatTime encodes t in UTC as an RFC3339Nano string so ordering and // round-trips are stable across processes. func formatTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } // parseTime decodes a stored RFC3339 string; blank input yields a zero time. func parseTime(s string) (time.Time, error) { if strings.TrimSpace(s) == "" { return time.Time{}, nil } t, err := time.Parse(time.RFC3339Nano, s) if err != nil { return time.Time{}, fmt.Errorf("projectindex: parse time %q: %w", s, err) } return t, nil }