Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3222a93
docs: add UI screenshots to backend docs
mlehotskylf May 14, 2026
d36eaf9
Add initiative_ledger_stats table, remove initiative_stats
mlehotskylf May 14, 2026
344739f
Rename backers → supporters in initiative_ledger_stats
mlehotskylf May 14, 2026
04f6593
Add SortBy/SortDir to InitiativeFilter for trending sort
mlehotskylf May 14, 2026
a225ef0
Unify Initiative response shape, add financials from CF DB
mlehotskylf May 14, 2026
4998a62
feat: add GET /v1/statistics endpoint for landing page hero section
mlehotskylf May 14, 2026
935bdae
docs: specify ledger-stats-sync CronJob and add skeleton binary
mlehotskylf May 15, 2026
ccff5f9
feat: make initiative list and detail endpoints public (no auth)
mlehotskylf May 15, 2026
06c1900
fix(review): address PR #16 review feedback
mlehotskylf May 15, 2026
f536298
fix(review): address second pass PR #16 review feedback
mlehotskylf May 15, 2026
fef01a4
fix(review+ci): address third pass PR #16 feedback and MegaLinter fai…
mlehotskylf May 15, 2026
e429c0b
fix(lint): add language specifiers to bare fenced code blocks
mlehotskylf May 15, 2026
6d993f8
small rename
mlehotskylf May 15, 2026
c7ee58a
fix(deps): bump devalue from 5.8.0 to 5.8.1 (CVE-2026-42570)
mlehotskylf May 15, 2026
c4ade47
fix(db): ensure goals always serialise as [] not null, fix redundant …
mlehotskylf May 15, 2026
665ba98
fix(models): use int64 for statistics aggregates, tighten devalue ove…
mlehotskylf May 15, 2026
117f81d
feat(backend): add ledger-stats-sync CronJob, remove backers column, …
lewisojile May 15, 2026
02e69d7
Merge branch 'main' into docs/add-screenshots
lewisojile May 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .mega-linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ DISABLE_ERRORS_LINTERS:
- PYTHON_PYLINT
- PYTHON_PYRIGHT
- PYTHON_MYPY
# devskim crashes with XML parse errors on non-XML files (tool bug, not a real
# security finding). Treat as non-fatal until upstream fixes it.
- REPOSITORY_DEVSKIM
# The Go module lives in backend/, not the repo root. Running golangci-lint /
# revive from the workspace root produces:
# [linters_context] typechecking error: pattern ./...: directory prefix .
Expand All @@ -46,3 +49,7 @@ SPELL_CSPELL_ANALYZE_FILE_NAMES: false
# main module" errors.
GO_GOLANGCI_LINT_DIRECTORY: backend
GO_REVIVE_DIRECTORY: backend

# Exclude .claude/ skill files — these are tool configuration, not application
# code, and contain markdown with intentional bare fences (example snippets).
FILTER_REGEX_EXCLUDE: (\.claude/)
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ LFX Crowdfunding enables open source projects to raise funds for development, se

## Repository Layout

