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:
395
ai-compliance-sdk/internal/funding/export.go
Normal file
395
ai-compliance-sdk/internal/funding/export.go
Normal 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
|
||||
}
|
||||
394
ai-compliance-sdk/internal/funding/models.go
Normal file
394
ai-compliance-sdk/internal/funding/models.go
Normal 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
|
||||
}
|
||||
652
ai-compliance-sdk/internal/funding/postgres_store.go
Normal file
652
ai-compliance-sdk/internal/funding/postgres_store.go
Normal 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)
|
||||
}
|
||||
81
ai-compliance-sdk/internal/funding/store.go
Normal file
81
ai-compliance-sdk/internal/funding/store.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user