A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
758 lines
21 KiB
Go
758 lines
21 KiB
Go
package roadmap
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Store handles roadmap data persistence
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewStore creates a new roadmap store
|
|
func NewStore(pool *pgxpool.Pool) *Store {
|
|
return &Store{pool: pool}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Roadmap CRUD Operations
|
|
// ============================================================================
|
|
|
|
// CreateRoadmap creates a new roadmap
|
|
func (s *Store) CreateRoadmap(ctx context.Context, r *Roadmap) error {
|
|
r.ID = uuid.New()
|
|
r.CreatedAt = time.Now().UTC()
|
|
r.UpdatedAt = r.CreatedAt
|
|
if r.Status == "" {
|
|
r.Status = "draft"
|
|
}
|
|
if r.Version == "" {
|
|
r.Version = "1.0"
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO roadmaps (
|
|
id, tenant_id, namespace_id, title, description, version,
|
|
assessment_id, portfolio_id, status,
|
|
total_items, completed_items, progress,
|
|
start_date, target_date,
|
|
created_at, updated_at, created_by
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6,
|
|
$7, $8, $9,
|
|
$10, $11, $12,
|
|
$13, $14,
|
|
$15, $16, $17
|
|
)
|
|
`,
|
|
r.ID, r.TenantID, r.NamespaceID, r.Title, r.Description, r.Version,
|
|
r.AssessmentID, r.PortfolioID, r.Status,
|
|
r.TotalItems, r.CompletedItems, r.Progress,
|
|
r.StartDate, r.TargetDate,
|
|
r.CreatedAt, r.UpdatedAt, r.CreatedBy,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// GetRoadmap retrieves a roadmap by ID
|
|
func (s *Store) GetRoadmap(ctx context.Context, id uuid.UUID) (*Roadmap, error) {
|
|
var r Roadmap
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, tenant_id, namespace_id, title, description, version,
|
|
assessment_id, portfolio_id, status,
|
|
total_items, completed_items, progress,
|
|
start_date, target_date,
|
|
created_at, updated_at, created_by
|
|
FROM roadmaps WHERE id = $1
|
|
`, id).Scan(
|
|
&r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version,
|
|
&r.AssessmentID, &r.PortfolioID, &r.Status,
|
|
&r.TotalItems, &r.CompletedItems, &r.Progress,
|
|
&r.StartDate, &r.TargetDate,
|
|
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy,
|
|
)
|
|
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &r, nil
|
|
}
|
|
|
|
// ListRoadmaps lists roadmaps for a tenant with optional filters
|
|
func (s *Store) ListRoadmaps(ctx context.Context, tenantID uuid.UUID, filters *RoadmapFilters) ([]Roadmap, error) {
|
|
query := `
|
|
SELECT
|
|
id, tenant_id, namespace_id, title, description, version,
|
|
assessment_id, portfolio_id, status,
|
|
total_items, completed_items, progress,
|
|
start_date, target_date,
|
|
created_at, updated_at, created_by
|
|
FROM roadmaps WHERE tenant_id = $1`
|
|
|
|
args := []interface{}{tenantID}
|
|
argIdx := 2
|
|
|
|
if filters != nil {
|
|
if filters.Status != "" {
|
|
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
|
args = append(args, filters.Status)
|
|
argIdx++
|
|
}
|
|
if filters.AssessmentID != nil {
|
|
query += fmt.Sprintf(" AND assessment_id = $%d", argIdx)
|
|
args = append(args, *filters.AssessmentID)
|
|
argIdx++
|
|
}
|
|
if filters.PortfolioID != nil {
|
|
query += fmt.Sprintf(" AND portfolio_id = $%d", argIdx)
|
|
args = append(args, *filters.PortfolioID)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
query += " ORDER BY created_at DESC"
|
|
|
|
if filters != nil && filters.Limit > 0 {
|
|
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
|
args = append(args, filters.Limit)
|
|
argIdx++
|
|
|
|
if filters.Offset > 0 {
|
|
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
|
args = append(args, filters.Offset)
|
|
}
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var roadmaps []Roadmap
|
|
for rows.Next() {
|
|
var r Roadmap
|
|
err := rows.Scan(
|
|
&r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version,
|
|
&r.AssessmentID, &r.PortfolioID, &r.Status,
|
|
&r.TotalItems, &r.CompletedItems, &r.Progress,
|
|
&r.StartDate, &r.TargetDate,
|
|
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
roadmaps = append(roadmaps, r)
|
|
}
|
|
|
|
return roadmaps, nil
|
|
}
|
|
|
|
// UpdateRoadmap updates a roadmap
|
|
func (s *Store) UpdateRoadmap(ctx context.Context, r *Roadmap) error {
|
|
r.UpdatedAt = time.Now().UTC()
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE roadmaps SET
|
|
title = $2, description = $3, version = $4,
|
|
assessment_id = $5, portfolio_id = $6, status = $7,
|
|
total_items = $8, completed_items = $9, progress = $10,
|
|
start_date = $11, target_date = $12,
|
|
updated_at = $13
|
|
WHERE id = $1
|
|
`,
|
|
r.ID, r.Title, r.Description, r.Version,
|
|
r.AssessmentID, r.PortfolioID, r.Status,
|
|
r.TotalItems, r.CompletedItems, r.Progress,
|
|
r.StartDate, r.TargetDate,
|
|
r.UpdatedAt,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// DeleteRoadmap deletes a roadmap and its items
|
|
func (s *Store) DeleteRoadmap(ctx context.Context, id uuid.UUID) error {
|
|
// Delete items first
|
|
_, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE roadmap_id = $1", id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Delete roadmap
|
|
_, err = s.pool.Exec(ctx, "DELETE FROM roadmaps WHERE id = $1", id)
|
|
return err
|
|
}
|
|
|
|
// ============================================================================
|
|
// RoadmapItem CRUD Operations
|
|
// ============================================================================
|
|
|
|
// CreateItem creates a new roadmap item
|
|
func (s *Store) CreateItem(ctx context.Context, item *RoadmapItem) error {
|
|
item.ID = uuid.New()
|
|
item.CreatedAt = time.Now().UTC()
|
|
item.UpdatedAt = item.CreatedAt
|
|
if item.Status == "" {
|
|
item.Status = ItemStatusPlanned
|
|
}
|
|
if item.Priority == "" {
|
|
item.Priority = ItemPriorityMedium
|
|
}
|
|
if item.Category == "" {
|
|
item.Category = ItemCategoryTechnical
|
|
}
|
|
|
|
dependsOn, _ := json.Marshal(item.DependsOn)
|
|
blockedBy, _ := json.Marshal(item.BlockedBy)
|
|
evidenceReq, _ := json.Marshal(item.EvidenceRequired)
|
|
evidenceProv, _ := json.Marshal(item.EvidenceProvided)
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO roadmap_items (
|
|
id, roadmap_id, title, description, category, priority, status,
|
|
control_id, regulation_ref, gap_id,
|
|
effort_days, effort_hours, estimated_cost,
|
|
assignee_id, assignee_name, department,
|
|
planned_start, planned_end, actual_start, actual_end,
|
|
depends_on, blocked_by,
|
|
evidence_required, evidence_provided,
|
|
notes, risk_notes,
|
|
source_row, source_file, sort_order,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7,
|
|
$8, $9, $10,
|
|
$11, $12, $13,
|
|
$14, $15, $16,
|
|
$17, $18, $19, $20,
|
|
$21, $22,
|
|
$23, $24,
|
|
$25, $26,
|
|
$27, $28, $29,
|
|
$30, $31
|
|
)
|
|
`,
|
|
item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status),
|
|
item.ControlID, item.RegulationRef, item.GapID,
|
|
item.EffortDays, item.EffortHours, item.EstimatedCost,
|
|
item.AssigneeID, item.AssigneeName, item.Department,
|
|
item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd,
|
|
dependsOn, blockedBy,
|
|
evidenceReq, evidenceProv,
|
|
item.Notes, item.RiskNotes,
|
|
item.SourceRow, item.SourceFile, item.SortOrder,
|
|
item.CreatedAt, item.UpdatedAt,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// GetItem retrieves a roadmap item by ID
|
|
func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*RoadmapItem, error) {
|
|
var item RoadmapItem
|
|
var category, priority, status string
|
|
var dependsOn, blockedBy, evidenceReq, evidenceProv []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, roadmap_id, title, description, category, priority, status,
|
|
control_id, regulation_ref, gap_id,
|
|
effort_days, effort_hours, estimated_cost,
|
|
assignee_id, assignee_name, department,
|
|
planned_start, planned_end, actual_start, actual_end,
|
|
depends_on, blocked_by,
|
|
evidence_required, evidence_provided,
|
|
notes, risk_notes,
|
|
source_row, source_file, sort_order,
|
|
created_at, updated_at
|
|
FROM roadmap_items WHERE id = $1
|
|
`, id).Scan(
|
|
&item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status,
|
|
&item.ControlID, &item.RegulationRef, &item.GapID,
|
|
&item.EffortDays, &item.EffortHours, &item.EstimatedCost,
|
|
&item.AssigneeID, &item.AssigneeName, &item.Department,
|
|
&item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd,
|
|
&dependsOn, &blockedBy,
|
|
&evidenceReq, &evidenceProv,
|
|
&item.Notes, &item.RiskNotes,
|
|
&item.SourceRow, &item.SourceFile, &item.SortOrder,
|
|
&item.CreatedAt, &item.UpdatedAt,
|
|
)
|
|
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
item.Category = ItemCategory(category)
|
|
item.Priority = ItemPriority(priority)
|
|
item.Status = ItemStatus(status)
|
|
json.Unmarshal(dependsOn, &item.DependsOn)
|
|
json.Unmarshal(blockedBy, &item.BlockedBy)
|
|
json.Unmarshal(evidenceReq, &item.EvidenceRequired)
|
|
json.Unmarshal(evidenceProv, &item.EvidenceProvided)
|
|
|
|
return &item, nil
|
|
}
|
|
|
|
// ListItems lists items for a roadmap with optional filters
|
|
func (s *Store) ListItems(ctx context.Context, roadmapID uuid.UUID, filters *RoadmapItemFilters) ([]RoadmapItem, error) {
|
|
query := `
|
|
SELECT
|
|
id, roadmap_id, title, description, category, priority, status,
|
|
control_id, regulation_ref, gap_id,
|
|
effort_days, effort_hours, estimated_cost,
|
|
assignee_id, assignee_name, department,
|
|
planned_start, planned_end, actual_start, actual_end,
|
|
depends_on, blocked_by,
|
|
evidence_required, evidence_provided,
|
|
notes, risk_notes,
|
|
source_row, source_file, sort_order,
|
|
created_at, updated_at
|
|
FROM roadmap_items WHERE roadmap_id = $1`
|
|
|
|
args := []interface{}{roadmapID}
|
|
argIdx := 2
|
|
|
|
if filters != nil {
|
|
if filters.Status != "" {
|
|
query += fmt.Sprintf(" AND status = $%d", argIdx)
|
|
args = append(args, string(filters.Status))
|
|
argIdx++
|
|
}
|
|
if filters.Priority != "" {
|
|
query += fmt.Sprintf(" AND priority = $%d", argIdx)
|
|
args = append(args, string(filters.Priority))
|
|
argIdx++
|
|
}
|
|
if filters.Category != "" {
|
|
query += fmt.Sprintf(" AND category = $%d", argIdx)
|
|
args = append(args, string(filters.Category))
|
|
argIdx++
|
|
}
|
|
if filters.AssigneeID != nil {
|
|
query += fmt.Sprintf(" AND assignee_id = $%d", argIdx)
|
|
args = append(args, *filters.AssigneeID)
|
|
argIdx++
|
|
}
|
|
if filters.ControlID != "" {
|
|
query += fmt.Sprintf(" AND control_id = $%d", argIdx)
|
|
args = append(args, filters.ControlID)
|
|
argIdx++
|
|
}
|
|
if filters.SearchQuery != "" {
|
|
query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx)
|
|
args = append(args, "%"+filters.SearchQuery+"%")
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
query += " ORDER BY sort_order ASC, priority ASC, created_at ASC"
|
|
|
|
if filters != nil && filters.Limit > 0 {
|
|
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
|
args = append(args, filters.Limit)
|
|
argIdx++
|
|
|
|
if filters.Offset > 0 {
|
|
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
|
args = append(args, filters.Offset)
|
|
}
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var items []RoadmapItem
|
|
for rows.Next() {
|
|
var item RoadmapItem
|
|
var category, priority, status string
|
|
var dependsOn, blockedBy, evidenceReq, evidenceProv []byte
|
|
|
|
err := rows.Scan(
|
|
&item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status,
|
|
&item.ControlID, &item.RegulationRef, &item.GapID,
|
|
&item.EffortDays, &item.EffortHours, &item.EstimatedCost,
|
|
&item.AssigneeID, &item.AssigneeName, &item.Department,
|
|
&item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd,
|
|
&dependsOn, &blockedBy,
|
|
&evidenceReq, &evidenceProv,
|
|
&item.Notes, &item.RiskNotes,
|
|
&item.SourceRow, &item.SourceFile, &item.SortOrder,
|
|
&item.CreatedAt, &item.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
item.Category = ItemCategory(category)
|
|
item.Priority = ItemPriority(priority)
|
|
item.Status = ItemStatus(status)
|
|
json.Unmarshal(dependsOn, &item.DependsOn)
|
|
json.Unmarshal(blockedBy, &item.BlockedBy)
|
|
json.Unmarshal(evidenceReq, &item.EvidenceRequired)
|
|
json.Unmarshal(evidenceProv, &item.EvidenceProvided)
|
|
|
|
items = append(items, item)
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
// UpdateItem updates a roadmap item
|
|
func (s *Store) UpdateItem(ctx context.Context, item *RoadmapItem) error {
|
|
item.UpdatedAt = time.Now().UTC()
|
|
|
|
dependsOn, _ := json.Marshal(item.DependsOn)
|
|
blockedBy, _ := json.Marshal(item.BlockedBy)
|
|
evidenceReq, _ := json.Marshal(item.EvidenceRequired)
|
|
evidenceProv, _ := json.Marshal(item.EvidenceProvided)
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE roadmap_items SET
|
|
title = $2, description = $3, category = $4, priority = $5, status = $6,
|
|
control_id = $7, regulation_ref = $8, gap_id = $9,
|
|
effort_days = $10, effort_hours = $11, estimated_cost = $12,
|
|
assignee_id = $13, assignee_name = $14, department = $15,
|
|
planned_start = $16, planned_end = $17, actual_start = $18, actual_end = $19,
|
|
depends_on = $20, blocked_by = $21,
|
|
evidence_required = $22, evidence_provided = $23,
|
|
notes = $24, risk_notes = $25,
|
|
sort_order = $26, updated_at = $27
|
|
WHERE id = $1
|
|
`,
|
|
item.ID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status),
|
|
item.ControlID, item.RegulationRef, item.GapID,
|
|
item.EffortDays, item.EffortHours, item.EstimatedCost,
|
|
item.AssigneeID, item.AssigneeName, item.Department,
|
|
item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd,
|
|
dependsOn, blockedBy,
|
|
evidenceReq, evidenceProv,
|
|
item.Notes, item.RiskNotes,
|
|
item.SortOrder, item.UpdatedAt,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// DeleteItem deletes a roadmap item
|
|
func (s *Store) DeleteItem(ctx context.Context, id uuid.UUID) error {
|
|
_, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE id = $1", id)
|
|
return err
|
|
}
|
|
|
|
// BulkCreateItems creates multiple items in a transaction
|
|
func (s *Store) BulkCreateItems(ctx context.Context, items []RoadmapItem) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
for i := range items {
|
|
item := &items[i]
|
|
item.ID = uuid.New()
|
|
item.CreatedAt = time.Now().UTC()
|
|
item.UpdatedAt = item.CreatedAt
|
|
|
|
dependsOn, _ := json.Marshal(item.DependsOn)
|
|
blockedBy, _ := json.Marshal(item.BlockedBy)
|
|
evidenceReq, _ := json.Marshal(item.EvidenceRequired)
|
|
evidenceProv, _ := json.Marshal(item.EvidenceProvided)
|
|
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO roadmap_items (
|
|
id, roadmap_id, title, description, category, priority, status,
|
|
control_id, regulation_ref, gap_id,
|
|
effort_days, effort_hours, estimated_cost,
|
|
assignee_id, assignee_name, department,
|
|
planned_start, planned_end, actual_start, actual_end,
|
|
depends_on, blocked_by,
|
|
evidence_required, evidence_provided,
|
|
notes, risk_notes,
|
|
source_row, source_file, sort_order,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7,
|
|
$8, $9, $10,
|
|
$11, $12, $13,
|
|
$14, $15, $16,
|
|
$17, $18, $19, $20,
|
|
$21, $22,
|
|
$23, $24,
|
|
$25, $26,
|
|
$27, $28, $29,
|
|
$30, $31
|
|
)
|
|
`,
|
|
item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status),
|
|
item.ControlID, item.RegulationRef, item.GapID,
|
|
item.EffortDays, item.EffortHours, item.EstimatedCost,
|
|
item.AssigneeID, item.AssigneeName, item.Department,
|
|
item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd,
|
|
dependsOn, blockedBy,
|
|
evidenceReq, evidenceProv,
|
|
item.Notes, item.RiskNotes,
|
|
item.SourceRow, item.SourceFile, item.SortOrder,
|
|
item.CreatedAt, item.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Import Job Operations
|
|
// ============================================================================
|
|
|
|
// CreateImportJob creates a new import job
|
|
func (s *Store) CreateImportJob(ctx context.Context, job *ImportJob) error {
|
|
job.ID = uuid.New()
|
|
job.CreatedAt = time.Now().UTC()
|
|
job.UpdatedAt = job.CreatedAt
|
|
if job.Status == "" {
|
|
job.Status = "pending"
|
|
}
|
|
|
|
parsedItems, _ := json.Marshal(job.ParsedItems)
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO roadmap_import_jobs (
|
|
id, tenant_id, roadmap_id,
|
|
filename, format, file_size, content_type,
|
|
status, error_message,
|
|
total_rows, valid_rows, invalid_rows, imported_items,
|
|
parsed_items,
|
|
created_at, updated_at, completed_at, created_by
|
|
) VALUES (
|
|
$1, $2, $3,
|
|
$4, $5, $6, $7,
|
|
$8, $9,
|
|
$10, $11, $12, $13,
|
|
$14,
|
|
$15, $16, $17, $18
|
|
)
|
|
`,
|
|
job.ID, job.TenantID, job.RoadmapID,
|
|
job.Filename, string(job.Format), job.FileSize, job.ContentType,
|
|
job.Status, job.ErrorMessage,
|
|
job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems,
|
|
parsedItems,
|
|
job.CreatedAt, job.UpdatedAt, job.CompletedAt, job.CreatedBy,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// GetImportJob retrieves an import job by ID
|
|
func (s *Store) GetImportJob(ctx context.Context, id uuid.UUID) (*ImportJob, error) {
|
|
var job ImportJob
|
|
var format string
|
|
var parsedItems []byte
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, tenant_id, roadmap_id,
|
|
filename, format, file_size, content_type,
|
|
status, error_message,
|
|
total_rows, valid_rows, invalid_rows, imported_items,
|
|
parsed_items,
|
|
created_at, updated_at, completed_at, created_by
|
|
FROM roadmap_import_jobs WHERE id = $1
|
|
`, id).Scan(
|
|
&job.ID, &job.TenantID, &job.RoadmapID,
|
|
&job.Filename, &format, &job.FileSize, &job.ContentType,
|
|
&job.Status, &job.ErrorMessage,
|
|
&job.TotalRows, &job.ValidRows, &job.InvalidRows, &job.ImportedItems,
|
|
&parsedItems,
|
|
&job.CreatedAt, &job.UpdatedAt, &job.CompletedAt, &job.CreatedBy,
|
|
)
|
|
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
job.Format = ImportFormat(format)
|
|
json.Unmarshal(parsedItems, &job.ParsedItems)
|
|
|
|
return &job, nil
|
|
}
|
|
|
|
// UpdateImportJob updates an import job
|
|
func (s *Store) UpdateImportJob(ctx context.Context, job *ImportJob) error {
|
|
job.UpdatedAt = time.Now().UTC()
|
|
|
|
parsedItems, _ := json.Marshal(job.ParsedItems)
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE roadmap_import_jobs SET
|
|
roadmap_id = $2,
|
|
status = $3, error_message = $4,
|
|
total_rows = $5, valid_rows = $6, invalid_rows = $7, imported_items = $8,
|
|
parsed_items = $9,
|
|
updated_at = $10, completed_at = $11
|
|
WHERE id = $1
|
|
`,
|
|
job.ID, job.RoadmapID,
|
|
job.Status, job.ErrorMessage,
|
|
job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems,
|
|
parsedItems,
|
|
job.UpdatedAt, job.CompletedAt,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// ============================================================================
|
|
// Statistics
|
|
// ============================================================================
|
|
|
|
// GetRoadmapStats returns statistics for a roadmap
|
|
func (s *Store) GetRoadmapStats(ctx context.Context, roadmapID uuid.UUID) (*RoadmapStats, error) {
|
|
stats := &RoadmapStats{
|
|
ByStatus: make(map[string]int),
|
|
ByPriority: make(map[string]int),
|
|
ByCategory: make(map[string]int),
|
|
ByDepartment: make(map[string]int),
|
|
}
|
|
|
|
// Total count
|
|
s.pool.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1",
|
|
roadmapID).Scan(&stats.TotalItems)
|
|
|
|
// By status
|
|
rows, err := s.pool.Query(ctx,
|
|
"SELECT status, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY status",
|
|
roadmapID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var status string
|
|
var count int
|
|
rows.Scan(&status, &count)
|
|
stats.ByStatus[status] = count
|
|
}
|
|
}
|
|
|
|
// By priority
|
|
rows, err = s.pool.Query(ctx,
|
|
"SELECT priority, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY priority",
|
|
roadmapID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var priority string
|
|
var count int
|
|
rows.Scan(&priority, &count)
|
|
stats.ByPriority[priority] = count
|
|
}
|
|
}
|
|
|
|
// By category
|
|
rows, err = s.pool.Query(ctx,
|
|
"SELECT category, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY category",
|
|
roadmapID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var category string
|
|
var count int
|
|
rows.Scan(&category, &count)
|
|
stats.ByCategory[category] = count
|
|
}
|
|
}
|
|
|
|
// By department
|
|
rows, err = s.pool.Query(ctx,
|
|
"SELECT COALESCE(department, 'Unassigned'), COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY department",
|
|
roadmapID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var dept string
|
|
var count int
|
|
rows.Scan(&dept, &count)
|
|
stats.ByDepartment[dept] = count
|
|
}
|
|
}
|
|
|
|
// Overdue items
|
|
s.pool.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end < NOW() AND status NOT IN ('COMPLETED', 'DEFERRED')",
|
|
roadmapID).Scan(&stats.OverdueItems)
|
|
|
|
// Upcoming items (next 7 days)
|
|
s.pool.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end BETWEEN NOW() AND NOW() + INTERVAL '7 days' AND status NOT IN ('COMPLETED', 'DEFERRED')",
|
|
roadmapID).Scan(&stats.UpcomingItems)
|
|
|
|
// Total effort
|
|
s.pool.QueryRow(ctx,
|
|
"SELECT COALESCE(SUM(effort_days), 0) FROM roadmap_items WHERE roadmap_id = $1",
|
|
roadmapID).Scan(&stats.TotalEffortDays)
|
|
|
|
// Progress
|
|
completedCount := stats.ByStatus[string(ItemStatusCompleted)]
|
|
if stats.TotalItems > 0 {
|
|
stats.Progress = (completedCount * 100) / stats.TotalItems
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// UpdateRoadmapProgress recalculates and updates roadmap progress
|
|
func (s *Store) UpdateRoadmapProgress(ctx context.Context, roadmapID uuid.UUID) error {
|
|
var total, completed int
|
|
|
|
s.pool.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1",
|
|
roadmapID).Scan(&total)
|
|
|
|
s.pool.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND status = 'COMPLETED'",
|
|
roadmapID).Scan(&completed)
|
|
|
|
progress := 0
|
|
if total > 0 {
|
|
progress = (completed * 100) / total
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE roadmaps SET
|
|
total_items = $2,
|
|
completed_items = $3,
|
|
progress = $4,
|
|
updated_at = $5
|
|
WHERE id = $1
|
|
`, roadmapID, total, completed, progress, time.Now().UTC())
|
|
|
|
return err
|
|
}
|