Initial commit: breakpilot-compliance - Compliance SDK Platform

Services: Admin-Compliance, Backend-Compliance,
AI-Compliance-SDK, Consent-SDK, Developer-Portal,
PCA-Platform, DSMS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:28 +01:00
commit 4435e7ea0a
734 changed files with 251369 additions and 0 deletions

View File

@@ -0,0 +1,395 @@
package funding
import (
"archive/zip"
"bytes"
"fmt"
"io"
"time"
"github.com/jung-kurt/gofpdf"
"github.com/xuri/excelize/v2"
)
// ExportService handles document generation
type ExportService struct{}
// NewExportService creates a new export service
func NewExportService() *ExportService {
return &ExportService{}
}
// GenerateApplicationLetter generates the main application letter as PDF
func (s *ExportService) GenerateApplicationLetter(app *FundingApplication) ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetMargins(25, 25, 25)
pdf.AddPage()
// Header
pdf.SetFont("Helvetica", "B", 14)
pdf.Cell(0, 10, "Antrag auf Foerderung im Rahmen der digitalen Bildungsinfrastruktur")
pdf.Ln(15)
// Application number
pdf.SetFont("Helvetica", "", 10)
pdf.Cell(0, 6, fmt.Sprintf("Antragsnummer: %s", app.ApplicationNumber))
pdf.Ln(6)
pdf.Cell(0, 6, fmt.Sprintf("Datum: %s", time.Now().Format("02.01.2006")))
pdf.Ln(15)
// Section 1: Einleitung
pdf.SetFont("Helvetica", "B", 12)
pdf.Cell(0, 8, "1. Einleitung")
pdf.Ln(10)
pdf.SetFont("Helvetica", "", 10)
if app.SchoolProfile != nil {
pdf.MultiCell(0, 6, fmt.Sprintf(
"Die %s (Schulnummer: %s) beantragt hiermit Foerdermittel aus dem Programm %s.\n\n"+
"Schultraeger: %s\n"+
"Schulform: %s\n"+
"Schueleranzahl: %d\n"+
"Lehrkraefte: %d",
app.SchoolProfile.Name,
app.SchoolProfile.SchoolNumber,
app.FundingProgram,
app.SchoolProfile.CarrierName,
app.SchoolProfile.Type,
app.SchoolProfile.StudentCount,
app.SchoolProfile.TeacherCount,
), "", "", false)
}
pdf.Ln(10)
// Section 2: Projektziel
pdf.SetFont("Helvetica", "B", 12)
pdf.Cell(0, 8, "2. Projektziel")
pdf.Ln(10)
pdf.SetFont("Helvetica", "", 10)
if app.ProjectPlan != nil {
pdf.MultiCell(0, 6, app.ProjectPlan.Summary, "", "", false)
pdf.Ln(5)
pdf.MultiCell(0, 6, app.ProjectPlan.Goals, "", "", false)
}
pdf.Ln(10)
// Section 3: Beschreibung der Massnahme
pdf.SetFont("Helvetica", "B", 12)
pdf.Cell(0, 8, "3. Beschreibung der Massnahme")
pdf.Ln(10)
pdf.SetFont("Helvetica", "", 10)
if app.ProjectPlan != nil {
pdf.MultiCell(0, 6, app.ProjectPlan.DidacticConcept, "", "", false)
}
pdf.Ln(10)
// Section 4: Datenschutz & IT-Betrieb
pdf.SetFont("Helvetica", "B", 12)
pdf.Cell(0, 8, "4. Datenschutz & IT-Betrieb")
pdf.Ln(10)
pdf.SetFont("Helvetica", "", 10)
if app.ProjectPlan != nil && app.ProjectPlan.DataProtection != "" {
pdf.MultiCell(0, 6, app.ProjectPlan.DataProtection, "", "", false)
}
pdf.Ln(10)
// Section 5: Kosten & Finanzierung
pdf.SetFont("Helvetica", "B", 12)
pdf.Cell(0, 8, "5. Kosten & Finanzierung")
pdf.Ln(10)
pdf.SetFont("Helvetica", "", 10)
if app.Budget != nil {
pdf.Cell(0, 6, fmt.Sprintf("Gesamtkosten: %.2f EUR", app.Budget.TotalCost))
pdf.Ln(6)
pdf.Cell(0, 6, fmt.Sprintf("Beantragter Foerderbetrag: %.2f EUR (%.0f%%)", app.Budget.RequestedFunding, app.Budget.FundingRate*100))
pdf.Ln(6)
pdf.Cell(0, 6, fmt.Sprintf("Eigenanteil: %.2f EUR", app.Budget.OwnContribution))
}
pdf.Ln(10)
// Section 6: Laufzeit
pdf.SetFont("Helvetica", "B", 12)
pdf.Cell(0, 8, "6. Laufzeit")
pdf.Ln(10)
pdf.SetFont("Helvetica", "", 10)
if app.Timeline != nil {
pdf.Cell(0, 6, fmt.Sprintf("Projektbeginn: %s", app.Timeline.PlannedStart.Format("02.01.2006")))
pdf.Ln(6)
pdf.Cell(0, 6, fmt.Sprintf("Projektende: %s", app.Timeline.PlannedEnd.Format("02.01.2006")))
}
pdf.Ln(15)
// Footer note
pdf.SetFont("Helvetica", "I", 9)
pdf.MultiCell(0, 5, "Hinweis: Dieser Antrag wurde mit dem Foerderantrag-Wizard von BreakPilot erstellt. "+
"Die finale Pruefung und Einreichung erfolgt durch den Schultraeger.", "", "", false)
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// GenerateBudgetPlan generates the budget plan as XLSX
func (s *ExportService) GenerateBudgetPlan(app *FundingApplication) ([]byte, error) {
f := excelize.NewFile()
sheetName := "Kostenplan"
f.SetSheetName("Sheet1", sheetName)
// Header row
headers := []string{
"Pos.", "Kategorie", "Beschreibung", "Hersteller",
"Anzahl", "Einzelpreis", "Gesamt", "Foerderfahig", "Finanzierung",
}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(sheetName, cell, h)
}
// Style header
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true},
Fill: excelize.Fill{Type: "pattern", Color: []string{"#E0E0E0"}, Pattern: 1},
})
f.SetRowStyle(sheetName, 1, 1, headerStyle)
// Data rows
row := 2
if app.Budget != nil {
for i, item := range app.Budget.BudgetItems {
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), i+1)
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), string(item.Category))
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), item.Description)
f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), item.Manufacturer)
f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), item.Quantity)
f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), item.UnitPrice)
f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), item.TotalPrice)
fundable := "Nein"
if item.IsFundable {
fundable = "Ja"
}
f.SetCellValue(sheetName, fmt.Sprintf("H%d", row), fundable)
f.SetCellValue(sheetName, fmt.Sprintf("I%d", row), item.FundingSource)
row++
}
// Summary rows
row += 2
f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), "Gesamtkosten:")
f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), app.Budget.TotalCost)
row++
f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), "Foerderbetrag:")
f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), app.Budget.RequestedFunding)
row++
f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), "Eigenanteil:")
f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), app.Budget.OwnContribution)
}
// Set column widths
f.SetColWidth(sheetName, "A", "A", 6)
f.SetColWidth(sheetName, "B", "B", 15)
f.SetColWidth(sheetName, "C", "C", 35)
f.SetColWidth(sheetName, "D", "D", 15)
f.SetColWidth(sheetName, "E", "E", 8)
f.SetColWidth(sheetName, "F", "F", 12)
f.SetColWidth(sheetName, "G", "G", 12)
f.SetColWidth(sheetName, "H", "H", 12)
f.SetColWidth(sheetName, "I", "I", 15)
// Add currency format
currencyStyle, _ := f.NewStyle(&excelize.Style{
NumFmt: 44, // Currency format
})
f.SetColStyle(sheetName, "F", currencyStyle)
f.SetColStyle(sheetName, "G", currencyStyle)
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// GenerateDataProtectionConcept generates the data protection concept as PDF
func (s *ExportService) GenerateDataProtectionConcept(app *FundingApplication) ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetMargins(25, 25, 25)
pdf.AddPage()
// Header
pdf.SetFont("Helvetica", "B", 14)
pdf.Cell(0, 10, "Datenschutz- und Betriebskonzept")
pdf.Ln(15)
pdf.SetFont("Helvetica", "", 10)
pdf.Cell(0, 6, fmt.Sprintf("Antragsnummer: %s", app.ApplicationNumber))
pdf.Ln(6)
if app.SchoolProfile != nil {
pdf.Cell(0, 6, fmt.Sprintf("Schule: %s", app.SchoolProfile.Name))
}
pdf.Ln(15)
// Section: Lokale Verarbeitung
pdf.SetFont("Helvetica", "B", 12)
pdf.Cell(0, 8, "1. Grundsaetze der Datenverarbeitung")
pdf.Ln(10)
pdf.SetFont("Helvetica", "", 10)
if app.ProjectPlan != nil && app.ProjectPlan.DataProtection != "" {
pdf.MultiCell(0, 6, app.ProjectPlan.DataProtection, "", "", false)
} else {
pdf.MultiCell(0, 6, "Das Projekt setzt auf eine vollstaendig lokale Datenverarbeitung:\n\n"+
"- Alle Daten werden ausschliesslich auf den schuleigenen Systemen verarbeitet\n"+
"- Keine Uebermittlung personenbezogener Daten an externe Dienste\n"+
"- Keine Cloud-Speicherung sensibler Daten\n"+
"- Betrieb im Verantwortungsbereich der Schule", "", "", false)
}
pdf.Ln(10)
// Section: Technische Massnahmen
pdf.SetFont("Helvetica", "B", 12)
pdf.Cell(0, 8, "2. Technische und organisatorische Massnahmen")
pdf.Ln(10)
pdf.SetFont("Helvetica", "", 10)
pdf.MultiCell(0, 6, "Folgende TOMs werden umgesetzt:\n\n"+
"- Zugriffskontrolle ueber schuleigene Benutzerverwaltung\n"+
"- Verschluesselte Datenspeicherung\n"+
"- Regelmaessige Sicherheitsupdates\n"+
"- Protokollierung von Zugriffen\n"+
"- Automatische Loeschung nach definierten Fristen", "", "", false)
pdf.Ln(10)
// Section: Betriebskonzept
pdf.SetFont("Helvetica", "B", 12)
pdf.Cell(0, 8, "3. Betriebskonzept")
pdf.Ln(10)
pdf.SetFont("Helvetica", "", 10)
if app.ProjectPlan != nil && app.ProjectPlan.MaintenancePlan != "" {
pdf.MultiCell(0, 6, app.ProjectPlan.MaintenancePlan, "", "", false)
} else {
pdf.MultiCell(0, 6, "Der laufende Betrieb wird wie folgt sichergestellt:\n\n"+
"- Schulung des technischen Personals\n"+
"- Dokumentierte Betriebsverfahren\n"+
"- Regelmaessige Wartung und Updates\n"+
"- Definierte Ansprechpartner", "", "", false)
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// GenerateExportBundle generates a ZIP file with all documents
func (s *ExportService) GenerateExportBundle(app *FundingApplication) ([]byte, error) {
var buf bytes.Buffer
zipWriter := zip.NewWriter(&buf)
// Generate and add application letter
letter, err := s.GenerateApplicationLetter(app)
if err == nil {
w, _ := zipWriter.Create(fmt.Sprintf("%s_Antragsschreiben.pdf", app.ApplicationNumber))
w.Write(letter)
}
// Generate and add budget plan
budget, err := s.GenerateBudgetPlan(app)
if err == nil {
w, _ := zipWriter.Create(fmt.Sprintf("%s_Kostenplan.xlsx", app.ApplicationNumber))
w.Write(budget)
}
// Generate and add data protection concept
dp, err := s.GenerateDataProtectionConcept(app)
if err == nil {
w, _ := zipWriter.Create(fmt.Sprintf("%s_Datenschutzkonzept.pdf", app.ApplicationNumber))
w.Write(dp)
}
// Add attachments
for _, attachment := range app.Attachments {
// Read attachment from storage and add to ZIP
// This would need actual file system access
_ = attachment
}
if err := zipWriter.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// ExportDocument represents a generated document
type GeneratedDocument struct {
Name string
Type string // pdf, xlsx, docx
Content []byte
MimeType string
}
// GenerateAllDocuments generates all documents for an application
func (s *ExportService) GenerateAllDocuments(app *FundingApplication) ([]GeneratedDocument, error) {
var docs []GeneratedDocument
// Application letter
letter, err := s.GenerateApplicationLetter(app)
if err == nil {
docs = append(docs, GeneratedDocument{
Name: fmt.Sprintf("%s_Antragsschreiben.pdf", app.ApplicationNumber),
Type: "pdf",
Content: letter,
MimeType: "application/pdf",
})
}
// Budget plan
budget, err := s.GenerateBudgetPlan(app)
if err == nil {
docs = append(docs, GeneratedDocument{
Name: fmt.Sprintf("%s_Kostenplan.xlsx", app.ApplicationNumber),
Type: "xlsx",
Content: budget,
MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
})
}
// Data protection concept
dp, err := s.GenerateDataProtectionConcept(app)
if err == nil {
docs = append(docs, GeneratedDocument{
Name: fmt.Sprintf("%s_Datenschutzkonzept.pdf", app.ApplicationNumber),
Type: "pdf",
Content: dp,
MimeType: "application/pdf",
})
}
return docs, nil
}
// WriteZipToWriter writes all documents to a zip writer
func (s *ExportService) WriteZipToWriter(app *FundingApplication, w io.Writer) error {
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
docs, err := s.GenerateAllDocuments(app)
if err != nil {
return err
}
for _, doc := range docs {
f, err := zipWriter.Create(doc.Name)
if err != nil {
continue
}
f.Write(doc.Content)
}
return nil
}

View File

@@ -0,0 +1,394 @@
package funding
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// FundingProgram represents the type of funding program
type FundingProgram string
const (
FundingProgramDigitalPakt1 FundingProgram = "DIGITALPAKT_1"
FundingProgramDigitalPakt2 FundingProgram = "DIGITALPAKT_2"
FundingProgramLandesfoerderung FundingProgram = "LANDESFOERDERUNG"
FundingProgramSchultraeger FundingProgram = "SCHULTRAEGER"
FundingProgramSonstige FundingProgram = "SONSTIGE"
)
// ApplicationStatus represents the workflow status
type ApplicationStatus string
const (
ApplicationStatusDraft ApplicationStatus = "DRAFT"
ApplicationStatusInProgress ApplicationStatus = "IN_PROGRESS"
ApplicationStatusReview ApplicationStatus = "REVIEW"
ApplicationStatusSubmitted ApplicationStatus = "SUBMITTED"
ApplicationStatusApproved ApplicationStatus = "APPROVED"
ApplicationStatusRejected ApplicationStatus = "REJECTED"
ApplicationStatusArchived ApplicationStatus = "ARCHIVED"
)
// FederalState represents German federal states
type FederalState string
const (
FederalStateNI FederalState = "NI" // Niedersachsen
FederalStateNRW FederalState = "NRW" // Nordrhein-Westfalen
FederalStateBAY FederalState = "BAY" // Bayern
FederalStateBW FederalState = "BW" // Baden-Wuerttemberg
FederalStateHE FederalState = "HE" // Hessen
FederalStateSN FederalState = "SN" // Sachsen
FederalStateTH FederalState = "TH" // Thueringen
FederalStateSA FederalState = "SA" // Sachsen-Anhalt
FederalStateBB FederalState = "BB" // Brandenburg
FederalStateMV FederalState = "MV" // Mecklenburg-Vorpommern
FederalStateSH FederalState = "SH" // Schleswig-Holstein
FederalStateHH FederalState = "HH" // Hamburg
FederalStateHB FederalState = "HB" // Bremen
FederalStateBE FederalState = "BE" // Berlin
FederalStateSL FederalState = "SL" // Saarland
FederalStateRP FederalState = "RP" // Rheinland-Pfalz
)
// SchoolType represents different school types
type SchoolType string
const (
SchoolTypeGrundschule SchoolType = "GRUNDSCHULE"
SchoolTypeHauptschule SchoolType = "HAUPTSCHULE"
SchoolTypeRealschule SchoolType = "REALSCHULE"
SchoolTypeGymnasium SchoolType = "GYMNASIUM"
SchoolTypeGesamtschule SchoolType = "GESAMTSCHULE"
SchoolTypeOberschule SchoolType = "OBERSCHULE"
SchoolTypeFoerderschule SchoolType = "FOERDERSCHULE"
SchoolTypeBerufsschule SchoolType = "BERUFSSCHULE"
SchoolTypeBerufskolleg SchoolType = "BERUFSKOLLEG"
SchoolTypeFachoberschule SchoolType = "FACHOBERSCHULE"
SchoolTypeBerufliches SchoolType = "BERUFLICHES_GYMNASIUM"
SchoolTypeSonstige SchoolType = "SONSTIGE"
)
// CarrierType represents the school carrier type
type CarrierType string
const (
CarrierTypePublic CarrierType = "PUBLIC" // Oeffentlich
CarrierTypePrivate CarrierType = "PRIVATE" // Privat
CarrierTypeChurch CarrierType = "CHURCH" // Kirchlich
CarrierTypeNonProfit CarrierType = "NON_PROFIT" // Gemeinnuetzig
)
// BudgetCategory represents categories for budget items
type BudgetCategory string
const (
BudgetCategoryNetwork BudgetCategory = "NETWORK" // Netzwerk/Verkabelung
BudgetCategoryWLAN BudgetCategory = "WLAN" // WLAN-Infrastruktur
BudgetCategoryDevices BudgetCategory = "DEVICES" // Endgeraete
BudgetCategoryPresentation BudgetCategory = "PRESENTATION" // Praesentationstechnik
BudgetCategorySoftware BudgetCategory = "SOFTWARE" // Software-Lizenzen
BudgetCategoryServer BudgetCategory = "SERVER" // Server/Rechenzentrum
BudgetCategoryServices BudgetCategory = "SERVICES" // Dienstleistungen
BudgetCategoryTraining BudgetCategory = "TRAINING" // Schulungen
BudgetCategorySonstige BudgetCategory = "SONSTIGE" // Sonstige
)
// ============================================================================
// Main Entities
// ============================================================================
// FundingApplication represents a funding application
type FundingApplication struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
ApplicationNumber string `json:"application_number"` // e.g., DP2-NI-2026-00123
Title string `json:"title"`
FundingProgram FundingProgram `json:"funding_program"`
Status ApplicationStatus `json:"status"`
// Wizard State
CurrentStep int `json:"current_step"`
TotalSteps int `json:"total_steps"`
WizardData map[string]interface{} `json:"wizard_data,omitempty"`
// School Information
SchoolProfile *SchoolProfile `json:"school_profile,omitempty"`
// Project Information
ProjectPlan *ProjectPlan `json:"project_plan,omitempty"`
Budget *Budget `json:"budget,omitempty"`
Timeline *ProjectTimeline `json:"timeline,omitempty"`
// Financial Summary
RequestedAmount float64 `json:"requested_amount"`
OwnContribution float64 `json:"own_contribution"`
ApprovedAmount *float64 `json:"approved_amount,omitempty"`
// Attachments
Attachments []Attachment `json:"attachments,omitempty"`
// Audit Trail
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
CreatedBy uuid.UUID `json:"created_by"`
UpdatedBy uuid.UUID `json:"updated_by"`
}
// SchoolProfile contains school information
type SchoolProfile struct {
Name string `json:"name"`
SchoolNumber string `json:"school_number"` // Official school number
Type SchoolType `json:"type"`
FederalState FederalState `json:"federal_state"`
Address Address `json:"address"`
ContactPerson ContactPerson `json:"contact_person"`
StudentCount int `json:"student_count"`
TeacherCount int `json:"teacher_count"`
ClassCount int `json:"class_count"`
CarrierType CarrierType `json:"carrier_type"`
CarrierName string `json:"carrier_name"`
CarrierAddress *Address `json:"carrier_address,omitempty"`
Infrastructure *InfrastructureStatus `json:"infrastructure,omitempty"`
}
// Address represents a postal address
type Address struct {
Street string `json:"street"`
HouseNo string `json:"house_no"`
PostalCode string `json:"postal_code"`
City string `json:"city"`
Country string `json:"country,omitempty"`
}
// ContactPerson represents a contact person
type ContactPerson struct {
Salutation string `json:"salutation,omitempty"` // Herr/Frau
Title string `json:"title,omitempty"` // Dr., Prof.
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Position string `json:"position,omitempty"` // Schulleitung, IT-Beauftragter
Email string `json:"email"`
Phone string `json:"phone,omitempty"`
}
// InfrastructureStatus describes current IT infrastructure
type InfrastructureStatus struct {
HasWLAN bool `json:"has_wlan"`
WLANCoverage int `json:"wlan_coverage"` // Percentage 0-100
HasStructuredCabling bool `json:"has_structured_cabling"`
InternetBandwidth string `json:"internet_bandwidth"` // e.g., "100 Mbit/s"
DeviceCount int `json:"device_count"` // Current devices
HasServerRoom bool `json:"has_server_room"`
Notes string `json:"notes,omitempty"`
}
// ProjectPlan describes the project
type ProjectPlan struct {
ProjectName string `json:"project_name"`
Summary string `json:"summary"` // Kurzbeschreibung
Goals string `json:"goals"` // Projektziele
DidacticConcept string `json:"didactic_concept"` // Paedagogisches Konzept
MEPReference string `json:"mep_reference,omitempty"` // Medienentwicklungsplan Bezug
DataProtection string `json:"data_protection"` // Datenschutzkonzept
MaintenancePlan string `json:"maintenance_plan"` // Wartungs-/Betriebskonzept
TargetGroups []string `json:"target_groups"` // e.g., ["Schueler", "Lehrer"]
SubjectsAffected []string `json:"subjects_affected,omitempty"` // Betroffene Faecher
}
// Budget represents the financial plan
type Budget struct {
TotalCost float64 `json:"total_cost"`
RequestedFunding float64 `json:"requested_funding"`
OwnContribution float64 `json:"own_contribution"`
OtherFunding float64 `json:"other_funding"`
FundingRate float64 `json:"funding_rate"` // 0.90 = 90%
BudgetItems []BudgetItem `json:"budget_items"`
IsWithinLimits bool `json:"is_within_limits"`
Justification string `json:"justification,omitempty"` // Begruendung
}
// BudgetItem represents a single budget line item
type BudgetItem struct {
ID uuid.UUID `json:"id"`
Position int `json:"position"` // Order number
Category BudgetCategory `json:"category"`
Description string `json:"description"`
Manufacturer string `json:"manufacturer,omitempty"`
ProductName string `json:"product_name,omitempty"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
IsFundable bool `json:"is_fundable"` // Foerderfahig Ja/Nein
FundingSource string `json:"funding_source"` // digitalpakt, eigenanteil, sonstige
Notes string `json:"notes,omitempty"`
}
// ProjectTimeline represents project schedule
type ProjectTimeline struct {
PlannedStart time.Time `json:"planned_start"`
PlannedEnd time.Time `json:"planned_end"`
Milestones []Milestone `json:"milestones,omitempty"`
ProjectPhase string `json:"project_phase,omitempty"` // Current phase
}
// Milestone represents a project milestone
type Milestone struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
DueDate time.Time `json:"due_date"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Status string `json:"status"` // planned, in_progress, completed
}
// Attachment represents an uploaded file
type Attachment struct {
ID uuid.UUID `json:"id"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // pdf, docx, xlsx, jpg, png
FileSize int64 `json:"file_size"` // bytes
Category string `json:"category"` // angebot, mep, nachweis, sonstiges
Description string `json:"description,omitempty"`
StoragePath string `json:"-"` // Internal path, not exposed
UploadedAt time.Time `json:"uploaded_at"`
UploadedBy uuid.UUID `json:"uploaded_by"`
}
// ============================================================================
// Wizard Step Data
// ============================================================================
// WizardStep represents a single wizard step
type WizardStep struct {
Number int `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
Fields []string `json:"fields"` // Field IDs for this step
IsCompleted bool `json:"is_completed"`
IsRequired bool `json:"is_required"`
HelpContext string `json:"help_context"` // Context for LLM assistant
}
// WizardProgress tracks wizard completion
type WizardProgress struct {
CurrentStep int `json:"current_step"`
TotalSteps int `json:"total_steps"`
CompletedSteps []int `json:"completed_steps"`
StepValidation map[int][]string `json:"step_validation,omitempty"` // Errors per step
FormData map[string]interface{} `json:"form_data"`
LastSavedAt time.Time `json:"last_saved_at"`
}
// ============================================================================
// BreakPilot Presets
// ============================================================================
// ProductPreset represents a BreakPilot product preset
type ProductPreset struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BudgetItems []BudgetItem `json:"budget_items"`
AutoFill map[string]interface{} `json:"auto_fill"`
DataProtection string `json:"data_protection"`
}
// ============================================================================
// Export Structures
// ============================================================================
// ExportDocument represents a generated document
type ExportDocument struct {
Type string `json:"type"` // antragsschreiben, kostenplan, datenschutz
Format string `json:"format"` // pdf, docx, xlsx
FileName string `json:"file_name"`
GeneratedAt time.Time `json:"generated_at"`
ContentHash string `json:"content_hash"`
StoragePath string `json:"-"`
}
// ExportBundle represents a ZIP bundle of all documents
type ExportBundle struct {
ID uuid.UUID `json:"id"`
ApplicationID uuid.UUID `json:"application_id"`
Documents []ExportDocument `json:"documents"`
GeneratedAt time.Time `json:"generated_at"`
DownloadURL string `json:"download_url"`
ExpiresAt time.Time `json:"expires_at"`
}
// ============================================================================
// LLM Assistant
// ============================================================================
// AssistantMessage represents a chat message with the assistant
type AssistantMessage struct {
Role string `json:"role"` // user, assistant, system
Content string `json:"content"`
Step int `json:"step,omitempty"` // Current wizard step
}
// AssistantRequest for asking questions
type AssistantRequest struct {
ApplicationID uuid.UUID `json:"application_id"`
Question string `json:"question"`
CurrentStep int `json:"current_step"`
Context map[string]interface{} `json:"context,omitempty"`
History []AssistantMessage `json:"history,omitempty"`
}
// AssistantResponse from the assistant
type AssistantResponse struct {
Answer string `json:"answer"`
Suggestions []string `json:"suggestions,omitempty"`
References []string `json:"references,omitempty"` // Links to help resources
FormFills map[string]interface{} `json:"form_fills,omitempty"` // Suggested form values
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// CreateApplicationRequest for creating a new application
type CreateApplicationRequest struct {
Title string `json:"title"`
FundingProgram FundingProgram `json:"funding_program"`
FederalState FederalState `json:"federal_state"`
PresetID string `json:"preset_id,omitempty"` // Optional BreakPilot preset
}
// UpdateApplicationRequest for updating an application
type UpdateApplicationRequest struct {
Title *string `json:"title,omitempty"`
WizardData map[string]interface{} `json:"wizard_data,omitempty"`
CurrentStep *int `json:"current_step,omitempty"`
}
// SaveWizardStepRequest for saving a wizard step
type SaveWizardStepRequest struct {
Step int `json:"step"`
Data map[string]interface{} `json:"data"`
Complete bool `json:"complete"` // Mark step as complete
}
// ApplicationListResponse for list endpoints
type ApplicationListResponse struct {
Applications []FundingApplication `json:"applications"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// ExportRequest for export endpoints
type ExportRequest struct {
Format string `json:"format"` // zip, pdf, docx
Documents []string `json:"documents"` // Which documents to include
Language string `json:"language"` // de, en
}

View File

@@ -0,0 +1,652 @@
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)
}

View File

@@ -0,0 +1,81 @@
package funding
import (
"context"
"github.com/google/uuid"
)
// Store defines the interface for funding application persistence
type Store interface {
// Application CRUD
CreateApplication(ctx context.Context, app *FundingApplication) error
GetApplication(ctx context.Context, id uuid.UUID) (*FundingApplication, error)
GetApplicationByNumber(ctx context.Context, number string) (*FundingApplication, error)
UpdateApplication(ctx context.Context, app *FundingApplication) error
DeleteApplication(ctx context.Context, id uuid.UUID) error
// List & Search
ListApplications(ctx context.Context, tenantID uuid.UUID, filter ApplicationFilter) (*ApplicationListResponse, error)
SearchApplications(ctx context.Context, tenantID uuid.UUID, query string) ([]FundingApplication, error)
// Wizard Data
SaveWizardStep(ctx context.Context, appID uuid.UUID, step int, data map[string]interface{}) error
GetWizardProgress(ctx context.Context, appID uuid.UUID) (*WizardProgress, error)
// Attachments
AddAttachment(ctx context.Context, appID uuid.UUID, attachment *Attachment) error
GetAttachments(ctx context.Context, appID uuid.UUID) ([]Attachment, error)
DeleteAttachment(ctx context.Context, attachmentID uuid.UUID) error
// Application History (Audit Trail)
AddHistoryEntry(ctx context.Context, entry *ApplicationHistoryEntry) error
GetHistory(ctx context.Context, appID uuid.UUID) ([]ApplicationHistoryEntry, error)
// Statistics
GetStatistics(ctx context.Context, tenantID uuid.UUID) (*FundingStatistics, error)
// Export Tracking
SaveExportBundle(ctx context.Context, bundle *ExportBundle) error
GetExportBundle(ctx context.Context, bundleID uuid.UUID) (*ExportBundle, error)
}
// ApplicationFilter for filtering list queries
type ApplicationFilter struct {
Status *ApplicationStatus `json:"status,omitempty"`
FundingProgram *FundingProgram `json:"funding_program,omitempty"`
FederalState *FederalState `json:"federal_state,omitempty"`
CreatedAfter *string `json:"created_after,omitempty"`
CreatedBefore *string `json:"created_before,omitempty"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by,omitempty"`
SortOrder string `json:"sort_order,omitempty"` // asc, desc
}
// ApplicationHistoryEntry for audit trail
type ApplicationHistoryEntry struct {
ID uuid.UUID `json:"id"`
ApplicationID uuid.UUID `json:"application_id"`
Action string `json:"action"` // created, updated, submitted, approved, etc.
ChangedFields []string `json:"changed_fields,omitempty"`
OldValues map[string]interface{} `json:"old_values,omitempty"`
NewValues map[string]interface{} `json:"new_values,omitempty"`
PerformedBy uuid.UUID `json:"performed_by"`
PerformedAt string `json:"performed_at"`
Notes string `json:"notes,omitempty"`
}
// FundingStatistics for dashboard
type FundingStatistics struct {
TotalApplications int `json:"total_applications"`
DraftCount int `json:"draft_count"`
SubmittedCount int `json:"submitted_count"`
ApprovedCount int `json:"approved_count"`
RejectedCount int `json:"rejected_count"`
TotalRequested float64 `json:"total_requested"`
TotalApproved float64 `json:"total_approved"`
AverageProcessDays float64 `json:"average_process_days"`
ByProgram map[FundingProgram]int `json:"by_program"`
ByState map[FederalState]int `json:"by_state"`
}