package funding import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // PostgresStore implements Store using PostgreSQL type PostgresStore struct { pool *pgxpool.Pool } // NewPostgresStore creates a new PostgreSQL store func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { return &PostgresStore{pool: pool} } // CreateApplication creates a new funding application func (s *PostgresStore) CreateApplication(ctx context.Context, app *FundingApplication) error { app.ID = uuid.New() app.CreatedAt = time.Now() app.UpdatedAt = time.Now() app.TotalSteps = 8 // Default 8-step wizard // Generate application number app.ApplicationNumber = s.generateApplicationNumber(app.FundingProgram, app.SchoolProfile) // Marshal JSON fields wizardDataJSON, err := json.Marshal(app.WizardData) if err != nil { return fmt.Errorf("failed to marshal wizard data: %w", err) } schoolProfileJSON, err := json.Marshal(app.SchoolProfile) if err != nil { return fmt.Errorf("failed to marshal school profile: %w", err) } projectPlanJSON, err := json.Marshal(app.ProjectPlan) if err != nil { return fmt.Errorf("failed to marshal project plan: %w", err) } budgetJSON, err := json.Marshal(app.Budget) if err != nil { return fmt.Errorf("failed to marshal budget: %w", err) } timelineJSON, err := json.Marshal(app.Timeline) if err != nil { return fmt.Errorf("failed to marshal timeline: %w", err) } query := ` INSERT INTO funding_applications ( id, tenant_id, application_number, title, funding_program, status, current_step, total_steps, wizard_data, school_profile, project_plan, budget, timeline, requested_amount, own_contribution, created_at, updated_at, created_by, updated_by ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19 ) ` _, err = s.pool.Exec(ctx, query, app.ID, app.TenantID, app.ApplicationNumber, app.Title, app.FundingProgram, app.Status, app.CurrentStep, app.TotalSteps, wizardDataJSON, schoolProfileJSON, projectPlanJSON, budgetJSON, timelineJSON, app.RequestedAmount, app.OwnContribution, app.CreatedAt, app.UpdatedAt, app.CreatedBy, app.UpdatedBy, ) if err != nil { return fmt.Errorf("failed to create application: %w", err) } return nil } // GetApplication retrieves an application by ID func (s *PostgresStore) GetApplication(ctx context.Context, id uuid.UUID) (*FundingApplication, error) { query := ` SELECT id, tenant_id, application_number, title, funding_program, status, current_step, total_steps, wizard_data, school_profile, project_plan, budget, timeline, requested_amount, own_contribution, approved_amount, created_at, updated_at, submitted_at, created_by, updated_by FROM funding_applications WHERE id = $1 ` var app FundingApplication var wizardDataJSON, schoolProfileJSON, projectPlanJSON, budgetJSON, timelineJSON []byte err := s.pool.QueryRow(ctx, query, id).Scan( &app.ID, &app.TenantID, &app.ApplicationNumber, &app.Title, &app.FundingProgram, &app.Status, &app.CurrentStep, &app.TotalSteps, &wizardDataJSON, &schoolProfileJSON, &projectPlanJSON, &budgetJSON, &timelineJSON, &app.RequestedAmount, &app.OwnContribution, &app.ApprovedAmount, &app.CreatedAt, &app.UpdatedAt, &app.SubmittedAt, &app.CreatedBy, &app.UpdatedBy, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, fmt.Errorf("application not found: %s", id) } return nil, fmt.Errorf("failed to get application: %w", err) } // Unmarshal JSON fields if len(wizardDataJSON) > 0 { if err := json.Unmarshal(wizardDataJSON, &app.WizardData); err != nil { return nil, fmt.Errorf("failed to unmarshal wizard data: %w", err) } } if len(schoolProfileJSON) > 0 { app.SchoolProfile = &SchoolProfile{} if err := json.Unmarshal(schoolProfileJSON, app.SchoolProfile); err != nil { return nil, fmt.Errorf("failed to unmarshal school profile: %w", err) } } if len(projectPlanJSON) > 0 { app.ProjectPlan = &ProjectPlan{} if err := json.Unmarshal(projectPlanJSON, app.ProjectPlan); err != nil { return nil, fmt.Errorf("failed to unmarshal project plan: %w", err) } } if len(budgetJSON) > 0 { app.Budget = &Budget{} if err := json.Unmarshal(budgetJSON, app.Budget); err != nil { return nil, fmt.Errorf("failed to unmarshal budget: %w", err) } } if len(timelineJSON) > 0 { app.Timeline = &ProjectTimeline{} if err := json.Unmarshal(timelineJSON, app.Timeline); err != nil { return nil, fmt.Errorf("failed to unmarshal timeline: %w", err) } } // Load attachments attachments, err := s.GetAttachments(ctx, id) if err == nil { app.Attachments = attachments } return &app, nil } // GetApplicationByNumber retrieves an application by number func (s *PostgresStore) GetApplicationByNumber(ctx context.Context, number string) (*FundingApplication, error) { query := `SELECT id FROM funding_applications WHERE application_number = $1` var id uuid.UUID err := s.pool.QueryRow(ctx, query, number).Scan(&id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, fmt.Errorf("application not found: %s", number) } return nil, fmt.Errorf("failed to find application by number: %w", err) } return s.GetApplication(ctx, id) } // UpdateApplication updates an existing application func (s *PostgresStore) UpdateApplication(ctx context.Context, app *FundingApplication) error { app.UpdatedAt = time.Now() // Marshal JSON fields wizardDataJSON, _ := json.Marshal(app.WizardData) schoolProfileJSON, _ := json.Marshal(app.SchoolProfile) projectPlanJSON, _ := json.Marshal(app.ProjectPlan) budgetJSON, _ := json.Marshal(app.Budget) timelineJSON, _ := json.Marshal(app.Timeline) query := ` UPDATE funding_applications SET title = $2, funding_program = $3, status = $4, current_step = $5, wizard_data = $6, school_profile = $7, project_plan = $8, budget = $9, timeline = $10, requested_amount = $11, own_contribution = $12, approved_amount = $13, updated_at = $14, submitted_at = $15, updated_by = $16 WHERE id = $1 ` result, err := s.pool.Exec(ctx, query, app.ID, app.Title, app.FundingProgram, app.Status, app.CurrentStep, wizardDataJSON, schoolProfileJSON, projectPlanJSON, budgetJSON, timelineJSON, app.RequestedAmount, app.OwnContribution, app.ApprovedAmount, app.UpdatedAt, app.SubmittedAt, app.UpdatedBy, ) if err != nil { return fmt.Errorf("failed to update application: %w", err) } if result.RowsAffected() == 0 { return fmt.Errorf("application not found: %s", app.ID) } return nil } // DeleteApplication soft-deletes an application func (s *PostgresStore) DeleteApplication(ctx context.Context, id uuid.UUID) error { query := `UPDATE funding_applications SET status = 'ARCHIVED', updated_at = $2 WHERE id = $1` result, err := s.pool.Exec(ctx, query, id, time.Now()) if err != nil { return fmt.Errorf("failed to delete application: %w", err) } if result.RowsAffected() == 0 { return fmt.Errorf("application not found: %s", id) } return nil } // ListApplications returns a paginated list of applications func (s *PostgresStore) ListApplications(ctx context.Context, tenantID uuid.UUID, filter ApplicationFilter) (*ApplicationListResponse, error) { // Build query with filters query := ` SELECT id, tenant_id, application_number, title, funding_program, status, current_step, total_steps, wizard_data, school_profile, project_plan, budget, timeline, requested_amount, own_contribution, approved_amount, created_at, updated_at, submitted_at, created_by, updated_by FROM funding_applications WHERE tenant_id = $1 AND status != 'ARCHIVED' ` args := []interface{}{tenantID} argIndex := 2 if filter.Status != nil { query += fmt.Sprintf(" AND status = $%d", argIndex) args = append(args, *filter.Status) argIndex++ } if filter.FundingProgram != nil { query += fmt.Sprintf(" AND funding_program = $%d", argIndex) args = append(args, *filter.FundingProgram) argIndex++ } // Count total countQuery := `SELECT COUNT(*) FROM funding_applications WHERE tenant_id = $1 AND status != 'ARCHIVED'` var total int s.pool.QueryRow(ctx, countQuery, tenantID).Scan(&total) // Add sorting and pagination sortBy := "created_at" if filter.SortBy != "" { sortBy = filter.SortBy } sortOrder := "DESC" if filter.SortOrder == "asc" { sortOrder = "ASC" } query += fmt.Sprintf(" ORDER BY %s %s", sortBy, sortOrder) if filter.PageSize <= 0 { filter.PageSize = 20 } if filter.Page <= 0 { filter.Page = 1 } offset := (filter.Page - 1) * filter.PageSize query += fmt.Sprintf(" LIMIT %d OFFSET %d", filter.PageSize, offset) rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("failed to list applications: %w", err) } defer rows.Close() var apps []FundingApplication for rows.Next() { var app FundingApplication var wizardDataJSON, schoolProfileJSON, projectPlanJSON, budgetJSON, timelineJSON []byte err := rows.Scan( &app.ID, &app.TenantID, &app.ApplicationNumber, &app.Title, &app.FundingProgram, &app.Status, &app.CurrentStep, &app.TotalSteps, &wizardDataJSON, &schoolProfileJSON, &projectPlanJSON, &budgetJSON, &timelineJSON, &app.RequestedAmount, &app.OwnContribution, &app.ApprovedAmount, &app.CreatedAt, &app.UpdatedAt, &app.SubmittedAt, &app.CreatedBy, &app.UpdatedBy, ) if err != nil { return nil, fmt.Errorf("failed to scan application: %w", err) } // Unmarshal JSON fields if len(schoolProfileJSON) > 0 { app.SchoolProfile = &SchoolProfile{} json.Unmarshal(schoolProfileJSON, app.SchoolProfile) } apps = append(apps, app) } return &ApplicationListResponse{ Applications: apps, Total: total, Page: filter.Page, PageSize: filter.PageSize, }, nil } // SearchApplications searches applications by text func (s *PostgresStore) SearchApplications(ctx context.Context, tenantID uuid.UUID, query string) ([]FundingApplication, error) { searchQuery := ` SELECT id FROM funding_applications WHERE tenant_id = $1 AND status != 'ARCHIVED' AND ( title ILIKE $2 OR application_number ILIKE $2 OR school_profile::text ILIKE $2 ) ORDER BY updated_at DESC LIMIT 50 ` rows, err := s.pool.Query(ctx, searchQuery, tenantID, "%"+query+"%") if err != nil { return nil, fmt.Errorf("failed to search applications: %w", err) } defer rows.Close() var apps []FundingApplication for rows.Next() { var id uuid.UUID if err := rows.Scan(&id); err != nil { continue } app, err := s.GetApplication(ctx, id) if err == nil { apps = append(apps, *app) } } return apps, nil } // SaveWizardStep saves data for a wizard step func (s *PostgresStore) SaveWizardStep(ctx context.Context, appID uuid.UUID, step int, data map[string]interface{}) error { // Get current wizard data app, err := s.GetApplication(ctx, appID) if err != nil { return err } // Initialize wizard data if nil if app.WizardData == nil { app.WizardData = make(map[string]interface{}) } // Merge step data stepKey := fmt.Sprintf("step_%d", step) app.WizardData[stepKey] = data app.CurrentStep = step // Update application return s.UpdateApplication(ctx, app) } // GetWizardProgress returns the wizard progress func (s *PostgresStore) GetWizardProgress(ctx context.Context, appID uuid.UUID) (*WizardProgress, error) { app, err := s.GetApplication(ctx, appID) if err != nil { return nil, err } progress := &WizardProgress{ CurrentStep: app.CurrentStep, TotalSteps: app.TotalSteps, CompletedSteps: []int{}, FormData: app.WizardData, LastSavedAt: app.UpdatedAt, } // Determine completed steps from wizard data for i := 1; i <= app.TotalSteps; i++ { stepKey := fmt.Sprintf("step_%d", i) if _, ok := app.WizardData[stepKey]; ok { progress.CompletedSteps = append(progress.CompletedSteps, i) } } return progress, nil } // AddAttachment adds an attachment to an application func (s *PostgresStore) AddAttachment(ctx context.Context, appID uuid.UUID, attachment *Attachment) error { attachment.ID = uuid.New() attachment.UploadedAt = time.Now() query := ` INSERT INTO funding_attachments ( id, application_id, file_name, file_type, file_size, category, description, storage_path, uploaded_at, uploaded_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ` _, err := s.pool.Exec(ctx, query, attachment.ID, appID, attachment.FileName, attachment.FileType, attachment.FileSize, attachment.Category, attachment.Description, attachment.StoragePath, attachment.UploadedAt, attachment.UploadedBy, ) return err } // GetAttachments returns all attachments for an application func (s *PostgresStore) GetAttachments(ctx context.Context, appID uuid.UUID) ([]Attachment, error) { query := ` SELECT id, file_name, file_type, file_size, category, description, storage_path, uploaded_at, uploaded_by FROM funding_attachments WHERE application_id = $1 ORDER BY uploaded_at DESC ` rows, err := s.pool.Query(ctx, query, appID) if err != nil { return nil, err } defer rows.Close() var attachments []Attachment for rows.Next() { var a Attachment err := rows.Scan(&a.ID, &a.FileName, &a.FileType, &a.FileSize, &a.Category, &a.Description, &a.StoragePath, &a.UploadedAt, &a.UploadedBy) if err != nil { continue } attachments = append(attachments, a) } return attachments, nil } // DeleteAttachment deletes an attachment func (s *PostgresStore) DeleteAttachment(ctx context.Context, attachmentID uuid.UUID) error { query := `DELETE FROM funding_attachments WHERE id = $1` _, err := s.pool.Exec(ctx, query, attachmentID) return err } // AddHistoryEntry adds an audit trail entry func (s *PostgresStore) AddHistoryEntry(ctx context.Context, entry *ApplicationHistoryEntry) error { entry.ID = uuid.New() entry.PerformedAt = time.Now().Format(time.RFC3339) oldValuesJSON, _ := json.Marshal(entry.OldValues) newValuesJSON, _ := json.Marshal(entry.NewValues) changedFieldsJSON, _ := json.Marshal(entry.ChangedFields) query := ` INSERT INTO funding_application_history ( id, application_id, action, changed_fields, old_values, new_values, performed_by, performed_at, notes ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ` _, err := s.pool.Exec(ctx, query, entry.ID, entry.ApplicationID, entry.Action, changedFieldsJSON, oldValuesJSON, newValuesJSON, entry.PerformedBy, entry.PerformedAt, entry.Notes, ) return err } // GetHistory returns the audit trail for an application func (s *PostgresStore) GetHistory(ctx context.Context, appID uuid.UUID) ([]ApplicationHistoryEntry, error) { query := ` SELECT id, application_id, action, changed_fields, old_values, new_values, performed_by, performed_at, notes FROM funding_application_history WHERE application_id = $1 ORDER BY performed_at DESC ` rows, err := s.pool.Query(ctx, query, appID) if err != nil { return nil, err } defer rows.Close() var history []ApplicationHistoryEntry for rows.Next() { var entry ApplicationHistoryEntry var changedFieldsJSON, oldValuesJSON, newValuesJSON []byte err := rows.Scan( &entry.ID, &entry.ApplicationID, &entry.Action, &changedFieldsJSON, &oldValuesJSON, &newValuesJSON, &entry.PerformedBy, &entry.PerformedAt, &entry.Notes, ) if err != nil { continue } json.Unmarshal(changedFieldsJSON, &entry.ChangedFields) json.Unmarshal(oldValuesJSON, &entry.OldValues) json.Unmarshal(newValuesJSON, &entry.NewValues) history = append(history, entry) } return history, nil } // GetStatistics returns funding statistics func (s *PostgresStore) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*FundingStatistics, error) { stats := &FundingStatistics{ ByProgram: make(map[FundingProgram]int), ByState: make(map[FederalState]int), } // Total and by status query := ` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'DRAFT') as draft, COUNT(*) FILTER (WHERE status = 'SUBMITTED') as submitted, COUNT(*) FILTER (WHERE status = 'APPROVED') as approved, COUNT(*) FILTER (WHERE status = 'REJECTED') as rejected, COALESCE(SUM(requested_amount), 0) as total_requested, COALESCE(SUM(COALESCE(approved_amount, 0)), 0) as total_approved FROM funding_applications WHERE tenant_id = $1 AND status != 'ARCHIVED' ` err := s.pool.QueryRow(ctx, query, tenantID).Scan( &stats.TotalApplications, &stats.DraftCount, &stats.SubmittedCount, &stats.ApprovedCount, &stats.RejectedCount, &stats.TotalRequested, &stats.TotalApproved, ) if err != nil { return nil, err } // By program programQuery := ` SELECT funding_program, COUNT(*) FROM funding_applications WHERE tenant_id = $1 AND status != 'ARCHIVED' GROUP BY funding_program ` rows, _ := s.pool.Query(ctx, programQuery, tenantID) for rows.Next() { var program FundingProgram var count int rows.Scan(&program, &count) stats.ByProgram[program] = count } rows.Close() return stats, nil } // SaveExportBundle saves an export bundle record func (s *PostgresStore) SaveExportBundle(ctx context.Context, bundle *ExportBundle) error { bundle.ID = uuid.New() bundle.GeneratedAt = time.Now() bundle.ExpiresAt = time.Now().Add(24 * time.Hour) // 24h expiry documentsJSON, _ := json.Marshal(bundle.Documents) query := ` INSERT INTO funding_export_bundles ( id, application_id, documents, generated_at, download_url, expires_at ) VALUES ($1, $2, $3, $4, $5, $6) ` _, err := s.pool.Exec(ctx, query, bundle.ID, bundle.ApplicationID, documentsJSON, bundle.GeneratedAt, bundle.DownloadURL, bundle.ExpiresAt, ) return err } // GetExportBundle retrieves an export bundle func (s *PostgresStore) GetExportBundle(ctx context.Context, bundleID uuid.UUID) (*ExportBundle, error) { query := ` SELECT id, application_id, documents, generated_at, download_url, expires_at FROM funding_export_bundles WHERE id = $1 AND expires_at > NOW() ` var bundle ExportBundle var documentsJSON []byte err := s.pool.QueryRow(ctx, query, bundleID).Scan( &bundle.ID, &bundle.ApplicationID, &documentsJSON, &bundle.GeneratedAt, &bundle.DownloadURL, &bundle.ExpiresAt, ) if err != nil { return nil, err } json.Unmarshal(documentsJSON, &bundle.Documents) return &bundle, nil } // generateApplicationNumber creates a unique application number func (s *PostgresStore) generateApplicationNumber(program FundingProgram, school *SchoolProfile) string { year := time.Now().Year() state := "XX" if school != nil { state = string(school.FederalState) } prefix := "FA" switch program { case FundingProgramDigitalPakt1: prefix = "DP1" case FundingProgramDigitalPakt2: prefix = "DP2" case FundingProgramLandesfoerderung: prefix = "LF" } // Get sequence number var seq int s.pool.QueryRow(context.Background(), `SELECT COALESCE(MAX(CAST(SUBSTRING(application_number FROM '\d{5}$') AS INTEGER)), 0) + 1 FROM funding_applications WHERE application_number LIKE $1`, fmt.Sprintf("%s-%s-%d-%%", prefix, state, year), ).Scan(&seq) return fmt.Sprintf("%s-%s-%d-%05d", prefix, state, year, seq) }