This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/ai-compliance-sdk/internal/funding/postgres_store.go
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
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>
2026-02-09 09:51:32 +01:00

653 lines
19 KiB
Go

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)
}