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