```
```text
lfx-crowdfunding/
├── frontend/ # Nuxt 3 frontend (Vue 3, TypeScript, Tailwind, PrimeVue)
├── cmd/
Expand Down
6 changes: 5 additions & 1 deletion backend/cmd/initiatives-api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ func LoadConfig() (*Config, error) {
if ledgerBaseURL == "" {
return nil, fmt.Errorf("LEDGER_BASE_URL is required")
}
ledgerAPIKey := getEnv("LEDGER_API_KEY", "")
if ledgerAPIKey == "" {
return nil, fmt.Errorf("LEDGER_API_KEY is required")
}
Comment on lines +121 to +123

port, err := getIntEnv("PORT", 8080)
if err != nil {
Expand Down Expand Up @@ -191,7 +195,7 @@ func LoadConfig() (*Config, error) {
},
Ledger: LedgerConfig{
BaseURL: ledgerBaseURL,
APIKey: getEnv("LEDGER_API_KEY", ""),
APIKey: ledgerAPIKey,
Timeout: ledgerTimeout,
},
OTel: OTelConfig{
Expand Down
11 changes: 8 additions & 3 deletions backend/cmd/initiatives-api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func NewServer(cfg *Config, logger *slog.Logger) (*Server, error) {
initiativeRepo := db.NewInitiativeRepository(pool)
donationRepo := db.NewDonationRepository(pool)
subscriptionRepo := db.NewSubscriptionRepository(pool)
statisticsRepo := db.NewStatisticsRepository(pool)

// Clients
ledgerClient := clients.NewLedgerClient(clients.LedgerConfig{
Expand All @@ -65,6 +66,7 @@ func NewServer(cfg *Config, logger *slog.Logger) (*Server, error) {
initiativeSvc := service.NewInitiativeService(initiativeRepo, ledgerClient, stripeClient)
donationSvc := service.NewDonationService(donationRepo, initiativeRepo, stripeClient)
subscriptionSvc := service.NewSubscriptionService(subscriptionRepo, initiativeRepo, stripeClient)
statisticsSvc := service.NewStatisticsService(statisticsRepo)

// JWT authenticator
jwtAuth, err := auth.NewJWTAuthenticator(auth.JWTAuthConfig{
Expand All @@ -88,6 +90,7 @@ func NewServer(cfg *Config, logger *slog.Logger) (*Server, error) {
initiativeH := handler.NewInitiativeHandler(initiativeSvc)
donationH := handler.NewDonationHandler(donationSvc)
subscriptionH := handler.NewSubscriptionHandler(subscriptionSvc)
statisticsH := handler.NewStatisticsHandler(statisticsSvc)
webhookH := handler.NewWebhookHandler(stripeClient, cfg.Stripe.WebhookSecret, logger, cfg.Stripe.AckUnimplementedWebhooks)

// Router
Expand All @@ -112,17 +115,19 @@ func NewServer(cfg *Config, logger *slog.Logger) (*Server, error) {
// Stripe webhook (no JWT — uses its own HMAC signature validation)
r.Post("/v1/stripe/webhook", webhookH.Handle)

// Public API (no auth)
r.Get("/v1/statistics", statisticsH.GetPlatform)
r.Get("/v1/initiatives", initiativeH.List)
r.Get("/v1/initiatives/{id}", initiativeH.GetByID)

Comment thread
mlehotskylf marked this conversation as resolved.
Comment thread
mlehotskylf marked this conversation as resolved.
// Protected API
r.Route("/v1", func(r chi.Router) {
r.Use(jwtAuth.Middleware)

r.Route("/initiatives", func(r chi.Router) {
Comment thread
mlehotskylf marked this conversation as resolved.
r.Get("/", initiativeH.List)
r.Post("/", initiativeH.Create)
r.Get("/{id}", initiativeH.GetByID)
r.Patch("/{id}", initiativeH.Update)
r.Delete("/{id}", initiativeH.Delete)
r.Get("/{id}/goals", initiativeH.ListGoals)
r.Get("/{id}/donations", donationH.List)
r.Post("/{id}/donations", donationH.Create)
r.Get("/{id}/subscriptions", subscriptionH.List)
Expand Down
130 changes: 130 additions & 0 deletions backend/cmd/ledger-stats-sync/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

// ledger-stats-sync pulls balance data from the Ledger HTTP API and upserts
// rows into initiative_ledger_stats in CF Postgres.
//
// See docs/rewrite/02-decisions.md § ledger-stats-sync CronJob for the full
// specification, column mapping, and ID constraints.
//
// Usage: run as a K8s CronJob (schedule: hourly). Exits 0 on success,
// non-zero on any error — K8s uses the exit code to track CronJob health.
package main

import (
"context"
"fmt"
"log/slog"
"os"
"time"

"github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/clients"
"github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/db"
)

func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

if err := run(logger); err != nil {
logger.Error("ledger-stats-sync failed", "error", err)
os.Exit(1)
}
}

func run(logger *slog.Logger) error {
ctx := context.Background()
start := time.Now()

cfg, err := loadConfig()
if err != nil {
return fmt.Errorf("config: %w", err)
}

// Database pool — shared pgxpool, same pattern as the initiatives API.
pool, err := db.NewPool(ctx, db.PoolConfig{
DSN: cfg.DatabaseURL,
MaxConns: cfg.DBMaxConns,
MinConns: cfg.DBMinConns,
ConnMaxLifetime: cfg.DBConnMaxLifetime,
})
if err != nil {
return fmt.Errorf("database pool: %w", err)
}
defer pool.Close()

// Ledger HTTP client.
ledgerClient := clients.NewLedgerClient(clients.LedgerConfig{
BaseURL: cfg.LedgerBaseURL,
APIKey: cfg.LedgerAPIKey,
Timeout: cfg.LedgerTimeout,
})

// Repository and syncer.
repo := db.NewLedgerStatsRepository(pool)
syncer := newSyncer(repo, ledgerClient, logger)

logger.Info("ledger-stats-sync starting")

result, err := syncer.Run(ctx)
if err != nil {
return fmt.Errorf("sync run: %w", err)
}

logger.Info("ledger-stats-sync complete",
"duration", time.Since(start).String(),
"total_initiatives", result.total,
"matched", result.matched,
"upserted", result.upserted,
"skipped", result.skipped,
)
return nil
}

// config holds the runtime configuration for ledger-stats-sync.
type config struct {
DatabaseURL string
DBMaxConns int
DBMinConns int
DBConnMaxLifetime time.Duration
LedgerBaseURL string
LedgerAPIKey string
LedgerTimeout time.Duration
}

func loadConfig() (*config, error) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
ledgerBaseURL := os.Getenv("LEDGER_BASE_URL")
if ledgerBaseURL == "" {
return nil, fmt.Errorf("LEDGER_BASE_URL is required")
}
ledgerAPIKey := os.Getenv("LEDGER_API_KEY")
if ledgerAPIKey == "" {
return nil, fmt.Errorf("LEDGER_API_KEY is required")
}

ledgerTimeout := 30 * time.Second
if v := os.Getenv("LEDGER_TIMEOUT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return nil, fmt.Errorf("LEDGER_TIMEOUT: invalid duration %q: %w", v, err)
}
ledgerTimeout = d
}

dbMaxConns := 5
dbMinConns := 1
dbConnMaxLifetime := 5 * time.Minute

return &config{
DatabaseURL: dbURL,
DBMaxConns: dbMaxConns,
DBMinConns: dbMinConns,
DBConnMaxLifetime: dbConnMaxLifetime,
LedgerBaseURL: ledgerBaseURL,
LedgerAPIKey: ledgerAPIKey,
LedgerTimeout: ledgerTimeout,
}, nil
Comment thread
mlehotskylf marked this conversation as resolved.
Comment thread
mlehotskylf marked this conversation as resolved.
}
118 changes: 118 additions & 0 deletions backend/cmd/ledger-stats-sync/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

package main

import (
"os"
"testing"
"time"
)

func TestLoadConfig_missingDatabaseURL(t *testing.T) {
clearEnv(t)
_, err := loadConfig()
if err == nil {
t.Fatal("expected error for missing DATABASE_URL, got nil")
}
}

func TestLoadConfig_missingLedgerBaseURL(t *testing.T) {
clearEnv(t)
t.Setenv("DATABASE_URL", "postgres://localhost/test")

_, err := loadConfig()
if err == nil {
t.Fatal("expected error for missing LEDGER_BASE_URL, got nil")
}
}

func TestLoadConfig_missingLedgerAPIKey(t *testing.T) {
clearEnv(t)
t.Setenv("DATABASE_URL", "postgres://localhost/test")
t.Setenv("LEDGER_BASE_URL", "https://ledger.example.com")

_, err := loadConfig()
if err == nil {
t.Fatal("expected error for missing LEDGER_API_KEY, got nil")
}
}

func TestLoadConfig_invalidLedgerTimeout(t *testing.T) {
clearEnv(t)
t.Setenv("DATABASE_URL", "postgres://localhost/test")
t.Setenv("LEDGER_BASE_URL", "https://ledger.example.com")
t.Setenv("LEDGER_API_KEY", "Bearer token123")
t.Setenv("LEDGER_TIMEOUT", "not-a-duration")

_, err := loadConfig()
if err == nil {
t.Fatal("expected error for invalid LEDGER_TIMEOUT, got nil")
}
}

func TestLoadConfig_defaultLedgerTimeout(t *testing.T) {
clearEnv(t)
t.Setenv("DATABASE_URL", "postgres://localhost/test")
t.Setenv("LEDGER_BASE_URL", "https://ledger.example.com")
t.Setenv("LEDGER_API_KEY", "Bearer token123")

cfg, err := loadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.LedgerTimeout != 30*time.Second {
t.Errorf("LedgerTimeout: got %v, want 30s", cfg.LedgerTimeout)
}
}

func TestLoadConfig_customLedgerTimeout(t *testing.T) {
clearEnv(t)
t.Setenv("DATABASE_URL", "postgres://localhost/test")
t.Setenv("LEDGER_BASE_URL", "https://ledger.example.com")
t.Setenv("LEDGER_API_KEY", "Bearer token123")
t.Setenv("LEDGER_TIMEOUT", "15s")

cfg, err := loadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.LedgerTimeout != 15*time.Second {
t.Errorf("LedgerTimeout: got %v, want 15s", cfg.LedgerTimeout)
}
}

func TestLoadConfig_allRequiredFieldsPopulated(t *testing.T) {
clearEnv(t)
t.Setenv("DATABASE_URL", "postgres://localhost/crowdfunding")
t.Setenv("LEDGER_BASE_URL", "https://ledger.example.com/")
t.Setenv("LEDGER_API_KEY", "Bearer secret")

cfg, err := loadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.DatabaseURL != "postgres://localhost/crowdfunding" {
t.Errorf("DatabaseURL: got %q", cfg.DatabaseURL)
}
if cfg.LedgerBaseURL != "https://ledger.example.com/" {
t.Errorf("LedgerBaseURL: got %q", cfg.LedgerBaseURL)
}
if cfg.LedgerAPIKey != "Bearer secret" {
t.Errorf("LedgerAPIKey: got %q", cfg.LedgerAPIKey)
}
}

// clearEnv unsets all environment variables read by loadConfig and registers a
// cleanup to restore them after the test.
func clearEnv(t *testing.T) {
t.Helper()
vars := []string{"DATABASE_URL", "LEDGER_BASE_URL", "LEDGER_API_KEY", "LEDGER_TIMEOUT"}
for _, v := range vars {
old, exists := os.LookupEnv(v)
os.Unsetenv(v) //nolint:errcheck
if exists {
t.Cleanup(func() { os.Setenv(v, old) }) //nolint:errcheck
}
}
}
Loading
Loading