diff --git a/.checkov.yml b/.checkov.yml index d272ec9c..f836a371 100644 --- a/.checkov.yml +++ b/.checkov.yml @@ -27,5 +27,10 @@ skip-check: # CKV_K8S_22: The frontend Nuxt/Nitro container requires a writable root # filesystem (writes tmp files at runtime). Scope the skip to the specific # file so the backend Deployment continues to be checked. +# +# validate.yaml files only emit {{- fail ... }} directives — they produce no +# Kubernetes resources and intentionally abort `helm template` when required +# values are absent. Checkov cannot render or check them meaningfully. skip-path: - frontend/charts/lfx-crowdfunding-frontend/templates/deployment.yaml + - backend/charts/lfx-crowdfunding-backend/templates/validate.yaml diff --git a/backend/Dockerfile.mentorship-sync b/backend/Dockerfile.mentorship-sync new file mode 100644 index 00000000..71871049 --- /dev/null +++ b/backend/Dockerfile.mentorship-sync @@ -0,0 +1,36 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT + +# ── Build stage ────────────────────────────────────────────────────────────── +FROM golang:1.25-alpine AS builder + +WORKDIR /build + +# Cache dependencies separately from source code +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags "-s -w" \ + -o /build/mentorship-sync ./cmd/mentorship-sync + +# ── Final stage ─────────────────────────────────────────────────────────────── +FROM alpine:3.20 + +# ca-certificates is required for outbound TLS (Auth0 JWKS, Stripe, Ledger, OTLP). +# Security: run as non-root user +RUN apk add --no-cache ca-certificates \ + && addgroup -S appgroup && adduser -S appuser -G appgroup -u 65532 + +WORKDIR /app +COPY --from=builder /build/mentorship-sync ./mentorship-sync +COPY cmd/mentorship-sync/testdata/ /app/testdata/ + +USER appuser + +# This is a CronJob — it runs to completion and exits. Health checks are not +# applicable; HEALTHCHECK NONE explicitly satisfies the Checkov CKV_DOCKER_2 check. +HEALTHCHECK NONE + +ENTRYPOINT ["/app/mentorship-sync"] diff --git a/backend/charts/lfx-crowdfunding-backend/ci/checkov-values.yaml b/backend/charts/lfx-crowdfunding-backend/ci/checkov-values.yaml new file mode 100644 index 00000000..9cb13d82 --- /dev/null +++ b/backend/charts/lfx-crowdfunding-backend/ci/checkov-values.yaml @@ -0,0 +1,14 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +--- +# Checkov CI values — used by Checkov's helm runner to render the chart for +# static analysis. These values satisfy required guards in validate.yaml so +# that `helm template` can complete without aborting. They are never used in +# a real deployment; ArgoCD overrides all values via environment-specific +# values files and ExternalSecrets. + +image: + tag: "ci-test" + +config: + STRIPE_RETURN_URL: "https://crowdfunding.linuxfoundation.org/payment/complete" diff --git a/backend/charts/lfx-crowdfunding-backend/templates/cronjob-mentorship-sync.yaml b/backend/charts/lfx-crowdfunding-backend/templates/cronjob-mentorship-sync.yaml new file mode 100644 index 00000000..b616d204 --- /dev/null +++ b/backend/charts/lfx-crowdfunding-backend/templates/cronjob-mentorship-sync.yaml @@ -0,0 +1,57 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +{{- if .Values.mentorshipSyncCronJob.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "lfx-v2-initiatives-service.fullname" . }}-mentorship-sync + namespace: {{ .Release.Namespace }} + labels: + {{- include "lfx-v2-initiatives-service.labels" . | nindent 4 }} + app.kubernetes.io/component: mentorship-sync +spec: + schedule: {{ .Values.mentorshipSyncCronJob.schedule | quote }} + concurrencyPolicy: {{ .Values.mentorshipSyncCronJob.concurrencyPolicy }} + successfulJobsHistoryLimit: {{ .Values.mentorshipSyncCronJob.successfulJobsHistoryLimit }} + failedJobsHistoryLimit: {{ .Values.mentorshipSyncCronJob.failedJobsHistoryLimit }} + jobTemplate: + spec: + template: + metadata: + labels: + app.kubernetes.io/component: mentorship-sync + spec: + restartPolicy: OnFailure + serviceAccountName: {{ include "lfx-v2-initiatives-service.serviceAccountName" . }} + automountServiceAccountToken: false + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 12 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 12 }} + containers: + - name: mentorship-sync + securityContext: + {{- toYaml .Values.securityContext | nindent 16 }} + image: "{{ .Values.mentorshipSyncCronJob.image.repository }}:{{ .Values.mentorshipSyncCronJob.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["/app/mentorship-sync"] + {{- if or .Values.env .Values.mentorshipSyncCronJob.fixtureFile }} + env: + {{- with .Values.env }} + {{- toYaml . | nindent 16 }} + {{- end }} + {{- if .Values.mentorshipSyncCronJob.fixtureFile }} + - name: MENTORSHIP_SYNC_FIXTURE_FILE + value: {{ .Values.mentorshipSyncCronJob.fixtureFile | quote }} + {{- end }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "lfx-v2-initiatives-service.fullname" . }}-config + - secretRef: + name: {{ .Values.secretName }} + resources: + {{- toYaml .Values.mentorshipSyncCronJob.resources | nindent 16 }} +{{- end }} diff --git a/backend/charts/lfx-crowdfunding-backend/templates/validate.yaml b/backend/charts/lfx-crowdfunding-backend/templates/validate.yaml index 811dbdac..b75c0d43 100644 --- a/backend/charts/lfx-crowdfunding-backend/templates/validate.yaml +++ b/backend/charts/lfx-crowdfunding-backend/templates/validate.yaml @@ -8,3 +8,7 @@ No Kubernetes resource is rendered; this file only produces errors. {{- if not .Values.config.STRIPE_RETURN_URL }} {{- fail (printf "\n\nvalues.yaml: config.STRIPE_RETURN_URL is required.\nSet it to the frontend URL Stripe redirects to after a 3DS challenge, e.g.:\n config:\n STRIPE_RETURN_URL: \"https://crowdfunding.linuxfoundation.org/payment/complete\"\nLeaving it empty causes 3DS redirect-based payment challenges to fail in production.") }} {{- end }} + +{{- if and .Values.mentorshipSyncCronJob.enabled (not .Values.mentorshipSyncCronJob.image.tag) }} +{{- fail (printf "\n\nvalues.yaml: mentorshipSyncCronJob.image.tag is required when mentorshipSyncCronJob.enabled is true.\nSet it to the mentorship-sync image tag to deploy, e.g.:\n mentorshipSyncCronJob:\n image:\n tag: \"abc1234\"\nFalling back to the main API image tag would pull from the wrong image repository.") }} +{{- end }} diff --git a/backend/charts/lfx-crowdfunding-backend/values.yaml b/backend/charts/lfx-crowdfunding-backend/values.yaml index 149eccc8..2c3dfe5e 100644 --- a/backend/charts/lfx-crowdfunding-backend/values.yaml +++ b/backend/charts/lfx-crowdfunding-backend/values.yaml @@ -128,6 +128,36 @@ secretName: lfx-crowdfunding-backend-secrets # Secret it did not create and the sync will fail silently. createPlaceholderSecret: false +# ── CronJob: mentorship-sync ────────────────────────────────────────────────── +# Runs daily to pull Mentorship program data from Snowflake and upsert into +# initiatives (initiative_type=mentorship) and initiative_beneficiaries. +# +# DEV: set fixtureFile to /app/testdata/programs.json — no Snowflake config needed. +# Staging/prod: leave fixtureFile empty. +# Non-secret config (ACCOUNT, USER, WAREHOUSE, DATABASE, ROLE) → env: in ArgoCD values. +# Secret (SNOWFLAKE_PRIVATE_KEY only) → external secret (lfx-crowdfunding-backend-secrets). +mentorshipSyncCronJob: + enabled: false + schedule: "0 2 * * *" # daily at 02:00 UTC + # fixtureFile: path inside the container to a JSON fixture file. + # When set, the CronJob skips Snowflake and reads from this file instead. + # Override to "/app/testdata/programs.json" in DEV. + fixtureFile: "" + image: + # Required — use the dedicated mentorship-sync image, not the main API image. + repository: ghcr.io/linuxfoundation/lfx-crowdfunding-mentorship-sync + tag: "" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + concurrencyPolicy: Forbid + # ── CronJob: ledger-stats-sync ──────────────────────────────────────────────── # Runs hourly to sync financial data from the ledger service into initiative_ledger_stats. cronJob: diff --git a/backend/cmd/mentorship-sync/helpers_test.go b/backend/cmd/mentorship-sync/helpers_test.go new file mode 100644 index 00000000..f4caf262 --- /dev/null +++ b/backend/cmd/mentorship-sync/helpers_test.go @@ -0,0 +1,14 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package main + +import "log/slog" + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(nopWriter{}, nil)) +} + +type nopWriter struct{} + +func (nopWriter) Write(p []byte) (int, error) { return len(p), nil } diff --git a/backend/cmd/mentorship-sync/main.go b/backend/cmd/mentorship-sync/main.go new file mode 100644 index 00000000..e3a77ee9 --- /dev/null +++ b/backend/cmd/mentorship-sync/main.go @@ -0,0 +1,147 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// mentorship-sync pulls Mentorship program data from Snowflake (or a JSON +// fixture in DEV) and upserts initiative_type=mentorship rows into CF Postgres. +// +// See backend/docs/rewrite/10-mentorship-sync-dev-testing.md for the DEV testing strategy. +// +// Usage: run as a K8s CronJob (daily schedule). 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/db" + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/snowflake" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + if err := run(logger); err != nil { + logger.Error("mentorship-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) + } + + pool, err := db.NewPool(ctx, db.PoolConfig{ + DSN: cfg.DatabaseURL, + MaxConns: 5, + MinConns: 1, + ConnMaxLifetime: cfg.DBConnMaxLifetime, + }) + if err != nil { + return fmt.Errorf("database pool: %w", err) + } + defer pool.Close() + + var src mentorshipSource + if cfg.FixtureFile != "" { + logger.Info("using fixture source", "path", cfg.FixtureFile) + src = snowflake.NewFixtureSource(cfg.FixtureFile) + } else { + client, err := snowflake.NewClient(snowflake.ClientConfig{ + Account: cfg.SnowflakeAccount, + User: cfg.SnowflakeUser, + Warehouse: cfg.SnowflakeWarehouse, + Database: cfg.SnowflakeDatabase, + Role: cfg.SnowflakeRole, + PrivateKey: cfg.SnowflakePrivateKey, + }) + if err != nil { + return fmt.Errorf("snowflake client: %w", err) + } + defer client.Close() + src = client + } + + repo := db.NewMentorshipRepository(pool) + syncer := newSyncer(repo, src, logger) + + logger.Info("mentorship-sync starting") + + result, err := syncer.Run(ctx) + if err != nil { + return fmt.Errorf("sync run: %w", err) + } + + logger.Info("mentorship-sync complete", + "duration", time.Since(start).String(), + "total", result.total, + "upserted", result.upserted, + "errors", result.errors, + ) + if result.errors > 0 { + return fmt.Errorf("sync completed with %d error(s) out of %d programs", result.errors, result.total) + } + return nil +} + +type config struct { + DatabaseURL string + DBConnMaxLifetime time.Duration + FixtureFile string + SnowflakeAccount string + SnowflakeUser string + SnowflakeWarehouse string + SnowflakeDatabase string + SnowflakeRole string + SnowflakePrivateKey string +} + +func loadConfig() (*config, error) { + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + return nil, fmt.Errorf("DATABASE_URL is required") + } + + cfg := &config{ + DatabaseURL: dbURL, + DBConnMaxLifetime: 5 * time.Minute, + FixtureFile: os.Getenv("MENTORSHIP_SYNC_FIXTURE_FILE"), + } + + if cfg.FixtureFile == "" { + cfg.SnowflakeAccount = os.Getenv("SNOWFLAKE_ACCOUNT") + if cfg.SnowflakeAccount == "" { + return nil, fmt.Errorf("SNOWFLAKE_ACCOUNT is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set") + } + cfg.SnowflakeUser = os.Getenv("SNOWFLAKE_USER") + if cfg.SnowflakeUser == "" { + return nil, fmt.Errorf("SNOWFLAKE_USER is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set") + } + cfg.SnowflakeWarehouse = os.Getenv("SNOWFLAKE_WAREHOUSE") + if cfg.SnowflakeWarehouse == "" { + return nil, fmt.Errorf("SNOWFLAKE_WAREHOUSE is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set") + } + cfg.SnowflakeDatabase = os.Getenv("SNOWFLAKE_DATABASE") + if cfg.SnowflakeDatabase == "" { + return nil, fmt.Errorf("SNOWFLAKE_DATABASE is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set") + } + cfg.SnowflakeRole = os.Getenv("SNOWFLAKE_ROLE") + if cfg.SnowflakeRole == "" { + return nil, fmt.Errorf("SNOWFLAKE_ROLE is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set") + } + cfg.SnowflakePrivateKey = os.Getenv("SNOWFLAKE_PRIVATE_KEY") + if cfg.SnowflakePrivateKey == "" { + return nil, fmt.Errorf("SNOWFLAKE_PRIVATE_KEY is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set") + } + } + + return cfg, nil +} diff --git a/backend/cmd/mentorship-sync/syncer.go b/backend/cmd/mentorship-sync/syncer.go new file mode 100644 index 00000000..8b875bd2 --- /dev/null +++ b/backend/cmd/mentorship-sync/syncer.go @@ -0,0 +1,91 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain" + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" +) + +const ( + // legacyStatusHide is the Jobspring legacy status value that maps to hidden. + legacyStatusHide = "hide" + normalizedStatusHidden = "hidden" +) + +// mentorshipSource is the interface Syncer needs from its data source. +// Defined at the point of consumption — both snowflake.Client and +// snowflake.FixtureSource satisfy this interface. +type mentorshipSource interface { + FetchPrograms(ctx context.Context) ([]models.MentorshipProgram, error) +} + +// syncResult carries the per-run counters logged on completion. +type syncResult struct { + total int + upserted int + errors int +} + +// Syncer orchestrates a single mentorship-sync run. +type Syncer struct { + repo domain.MentorshipRepository + source mentorshipSource + logger *slog.Logger +} + +// newSyncer returns a configured Syncer ready to call Run. +func newSyncer(repo domain.MentorshipRepository, source mentorshipSource, logger *slog.Logger) *Syncer { + return &Syncer{repo: repo, source: source, logger: logger} +} + +// Run executes the full sync algorithm: +// +// 1. Fetch all programs from source (Snowflake or fixture). +// 2. For each program: normalise status, upsert initiative row, upsert beneficiaries. +// 3. Log per-program errors without halting the run. +// 4. Return per-run counters for summary logging. +func (s *Syncer) Run(ctx context.Context) (syncResult, error) { + programs, err := s.source.FetchPrograms(ctx) + if err != nil { + return syncResult{}, fmt.Errorf("fetch programs: %w", err) + } + + result := syncResult{total: len(programs)} + + for _, p := range programs { + p.Status = strings.ToLower(p.Status) + // Jobspring legacy value — normalise to match CF Postgres expected value. + if p.Status == legacyStatusHide { + p.Status = normalizedStatusHidden + } + + p.OwnerLFUsername = strings.TrimSpace(p.OwnerLFUsername) + if p.OwnerLFUsername == "" { + s.logger.ErrorContext(ctx, "skipping program: missing owner_lf_username", + "jobspring_project_id", p.JobspringProjectID, + ) + result.errors++ + continue + } + + if _, err := s.repo.UpsertProgram(ctx, p); err != nil { + s.logger.ErrorContext(ctx, "upsert program failed", + "jobspring_project_id", p.JobspringProjectID, + "error", err, + ) + result.errors++ + continue + } + + result.upserted++ + } + + return result, nil +} diff --git a/backend/cmd/mentorship-sync/syncer_test.go b/backend/cmd/mentorship-sync/syncer_test.go new file mode 100644 index 00000000..76377b0f --- /dev/null +++ b/backend/cmd/mentorship-sync/syncer_test.go @@ -0,0 +1,403 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "errors" + "testing" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" +) + +// ─── Mock implementations ──────────────────────────────────────────────────── + +type mockMentorshipSource struct { + programs []models.MentorshipProgram + err error +} + +func (m *mockMentorshipSource) FetchPrograms(_ context.Context) ([]models.MentorshipProgram, error) { + return m.programs, m.err +} + +type mockMentorshipRepo struct { + upsertedPrograms []models.MentorshipProgram + programErr error + jobspringIDs []string + listErr error +} + +func (m *mockMentorshipRepo) UpsertProgram(_ context.Context, p models.MentorshipProgram) (string, error) { + if m.programErr != nil { + return "", m.programErr + } + m.upsertedPrograms = append(m.upsertedPrograms, p) + return "initiative-uuid-" + p.JobspringProjectID, nil +} + +func (m *mockMentorshipRepo) ListJobspringIDs(_ context.Context) ([]string, error) { + return m.jobspringIDs, m.listErr +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +func TestSyncer_Run_upsertsAllPrograms(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "Prog A", Status: "published", OwnerLFUsername: "alice", Beneficiaries: []models.MentorshipBeneficiary{{Name: "Alice", Email: "a@x.com"}}}, + {JobspringProjectID: "js-2", Name: "Prog B", Status: "pending", OwnerLFUsername: "bob", Beneficiaries: nil}, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + result, err := s.Run(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.total != 2 { + t.Errorf("total: got %d, want 2", result.total) + } + if result.upserted != 2 { + t.Errorf("upserted: got %d, want 2", result.upserted) + } + if result.errors != 0 { + t.Errorf("errors: got %d, want 0", result.errors) + } +} + +func TestSyncer_Run_normalisesStatusToLowercase(t *testing.T) { + t.Parallel() + + cases := []struct { + input string + want string + }{ + {"Published", "published"}, + {"Pending", "pending"}, + {"Hidden", "hidden"}, + {"Rejected", "rejected"}, + {"hide", "hidden"}, // Jobspring legacy value + } + + for _, tc := range cases { + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "Test", Status: tc.input, OwnerLFUsername: "testowner"}, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + if _, err := s.Run(context.Background()); err != nil { + t.Fatalf("input %q: unexpected error: %v", tc.input, err) + } + if len(repo.upsertedPrograms) != 1 { + t.Fatalf("input %q: expected 1 upsert, got %d", tc.input, len(repo.upsertedPrograms)) + } + if got := repo.upsertedPrograms[0].Status; got != tc.want { + t.Errorf("input %q: status got %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestSyncer_Run_beneficiariesUpsertedForInitiative(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + { + JobspringProjectID: "js-1", + Name: "Prog A", + Status: "published", + OwnerLFUsername: "alice", + Beneficiaries: []models.MentorshipBeneficiary{ + {Name: "Alice", Email: "alice@x.com"}, + }, + }, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + if _, err := s.Run(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(repo.upsertedPrograms) != 1 { + t.Fatalf("expected 1 upserted program, got %d", len(repo.upsertedPrograms)) + } + bens := repo.upsertedPrograms[0].Beneficiaries + if len(bens) != 1 || bens[0].Email != "alice@x.com" { + t.Errorf("beneficiaries: got %+v", bens) + } +} + +func TestSyncer_Run_nilBeneficiariesSkipsUpsert(t *testing.T) { + t.Parallel() + + // nil Beneficiaries = source did not provide beneficiary data (e.g. Snowflake client) + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "Prog A", Status: "published", OwnerLFUsername: "alice", Beneficiaries: nil}, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + if _, err := s.Run(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(repo.upsertedPrograms) != 1 { + t.Fatalf("expected 1 upserted program, got %d", len(repo.upsertedPrograms)) + } + if repo.upsertedPrograms[0].Beneficiaries != nil { + t.Error("Beneficiaries should be nil when not provided by source") + } +} + +func TestSyncer_Run_emptyBeneficiariesDeletesExisting(t *testing.T) { + t.Parallel() + + // non-nil empty slice = source explicitly returned zero beneficiaries + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "Prog A", Status: "published", OwnerLFUsername: "alice", Beneficiaries: []models.MentorshipBeneficiary{}}, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + if _, err := s.Run(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(repo.upsertedPrograms) != 1 { + t.Fatalf("expected 1 upserted program, got %d", len(repo.upsertedPrograms)) + } + bens := repo.upsertedPrograms[0].Beneficiaries + if bens == nil { + t.Fatal("Beneficiaries should be non-nil empty slice to signal delete-all") + } + if len(bens) != 0 { + t.Errorf("expected empty beneficiaries slice, got %+v", bens) + } +} + +func TestSyncer_Run_emptySourceReturnsZeroResult(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{programs: nil} + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + result, err := s.Run(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.total != 0 || result.upserted != 0 || result.errors != 0 { + t.Errorf("expected all-zero result, got %+v", result) + } +} + +func TestSyncer_Run_propagatesSourceError(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{err: errors.New("snowflake unavailable")} + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + _, err := s.Run(context.Background()) + + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSyncer_Run_countsUpsertErrorsWithoutHalting(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "Good", OwnerLFUsername: "alice"}, + {JobspringProjectID: "js-2", Name: "Bad", OwnerLFUsername: "bob"}, + }, + } + repo := &mockMentorshipRepo{} + + failRepo := &failOnSecondRepo{base: repo} + + s := newSyncer(failRepo, src, discardLogger()) + result, err := s.Run(context.Background()) + + if err != nil { + t.Fatalf("unexpected top-level error: %v", err) + } + if result.upserted != 1 { + t.Errorf("upserted: got %d, want 1", result.upserted) + } + if result.errors != 1 { + t.Errorf("errors: got %d, want 1", result.errors) + } +} + +// failOnSecondRepo fails on the second UpsertProgram call. +type failOnSecondRepo struct { + base *mockMentorshipRepo + calls int +} + +func (r *failOnSecondRepo) UpsertProgram(ctx context.Context, p models.MentorshipProgram) (string, error) { + r.calls++ + if r.calls == 2 { + return "", errors.New("db error") + } + return r.base.UpsertProgram(ctx, p) +} + +func (r *failOnSecondRepo) ListJobspringIDs(ctx context.Context) ([]string, error) { + return r.base.ListJobspringIDs(ctx) +} + +func TestSyncer_Run_skillsUpsertedForInitiative(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + { + JobspringProjectID: "js-1", + Name: "Prog A", + Status: "published", + OwnerLFUsername: "alice", + Skills: []string{"Golang", "Kubernetes"}, + }, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + if _, err := s.Run(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(repo.upsertedPrograms) != 1 { + t.Fatalf("expected 1 upserted program, got %d", len(repo.upsertedPrograms)) + } + skills := repo.upsertedPrograms[0].Skills + if len(skills) != 2 || skills[0] != "Golang" || skills[1] != "Kubernetes" { + t.Errorf("skills: got %v", skills) + } +} + +func TestSyncer_Run_nilSkillsSkipsUpsert(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "Prog A", Status: "published", OwnerLFUsername: "alice", Skills: nil}, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + if _, err := s.Run(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(repo.upsertedPrograms) != 1 { + t.Fatalf("expected 1 upserted program, got %d", len(repo.upsertedPrograms)) + } + if repo.upsertedPrograms[0].Skills != nil { + t.Errorf("Skills should be nil when not provided, got %v", repo.upsertedPrograms[0].Skills) + } +} + +func TestSyncer_Run_mentorsUpsertedForInitiative(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + { + JobspringProjectID: "js-1", + Name: "Prog A", + Status: "published", + OwnerLFUsername: "alice", + Mentors: []models.MentorshipMentor{ + {Name: "Jane Smith", Email: "jane@example.com", AvatarURL: "https://example.com/jane.png"}, + }, + }, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + if _, err := s.Run(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(repo.upsertedPrograms) != 1 { + t.Fatalf("expected 1 upserted program, got %d", len(repo.upsertedPrograms)) + } + mentors := repo.upsertedPrograms[0].Mentors + if len(mentors) != 1 || mentors[0].Email != "jane@example.com" { + t.Errorf("mentors: got %+v", mentors) + } +} + +func TestSyncer_Run_nilMentorsSkipsUpsert(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "Prog A", Status: "published", OwnerLFUsername: "alice", Mentors: nil}, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + if _, err := s.Run(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(repo.upsertedPrograms) != 1 { + t.Fatalf("expected 1 upserted program, got %d", len(repo.upsertedPrograms)) + } + if repo.upsertedPrograms[0].Mentors != nil { + t.Errorf("Mentors should be nil when not provided, got %v", repo.upsertedPrograms[0].Mentors) + } +} + +func TestSyncer_Run_skipsAndCountsErrorForEmptyOwnerLFUsername(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "No Owner", Status: "published", OwnerLFUsername: ""}, + {JobspringProjectID: "js-2", Name: "Has Owner", Status: "published", OwnerLFUsername: "alice"}, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + result, err := s.Run(context.Background()) + + if err != nil { + t.Fatalf("unexpected top-level error: %v", err) + } + if result.upserted != 1 { + t.Errorf("upserted: got %d, want 1", result.upserted) + } + if result.errors != 1 { + t.Errorf("errors: got %d, want 1 (missing OwnerLFUsername counts as error)", result.errors) + } + if len(repo.upsertedPrograms) != 1 || repo.upsertedPrograms[0].JobspringProjectID != "js-2" { + t.Errorf("only js-2 should be upserted, got %+v", repo.upsertedPrograms) + } +} diff --git a/backend/cmd/mentorship-sync/testdata/programs.json b/backend/cmd/mentorship-sync/testdata/programs.json new file mode 100644 index 00000000..bdd1d1f9 --- /dev/null +++ b/backend/cmd/mentorship-sync/testdata/programs.json @@ -0,0 +1,53 @@ +[ + { + "jobspring_project_id": "fe38c553-a066-44b0-8192-f5a5bee5074b", + "name": "CNCF - Example Program (Hidden)", + "status": "Hidden", + "description": "A sample hidden mentorship program for testing.", + "slug": "cncf-example-hidden", + "industry": "Cloud Computing, Containers", + "owner_lf_username": "cncf-admin", + "owner_email": "cncf-admin@example.com", + "owner_first_name": "Admin", + "owner_last_name": "CNCF", + "owner_avatar_url": "https://example.com/cncf-admin.png", + "skills": ["Golang", "Kubernetes", "kubeadm"], + "mentors": [ + {"name": "Jane Mentor", "email": "jane.mentor@example.com", "avatar_url": "https://example.com/jane.png"} + ], + "beneficiaries": [] + }, + { + "jobspring_project_id": "60410ceb-37ca-4ecf-9233-f907d1adf439", + "name": "Open Mainframe Project - Example Program (Published)", + "status": "Published", + "description": "An active mentorship program under the Open Mainframe Project.", + "slug": "omp-example-published", + "industry": "Mainframe, Enterprise Computing", + "owner_lf_username": "omp-admin", + "owner_email": "omp-admin@example.com", + "owner_first_name": "Admin", + "owner_last_name": "OMP", + "owner_avatar_url": "https://example.com/omp-admin.png", + "skills": ["COBOL", "z/OS"], + "mentors": [], + "beneficiaries": [ + {"name": "Alice Mentee", "email": "alice.mentee@example.com"}, + {"name": "Bob Mentee", "email": "bob.mentee@example.com"} + ] + }, + { + "jobspring_project_id": "0d6fd362-04a1-4086-a6e7-ec753ed4a60b", + "name": "Example Program (legacy hide status)", + "status": "hide", + "description": "", + "slug": "", + "industry": "", + "owner_lf_username": "meshery-admin", + "owner_email": "meshery-admin@example.com", + "owner_first_name": "Admin", + "owner_last_name": "Meshery", + "owner_avatar_url": "", + "beneficiaries": [] + } +] diff --git a/backend/db/migrations/002_jobspring_project_id_unique.up.sql b/backend/db/migrations/002_jobspring_project_id_unique.up.sql new file mode 100644 index 00000000..355ae4dd --- /dev/null +++ b/backend/db/migrations/002_jobspring_project_id_unique.up.sql @@ -0,0 +1,14 @@ +-- Copyright The Linux Foundation and each contributor to LFX. +-- SPDX-License-Identifier: MIT + +-- Add unique constraint on jobspring_project_id to enforce data integrity — +-- prevents duplicate Jobspring program IDs from being inserted into the +-- initiatives table during mentorship-sync runs. +BEGIN; + +SET LOCAL search_path TO crowdfunding, public; + +ALTER TABLE initiatives + ADD CONSTRAINT initiatives_jobspring_project_id_key UNIQUE (jobspring_project_id); + +COMMIT; diff --git a/backend/go.mod b/backend/go.mod index 91561d37..3dfb9c8d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -15,6 +15,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gosimple/slug v1.15.0 github.com/jackc/pgx/v5 v5.9.2 + github.com/snowflakedb/gosnowflake v1.19.1 github.com/stripe/stripe-go/v85 v85.2.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 go.opentelemetry.io/otel v1.43.0 @@ -23,9 +24,19 @@ require ( ) require ( + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/99designs/keyring v1.2.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/apache/arrow-go/v18 v18.4.0 // indirect + github.com/apache/thrift v0.23.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect @@ -40,24 +51,49 @@ require ( github.com/aws/smithy-go v1.25.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.7 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/gosimple/unidecode v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/asmfmt v1.3.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect + github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect + github.com/mtibben/percent v0.2.1 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect + golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 6b1a7516..a649ff7c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,25 @@ +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 h1:rTnT/Jrcm+figWlYz4Ixzt0SJVR2cMC8lvZcimipiEY= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0 h1:QkAcEIAKbNL4KoFr4SathZPhDhF4mVwpBMFlYjyAqy8= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 h1:+5VZ72z0Qan5Bog5C+ZkgSqUbeVUd9wgtHOrIKuc5b8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 h1:u/LLAOFgsMv7HmNL4Qufg58y+qElGOt5qv0z1mURkRY= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0/go.mod h1:2e8rMJtl2+2j+HXbTBwnyGpm5Nou7KhvSfxOq8JpTag= +github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1 h1:BWe8a+f/t+7KY7zH2mqygeUD0t8hNFXe08p1Pb3/jKE= +github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.4.0 h1:/RvkGqH517iY8bZKc4FD5/kkdwXJGjxf28JIXbJ/oB0= +github.com/apache/arrow-go/v18 v18.4.0/go.mod h1:Aawvwhj8x2jURIzD9Moy72cF0FyJXOpkYpdmGRHcw14= +github.com/apache/thrift v0.23.0 h1:wKR6YnefQSEnxpEfmgTPuJibNG4bF0p2TK34tHLWi3s= +github.com/apache/thrift v0.23.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= github.com/auth0/go-jwt-middleware/v2 v2.3.1 h1:lbDyWE9aLydb3zrank+Gufb9qGJN9u//7EbJK07pRrw= github.com/auth0/go-jwt-middleware/v2 v2.3.1/go.mod h1:mqVr0gdB5zuaFyQFWMJH/c/2hehNjbYUD4i8Dpyf+Hc= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -10,6 +32,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOq github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15 h1:7Zwtt/lP3KNRkeZre7soMELMGNoBrutx8nobg1jKWmo= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15/go.mod h1:436h2adoHb57yd+8W+gYPrrA9U/R/SuAuOO42Ushzhw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= @@ -40,11 +64,20 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.1.0 h1:ReYa/UBrRyQdant9B4fNHGoCNKw6qh6P0fsdGmZpR7c= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= +github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= +github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -52,10 +85,20 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -66,6 +109,8 @@ github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6 github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -74,15 +119,55 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/snowflakedb/gosnowflake v1.19.1 h1:NZMErtdZMu6kooehbONNQmu/W5BPsaX8hYdlBBEHgxs= +github.com/snowflakedb/gosnowflake v1.19.1/go.mod h1:9vGW6LYbUD1UqfjpuNN5a5vtha+u4n1AlsR1BqhHwPA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stripe/stripe-go/v85 v85.2.0 h1:LomL8ulv13+y+nIKtFP48Cn/pCIZmi4IBz5qjdDn+FY= github.com/stripe/stripe-go/v85 v85.2.0/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= @@ -107,14 +192,30 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= @@ -126,8 +227,13 @@ google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/domain/models/mentorship_sync.go b/backend/internal/domain/models/mentorship_sync.go new file mode 100644 index 00000000..b455bd55 --- /dev/null +++ b/backend/internal/domain/models/mentorship_sync.go @@ -0,0 +1,47 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package models + +// MentorshipProgram holds the Mentorship program fields that mentorship-sync +// reads from Snowflake and writes into CF Postgres. +// Field names mirror the ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS columns. +type MentorshipProgram struct { + JobspringProjectID string // upsert key — PROGRAM_ID from Snowflake + Name string // PROGRAM_NAME + Status string // PROGRAM_STATUS; normalised to lowercase in syncer + Description string // PROGRAM_DESCRIPTION + Slug string // program_slug + Industry string // PROGRAM_TECHNOLOGY (comma-separated list) + OwnerLFUsername string // OWNER_LF_USERNAME — LF SSO username of the program owner + OwnerEmail string // OWNER_EMAIL + OwnerFirstName string // OWNER_FIRST_NAME + OwnerLastName string // OWNER_LAST_NAME + OwnerAvatarURL string // OWNER_AVATAR_URL + + // Skills is nil when the source did not provide skills data. + // A nil slice means "do not touch skills"; non-nil (even empty) replaces all existing rows. + Skills []string + + // Mentors is nil when the source did not provide mentor data. + // A nil slice means "do not touch mentors"; non-nil (even empty) replaces all existing rows. + Mentors []MentorshipMentor + + // Beneficiaries is nil when the source did not provide beneficiary data. + // A nil slice means "do not touch beneficiaries"; an empty non-nil slice + // means "source returned zero beneficiaries — delete all existing rows". + Beneficiaries []MentorshipBeneficiary +} + +// MentorshipBeneficiary is one approved beneficiary (selected mentee) on a program. +type MentorshipBeneficiary struct { + Name string // derived: first_name + " " + last_name + Email string +} + +// MentorshipMentor is one mentor on a program. +type MentorshipMentor struct { + Name string // derived: first_name + " " + last_name + Email string + AvatarURL string +} diff --git a/backend/internal/domain/repository.go b/backend/internal/domain/repository.go index ef461461..363bad5f 100644 --- a/backend/internal/domain/repository.go +++ b/backend/internal/domain/repository.go @@ -143,3 +143,19 @@ type LedgerStatsRepository interface { // IDs in the slice. Missing IDs are simply absent from the map. GetUsersByIDs(ctx context.Context, userIDs []string) (map[string]models.User, error) } + +// MentorshipRepository defines persistence operations used by mentorship-sync. +// All methods are scoped to the batch upsert pattern of that CronJob. +type MentorshipRepository interface { + // UpsertProgram creates or updates the initiative row for a mentorship program, + // ensures the fixed mentee funding goal exists, and replaces beneficiaries, + // skills, and mentors — all in a single transaction. + // nil slice fields are skipped; non-nil (including empty) replace the existing rows. + // The upsert key is the program's UUID (initiatives.id). Returns the initiative UUID. + UpsertProgram(ctx context.Context, p models.MentorshipProgram) (string, error) + + // ListJobspringIDs returns the jobspring_project_id values for all existing + // mentorship initiatives. Used to detect programs that have been removed from + // Snowflake (not currently acted on, but useful for future reconciliation). + ListJobspringIDs(ctx context.Context) ([]string, error) +} diff --git a/backend/internal/handler/initiative_handler.go b/backend/internal/handler/initiative_handler.go index bfff83ab..dbbb63a1 100644 --- a/backend/internal/handler/initiative_handler.go +++ b/backend/internal/handler/initiative_handler.go @@ -50,10 +50,15 @@ func (h *InitiativeHandler) List(w http.ResponseWriter, r *http.Request) { } q := r.URL.Query() + status := models.InitiativeStatus(q.Get("status")) + if status == "" { + status = models.StatusPublished + } + filter := models.InitiativeFilter{ OwnerID: q.Get("owner_id"), InitiativeType: q.Get("type"), - Status: models.InitiativeStatus(q.Get("status")), + Status: status, Search: q.Get("search"), SortBy: strings.ToLower(q.Get("sort_by")), SortDir: strings.ToLower(q.Get("sort_dir")), @@ -88,7 +93,13 @@ func (h *InitiativeHandler) ListForUser(w http.ResponseWriter, r *http.Request) return } + status := models.InitiativeStatus(r.URL.Query().Get("status")) + if status == "" { + status = models.StatusPublished + } + initiatives, meta, err := h.svc.ListForUser(r.Context(), principal.Username, models.InitiativeFilter{ + Status: status, Limit: limit, Offset: offset, }) diff --git a/backend/internal/infrastructure/db/mentorship_repository.go b/backend/internal/infrastructure/db/mentorship_repository.go new file mode 100644 index 00000000..aa025557 --- /dev/null +++ b/backend/internal/infrastructure/db/mentorship_repository.go @@ -0,0 +1,225 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package db + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" +) + +const ( + initiativeTypeMentorship = "mentorship" + menteeGoalName = "mentee" + menteeGoalAmountCents = 600000 + menteeGoalSortOrder = 7 +) + +// MentorshipRepositoryImpl implements domain.MentorshipRepository against CF Postgres. +type MentorshipRepositoryImpl struct { + pool *pgxpool.Pool +} + +// NewMentorshipRepository returns a MentorshipRepositoryImpl backed by pool. +func NewMentorshipRepository(pool *pgxpool.Pool) *MentorshipRepositoryImpl { + return &MentorshipRepositoryImpl{pool: pool} +} + +// UpsertProgram inserts or updates the mentorship initiative row identified by +// jobspring_project_id, ensures the fixed mentee funding goal exists, and runs +// all writes in a single transaction. Returns the initiative UUID. +func (r *MentorshipRepositoryImpl) UpsertProgram(ctx context.Context, p models.MentorshipProgram) (string, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return "", fmt.Errorf("begin tx for program %q: %w", p.JobspringProjectID, err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + // 1. Resolve owner: upsert a stub user row by LF username, then fetch the UUID. + // Use COALESCE(NULLIF(...,''), col) so empty strings don't overwrite existing values. + const insertOwner = ` +INSERT INTO users (username, email, given_name, family_name, avatar_url, created_on, updated_on) +VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''), NOW(), NOW()) +ON CONFLICT (username) DO UPDATE SET + email = COALESCE(NULLIF(EXCLUDED.email, ''), users.email), + given_name = COALESCE(NULLIF(EXCLUDED.given_name, ''), users.given_name), + family_name = COALESCE(NULLIF(EXCLUDED.family_name, ''), users.family_name), + avatar_url = COALESCE(NULLIF(EXCLUDED.avatar_url, ''), users.avatar_url)` + const selectOwner = `SELECT id FROM users WHERE username = $1` + + if _, err := tx.Exec(ctx, insertOwner, p.OwnerLFUsername, p.OwnerEmail, p.OwnerFirstName, p.OwnerLastName, p.OwnerAvatarURL); err != nil { + return "", fmt.Errorf("insert owner user %q: %w", p.OwnerLFUsername, err) + } + var ownerID string + if err := tx.QueryRow(ctx, selectOwner, p.OwnerLFUsername).Scan(&ownerID); err != nil { + return "", fmt.Errorf("get owner user %q: %w", p.OwnerLFUsername, err) + } + + // 2. Upsert initiative row with all Snowflake-sourced fields. + // PROGRAM_ID is used directly as initiatives.id (PK) and jobspring_project_id, + // so ON CONFLICT (id) is always valid and needs no separate unique index. + const upsertInitiative = ` +INSERT INTO initiatives ( + id, + initiative_type, + jobspring_project_id, + owner_id, + name, + status, + description, + slug, + industry, + created_on, + updated_on +) VALUES ( + $1::uuid, + $2, + $1::text, + $3, + $4, + $5, + $6, + $7, + $8, + NOW(), + NOW() +) +ON CONFLICT (id) DO UPDATE SET + jobspring_project_id = EXCLUDED.jobspring_project_id, + owner_id = EXCLUDED.owner_id, + name = EXCLUDED.name, + status = EXCLUDED.status, + description = EXCLUDED.description, + slug = EXCLUDED.slug, + industry = EXCLUDED.industry, + updated_on = NOW() +RETURNING id` + + var id string + if err := tx.QueryRow(ctx, upsertInitiative, + p.JobspringProjectID, + initiativeTypeMentorship, + ownerID, + p.Name, + p.Status, + nullableMentorshipString(p.Description), + nullableMentorshipString(p.Slug), + nullableMentorshipString(p.Industry), + ).Scan(&id); err != nil { + return "", fmt.Errorf("upsert mentorship program %q: %w", p.JobspringProjectID, err) + } + + // 3. Ensure the fixed "mentee" funding goal row exists (idempotent). + const insertMenteeGoal = ` +INSERT INTO initiative_goals (id, initiative_id, name, amount_in_cents, sort_order) +VALUES (gen_random_uuid(), $1, $2, $3, $4) +ON CONFLICT (initiative_id, name) DO NOTHING` + if _, err := tx.Exec(ctx, insertMenteeGoal, id, menteeGoalName, menteeGoalAmountCents, menteeGoalSortOrder); err != nil { + return "", fmt.Errorf("insert mentee goal for %q: %w", p.JobspringProjectID, err) + } + + // 4. Replace beneficiaries (nil = skip; non-nil = replace all). + if p.Beneficiaries != nil { + if _, err := tx.Exec(ctx, + `DELETE FROM initiative_beneficiaries WHERE initiative_id = $1`, id, + ); err != nil { + return "", fmt.Errorf("delete beneficiaries for %q: %w", p.JobspringProjectID, err) + } + for _, b := range p.Beneficiaries { + if _, err := tx.Exec(ctx, + `INSERT INTO initiative_beneficiaries (id, initiative_id, name, email, created_on, updated_on) + VALUES ($1, $2, $3, $4, NOW(), NOW())`, + uuid.New().String(), id, b.Name, b.Email, + ); err != nil { + return "", fmt.Errorf("insert beneficiary %q for %q: %w", b.Email, p.JobspringProjectID, err) + } + } + } + + // 5. Replace skills (nil = skip; non-nil = replace all). + if p.Skills != nil { + if _, err := tx.Exec(ctx, + `DELETE FROM initiative_program_info_skills WHERE initiative_id = $1`, id, + ); err != nil { + return "", fmt.Errorf("delete skills for %q: %w", p.JobspringProjectID, err) + } + for _, skill := range p.Skills { + skill = strings.TrimSpace(skill) + if skill == "" { + continue + } + if _, err := tx.Exec(ctx, + `INSERT INTO initiative_program_info_skills (id, initiative_id, skill) + VALUES (gen_random_uuid(), $1, $2) + ON CONFLICT (initiative_id, skill) DO NOTHING`, + id, skill, + ); err != nil { + return "", fmt.Errorf("insert skill %q for %q: %w", skill, p.JobspringProjectID, err) + } + } + } + + // 6. Replace mentors (nil = skip; non-nil = replace all). + if p.Mentors != nil { + if _, err := tx.Exec(ctx, + `DELETE FROM initiative_mentors WHERE initiative_id = $1`, id, + ); err != nil { + return "", fmt.Errorf("delete mentors for %q: %w", p.JobspringProjectID, err) + } + for _, m := range p.Mentors { + if _, err := tx.Exec(ctx, + `INSERT INTO initiative_mentors (id, initiative_id, name, email, avatar_url) + VALUES (gen_random_uuid(), $1, $2, $3, $4)`, + id, + nullableMentorshipString(m.Name), + nullableMentorshipString(m.Email), + nullableMentorshipString(m.AvatarURL), + ); err != nil { + return "", fmt.Errorf("insert mentor %q for %q: %w", m.Email, p.JobspringProjectID, err) + } + } + } + + if err := tx.Commit(ctx); err != nil { + return "", fmt.Errorf("commit program tx for %q: %w", p.JobspringProjectID, err) + } + return id, nil +} + +// ListJobspringIDs returns the jobspring_project_id for all existing mentorship initiatives. +func (r *MentorshipRepositoryImpl) ListJobspringIDs(ctx context.Context) ([]string, error) { + const q = ` +SELECT jobspring_project_id +FROM initiatives +WHERE initiative_type = $1 + AND jobspring_project_id IS NOT NULL +` + rows, err := r.pool.Query(ctx, q, initiativeTypeMentorship) + if err != nil { + return nil, fmt.Errorf("list jobspring IDs: %w", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan jobspring ID: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// nullableMentorshipString converts an empty string to nil (stored as NULL in Postgres). +func nullableMentorshipString(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/backend/internal/infrastructure/db/mentorship_repository_test.go b/backend/internal/infrastructure/db/mentorship_repository_test.go new file mode 100644 index 00000000..e5d6f2ac --- /dev/null +++ b/backend/internal/infrastructure/db/mentorship_repository_test.go @@ -0,0 +1,11 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package db + +import ( + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain" +) + +// compile-time check that MentorshipRepositoryImpl satisfies domain.MentorshipRepository. +var _ domain.MentorshipRepository = (*MentorshipRepositoryImpl)(nil) diff --git a/backend/internal/infrastructure/snowflake/client.go b/backend/internal/infrastructure/snowflake/client.go new file mode 100644 index 00000000..08fdd929 --- /dev/null +++ b/backend/internal/infrastructure/snowflake/client.go @@ -0,0 +1,268 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package snowflake + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "database/sql" + "encoding/json" + "encoding/pem" + "fmt" + "strings" + + "github.com/snowflakedb/gosnowflake" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" +) + +var snowflakeTracer = otel.Tracer("snowflake-client") + +const fetchProgramsQuery = ` +SELECT + p.PROGRAM_ID, + p.PROGRAM_NAME, + p.PROGRAM_STATUS, + p.PROGRAM_DESCRIPTION, + p.PROGRAM_SLUG, + COALESCE(p.OWNER_LF_USERNAME, '') AS OWNER_LF_USERNAME, + COALESCE(p.OWNER_EMAIL, '') AS OWNER_EMAIL, + COALESCE(p.OWNER_FIRST_NAME, '') AS OWNER_FIRST_NAME, + COALESCE(p.OWNER_LAST_NAME, '') AS OWNER_LAST_NAME, + COALESCE(p.OWNER_AVATAR_URL, '') AS OWNER_AVATAR_URL, + p.PROGRAM_TECHNOLOGY, + p.SELECTED_MENTEES, + p.MENTORS, + p.PROGRAM_SKILLS +FROM ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS p +WHERE p.PROGRAM_ID IS NOT NULL + AND p.UPDATED_AT >= DATEADD(DAY, -30, CURRENT_TIMESTAMP()) +` + +// snowflakePerson is the shared JSON structure for entries in the +// SELECTED_MENTEES and mentors VARIANT array columns. +type snowflakePerson struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +// ClientConfig holds credentials for connecting to Snowflake via key-pair auth. +type ClientConfig struct { + Account string + User string + Warehouse string + Database string + Role string + PrivateKey string // PEM-encoded PKCS8 private key +} + +// Client queries Snowflake for Mentorship program data. +type Client struct { + db *sql.DB +} + +// NewClient opens a Snowflake connection using key-pair authentication. +func NewClient(cfg ClientConfig) (*Client, error) { + // Parse the PEM-encoded private key. + block, rest := pem.Decode([]byte(cfg.PrivateKey)) + if block == nil { + return nil, fmt.Errorf("failed to parse PEM private key: no PEM block found") + } + if len(rest) > 0 { + return nil, fmt.Errorf("failed to parse PEM private key: extra data after PEM block") + } + + // Parse the PKCS8 DER-encoded private key. + privateKeyInterface, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS8 private key: %w", err) + } + + privateKey, ok := privateKeyInterface.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key is not RSA") + } + + // Build Snowflake connection config. + sfCfg := &gosnowflake.Config{ + Account: cfg.Account, + User: cfg.User, + Warehouse: cfg.Warehouse, + Database: cfg.Database, + Role: cfg.Role, + Authenticator: gosnowflake.AuthTypeJwt, + PrivateKey: privateKey, + } + + // Construct DSN. + dsn, err := gosnowflake.DSN(sfCfg) + if err != nil { + return nil, fmt.Errorf("failed to construct DSN: %w", err) + } + + // Open database connection. + db, err := sql.Open("snowflake", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open snowflake connection: %w", err) + } + + return &Client{db: db}, nil +} + +// NewClientFromDB constructs a Client from an existing *sql.DB. +// Used in tests to inject a mock driver. +func NewClientFromDB(db *sql.DB) *Client { + return &Client{db: db} +} + +// Close releases the underlying database connection pool. +func (c *Client) Close() error { + return c.db.Close() +} + +// FetchPrograms runs the Snowflake query and returns all Mentorship programs. +// VARIANT columns (SELECTED_MENTEES, mentors, program_skills) are parsed from +// their JSON string representation. A NULL VARIANT column means the field is +// absent and the corresponding slice on the model is left nil (skip upsert). +func (c *Client) FetchPrograms(ctx context.Context) (_ []models.MentorshipProgram, retErr error) { + ctx, span := snowflakeTracer.Start(ctx, "FetchPrograms") + defer func() { + if retErr != nil { + span.RecordError(retErr) + span.SetStatus(codes.Error, retErr.Error()) + } + span.End() + }() + + rows, err := c.db.QueryContext(ctx, fetchProgramsQuery) + if err != nil { + return nil, fmt.Errorf("query mentorship programs: %w", err) + } + defer rows.Close() + + var programs []models.MentorshipProgram + for rows.Next() { + var ( + programID string + name string + status string + description sql.NullString + slug sql.NullString + ownerLFUsername string + ownerEmail string + ownerFirstName string + ownerLastName string + ownerAvatarURL string + industry sql.NullString + menteesJSON sql.NullString + mentorsJSON sql.NullString + skillsJSON sql.NullString + ) + if err := rows.Scan( + &programID, + &name, + &status, + &description, + &slug, + &ownerLFUsername, + &ownerEmail, + &ownerFirstName, + &ownerLastName, + &ownerAvatarURL, + &industry, + &menteesJSON, + &mentorsJSON, + &skillsJSON, + ); err != nil { + return nil, fmt.Errorf("scan mentorship program row: %w", err) + } + + p := models.MentorshipProgram{ + JobspringProjectID: programID, + Name: name, + Status: status, + Description: description.String, + Slug: slug.String, + OwnerLFUsername: ownerLFUsername, + OwnerEmail: ownerEmail, + OwnerFirstName: ownerFirstName, + OwnerLastName: ownerLastName, + OwnerAvatarURL: ownerAvatarURL, + Industry: industry.String, + } + + // Parse VARIANT JSON columns. A NULL column → nil slice (skip upsert). + if menteesJSON.Valid && menteesJSON.String != "" { + var err error + if p.Beneficiaries, err = parseMentees(menteesJSON.String); err != nil { + return nil, fmt.Errorf("parse SELECTED_MENTEES for %q: %w", programID, err) + } + } + if mentorsJSON.Valid && mentorsJSON.String != "" { + var err error + if p.Mentors, err = parseMentors(mentorsJSON.String); err != nil { + return nil, fmt.Errorf("parse mentors for %q: %w", programID, err) + } + } + if skillsJSON.Valid && skillsJSON.String != "" { + var err error + if p.Skills, err = parseSkills(skillsJSON.String); err != nil { + return nil, fmt.Errorf("parse program_skills for %q: %w", programID, err) + } + } + + programs = append(programs, p) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate mentorship program rows: %w", err) + } + return programs, nil +} + +// parseMentees decodes the SELECTED_MENTEES VARIANT JSON array into beneficiary models. +func parseMentees(js string) ([]models.MentorshipBeneficiary, error) { + var raw []snowflakePerson + if err := json.Unmarshal([]byte(js), &raw); err != nil { + return nil, err + } + out := make([]models.MentorshipBeneficiary, 0, len(raw)) + for _, p := range raw { + out = append(out, models.MentorshipBeneficiary{ + Name: strings.TrimSpace(p.FirstName + " " + p.LastName), + Email: p.Email, + }) + } + return out, nil +} + +// parseMentors decodes the mentors VARIANT JSON array into mentor models. +func parseMentors(js string) ([]models.MentorshipMentor, error) { + var raw []snowflakePerson + if err := json.Unmarshal([]byte(js), &raw); err != nil { + return nil, err + } + out := make([]models.MentorshipMentor, 0, len(raw)) + for _, p := range raw { + out = append(out, models.MentorshipMentor{ + Name: strings.TrimSpace(p.FirstName + " " + p.LastName), + Email: p.Email, + AvatarURL: p.AvatarURL, + }) + } + return out, nil +} + +// parseSkills decodes the program_skills VARIANT JSON array into a string slice. +func parseSkills(js string) ([]string, error) { + var raw []string + if err := json.Unmarshal([]byte(js), &raw); err != nil { + return nil, err + } + return raw, nil +} diff --git a/backend/internal/infrastructure/snowflake/client_test.go b/backend/internal/infrastructure/snowflake/client_test.go new file mode 100644 index 00000000..bac6610f --- /dev/null +++ b/backend/internal/infrastructure/snowflake/client_test.go @@ -0,0 +1,202 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package snowflake_test + +import ( + "context" + "database/sql" + "database/sql/driver" + "io" + "testing" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/snowflake" +) + +// mockSnowflakeDriver records the last query executed against it. +type mockSnowflakeDriver struct { + lastQuery string + rows [][]driver.Value +} + +func (d *mockSnowflakeDriver) Open(_ string) (driver.Conn, error) { + return &mockConn{driver: d}, nil +} + +type mockConn struct{ driver *mockSnowflakeDriver } + +func (c *mockConn) Prepare(query string) (driver.Stmt, error) { + c.driver.lastQuery = query + return &mockStmt{driver: c.driver}, nil +} +func (c *mockConn) Close() error { return nil } +func (c *mockConn) Begin() (driver.Tx, error) { return nil, nil } + +type mockStmt struct{ driver *mockSnowflakeDriver } + +func (s *mockStmt) Close() error { return nil } +func (s *mockStmt) NumInput() int { return 0 } +func (s *mockStmt) Exec(_ []driver.Value) (driver.Result, error) { return nil, nil } +func (s *mockStmt) Query(_ []driver.Value) (driver.Rows, error) { + return &mockRows{rows: s.driver.rows, pos: 0}, nil +} + +type mockRows struct { + rows [][]driver.Value + pos int +} + +func (r *mockRows) Columns() []string { + return []string{ + "PROGRAM_ID", + "PROGRAM_NAME", + "PROGRAM_STATUS", + "PROGRAM_DESCRIPTION", + "program_slug", + "OWNER_LF_USERNAME", + "OWNER_EMAIL", + "OWNER_FIRST_NAME", + "OWNER_LAST_NAME", + "OWNER_AVATAR_URL", + "PROGRAM_TECHNOLOGY", + "SELECTED_MENTEES", + "mentors", + "program_skills", + } +} +func (r *mockRows) Close() error { return nil } +func (r *mockRows) Next(dest []driver.Value) error { + if r.pos >= len(r.rows) { + return io.EOF + } + copy(dest, r.rows[r.pos]) + r.pos++ + return nil +} + +func TestClient_FetchPrograms_queriesExpectedSQL(t *testing.T) { + t.Parallel() + + menteesJSON := `[{"first_name":"Alice","last_name":"Mentee","email":"alice@example.com","avatar_url":""}]` + mentorsJSON := `[{"first_name":"Jane","last_name":"Smith","email":"jane@example.com","avatar_url":"https://example.com/jane.png"}]` + skillsJSON := `["Golang","Kubernetes"]` + + mock := &mockSnowflakeDriver{ + rows: [][]driver.Value{ + { + "fe38c553-a066-44b0-8192-f5a5bee5074b", + "Linux Kernel Mentorship", + "Published", + "A great program", + "linux-kernel-mentorship", + "cncf-admin", + "cncf-admin@example.com", + "Admin", + "CNCF", + "https://example.com/cncf-admin.png", + "Cloud Native", + menteesJSON, + mentorsJSON, + skillsJSON, + }, + }, + } + driverName := "snowflake_mock_" + t.Name() + sql.Register(driverName, mock) + + db, err := sql.Open(driverName, "") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + + client := snowflake.NewClientFromDB(db) + programs, err := client.FetchPrograms(context.Background()) + if err != nil { + t.Fatalf("FetchPrograms: %v", err) + } + + // Assert SQL contains the expected table. + wantTable := "ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS" + if mock.lastQuery == "" { + t.Fatal("no query was executed") + } + if !containsSubstr(mock.lastQuery, wantTable) { + t.Errorf("query does not reference %q\ngot: %s", wantTable, mock.lastQuery) + } + + // Assert field mapping. + if len(programs) != 1 { + t.Fatalf("got %d programs, want 1", len(programs)) + } + p := programs[0] + if p.JobspringProjectID != "fe38c553-a066-44b0-8192-f5a5bee5074b" { + t.Errorf("JobspringProjectID: got %q", p.JobspringProjectID) + } + if p.Name != "Linux Kernel Mentorship" { + t.Errorf("Name: got %q, want Linux Kernel Mentorship", p.Name) + } + if p.Status != "Published" { + t.Errorf("Status: got %q, want Published", p.Status) + } + if p.OwnerLFUsername != "cncf-admin" { + t.Errorf("OwnerLFUsername: got %q, want cncf-admin", p.OwnerLFUsername) + } + if p.OwnerEmail != "cncf-admin@example.com" { + t.Errorf("OwnerEmail: got %q, want cncf-admin@example.com", p.OwnerEmail) + } + if p.OwnerFirstName != "Admin" { + t.Errorf("OwnerFirstName: got %q, want Admin", p.OwnerFirstName) + } + if p.OwnerLastName != "CNCF" { + t.Errorf("OwnerLastName: got %q, want CNCF", p.OwnerLastName) + } + if p.OwnerAvatarURL != "https://example.com/cncf-admin.png" { + t.Errorf("OwnerAvatarURL: got %q, want https://example.com/cncf-admin.png", p.OwnerAvatarURL) + } + if p.Description != "A great program" { + t.Errorf("Description: got %q, want A great program", p.Description) + } + if p.Slug != "linux-kernel-mentorship" { + t.Errorf("Slug: got %q, want linux-kernel-mentorship", p.Slug) + } + if p.Industry != "Cloud Native" { + t.Errorf("Industry: got %q, want Cloud Native", p.Industry) + } + + // Beneficiaries (SELECTED_MENTEES). + if len(p.Beneficiaries) != 1 || p.Beneficiaries[0].Email != "alice@example.com" { + t.Errorf("Beneficiaries: got %+v", p.Beneficiaries) + } + if p.Beneficiaries[0].Name != "Alice Mentee" { + t.Errorf("Beneficiaries[0].Name: got %q, want Alice Mentee", p.Beneficiaries[0].Name) + } + + // Mentors. + if len(p.Mentors) != 1 || p.Mentors[0].Email != "jane@example.com" { + t.Errorf("Mentors: got %+v", p.Mentors) + } + if p.Mentors[0].Name != "Jane Smith" { + t.Errorf("Mentors[0].Name: got %q, want Jane Smith", p.Mentors[0].Name) + } + if p.Mentors[0].AvatarURL != "https://example.com/jane.png" { + t.Errorf("Mentors[0].AvatarURL: got %q", p.Mentors[0].AvatarURL) + } + + // Skills. + if len(p.Skills) != 2 || p.Skills[0] != "Golang" || p.Skills[1] != "Kubernetes" { + t.Errorf("Skills: got %v", p.Skills) + } +} + +func containsSubstr(s, substr string) bool { + if len(substr) > len(s) { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/backend/internal/infrastructure/snowflake/fixture_source.go b/backend/internal/infrastructure/snowflake/fixture_source.go new file mode 100644 index 00000000..6fe700b0 --- /dev/null +++ b/backend/internal/infrastructure/snowflake/fixture_source.go @@ -0,0 +1,105 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package snowflake + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" +) + +// fixtureProgram mirrors MentorshipProgram with JSON tags for fixture file decoding. +type fixtureProgram struct { + JobspringProjectID string `json:"jobspring_project_id"` + Name string `json:"name"` + Status string `json:"status"` + Description string `json:"description"` + Slug string `json:"slug"` + Industry string `json:"industry"` + OwnerLFUsername string `json:"owner_lf_username"` + OwnerEmail string `json:"owner_email"` + OwnerFirstName string `json:"owner_first_name"` + OwnerLastName string `json:"owner_last_name"` + OwnerAvatarURL string `json:"owner_avatar_url"` + Skills []string `json:"skills"` + Mentors []fixtureMentor `json:"mentors"` + Beneficiaries []fixtureBeneficiary `json:"beneficiaries"` +} + +type fixtureBeneficiary struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type fixtureMentor struct { + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +// FixtureSource implements mentorshipSource by reading a JSON file from disk. +// Used in DEV and local development — requires no Snowflake credentials. +type FixtureSource struct { + path string +} + +// NewFixtureSource returns a FixtureSource that reads programs from the given path. +func NewFixtureSource(path string) *FixtureSource { + return &FixtureSource{path: path} +} + +// FetchPrograms reads the fixture file and returns its contents as domain models. +func (f *FixtureSource) FetchPrograms(_ context.Context) ([]models.MentorshipProgram, error) { + data, err := os.ReadFile(f.path) + if err != nil { + return nil, fmt.Errorf("read fixture file %q: %w", f.path, err) + } + + var raw []fixtureProgram + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse fixture file %q: %w", f.path, err) + } + + programs := make([]models.MentorshipProgram, len(raw)) + for i, r := range raw { + // Preserve nil when the JSON field was absent — nil means "source did not + // provide data" and causes the syncer to skip the corresponding upsert. + var beneficiaries []models.MentorshipBeneficiary + if r.Beneficiaries != nil { + beneficiaries = make([]models.MentorshipBeneficiary, len(r.Beneficiaries)) + for j, b := range r.Beneficiaries { + beneficiaries[j] = models.MentorshipBeneficiary{Name: b.Name, Email: b.Email} + } + } + + var mentors []models.MentorshipMentor + if r.Mentors != nil { + mentors = make([]models.MentorshipMentor, len(r.Mentors)) + for j, m := range r.Mentors { + mentors[j] = models.MentorshipMentor{Name: m.Name, Email: m.Email, AvatarURL: m.AvatarURL} + } + } + + programs[i] = models.MentorshipProgram{ + JobspringProjectID: r.JobspringProjectID, + Name: r.Name, + Status: r.Status, + Description: r.Description, + Slug: r.Slug, + Industry: r.Industry, + OwnerLFUsername: r.OwnerLFUsername, + OwnerEmail: r.OwnerEmail, + OwnerFirstName: r.OwnerFirstName, + OwnerLastName: r.OwnerLastName, + OwnerAvatarURL: r.OwnerAvatarURL, + Skills: r.Skills, + Mentors: mentors, + Beneficiaries: beneficiaries, + } + } + return programs, nil +} diff --git a/backend/internal/infrastructure/snowflake/fixture_source_test.go b/backend/internal/infrastructure/snowflake/fixture_source_test.go new file mode 100644 index 00000000..e0b7a6b9 --- /dev/null +++ b/backend/internal/infrastructure/snowflake/fixture_source_test.go @@ -0,0 +1,154 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package snowflake_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/snowflake" +) + +func TestFixtureSource_FetchPrograms_readsAllFields(t *testing.T) { + t.Parallel() + + fixture := `[ + { + "jobspring_project_id": "fe38c553-a066-44b0-8192-f5a5bee5074b", + "name": "Linux Kernel Mentorship", + "status": "Published", + "description": "Learn kernel development", + "slug": "linux-kernel-mentorship", + "industry": "Open Source, Systems", + "owner_lf_username": "cncf-admin", + "skills": ["C", "Linux"], + "mentors": [ + {"name": "Jane Smith", "email": "jane@example.com", "avatar_url": "https://example.com/jane.png"} + ], + "beneficiaries": [ + {"name": "Alice Mentee", "email": "alice@example.com"} + ] + }, + { + "jobspring_project_id": "60410ceb-37ca-4ecf-9233-f907d1adf439", + "name": "COBOL Programming Course", + "status": "Published", + "owner_lf_username": "omp-admin", + "beneficiaries": [] + }, + { + "jobspring_project_id": "92df3acf-9c1e-4a27-b9a4-cb5ed4293435", + "name": "Hidden Program", + "status": "Hidden", + "owner_lf_username": "omp-admin", + "beneficiaries": [] + } + ]` + + path := filepath.Join(t.TempDir(), "programs.json") + if err := os.WriteFile(path, []byte(fixture), 0600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + src := snowflake.NewFixtureSource(path) + programs, err := src.FetchPrograms(context.Background()) + if err != nil { + t.Fatalf("FetchPrograms: %v", err) + } + + if len(programs) != 3 { + t.Fatalf("got %d programs, want 3", len(programs)) + } + + p := programs[0] + if p.JobspringProjectID != "fe38c553-a066-44b0-8192-f5a5bee5074b" { + t.Errorf("JobspringProjectID: got %q", p.JobspringProjectID) + } + if p.Name != "Linux Kernel Mentorship" { + t.Errorf("Name: got %q, want Linux Kernel Mentorship", p.Name) + } + if p.Status != "Published" { + t.Errorf("Status: got %q, want Published", p.Status) + } + if p.Description != "Learn kernel development" { + t.Errorf("Description: got %q, want Learn kernel development", p.Description) + } + if p.Slug != "linux-kernel-mentorship" { + t.Errorf("Slug: got %q, want linux-kernel-mentorship", p.Slug) + } + if p.Industry != "Open Source, Systems" { + t.Errorf("Industry: got %q, want Open Source, Systems", p.Industry) + } + if p.OwnerLFUsername != "cncf-admin" { + t.Errorf("OwnerLFUsername: got %q, want cncf-admin", p.OwnerLFUsername) + } + + // Skills. + if len(p.Skills) != 2 || p.Skills[0] != "C" || p.Skills[1] != "Linux" { + t.Errorf("Skills: got %v", p.Skills) + } + + // Mentors. + if len(p.Mentors) != 1 || p.Mentors[0].Email != "jane@example.com" { + t.Errorf("Mentors: got %+v", p.Mentors) + } + if p.Mentors[0].Name != "Jane Smith" { + t.Errorf("Mentors[0].Name: got %q, want Jane Smith", p.Mentors[0].Name) + } + if p.Mentors[0].AvatarURL != "https://example.com/jane.png" { + t.Errorf("Mentors[0].AvatarURL: got %q", p.Mentors[0].AvatarURL) + } + + // Beneficiaries. + if len(p.Beneficiaries) != 1 { + t.Fatalf("Beneficiaries: got %d, want 1", len(p.Beneficiaries)) + } + if p.Beneficiaries[0].Name != "Alice Mentee" { + t.Errorf("Beneficiaries[0].Name: got %q, want Alice Mentee", p.Beneficiaries[0].Name) + } + if p.Beneficiaries[0].Email != "alice@example.com" { + t.Errorf("Beneficiaries[0].Email: got %q, want alice@example.com", p.Beneficiaries[0].Email) + } +} + +func TestFixtureSource_FetchPrograms_absentFieldsProduceNilSlices(t *testing.T) { + t.Parallel() + + // When "beneficiaries", "skills", "mentors" keys are absent, all must be + // nil so the syncer skips their respective upserts. + fixture := `[{"jobspring_project_id": "abc", "name": "Test", "status": "Published"}]` + + path := filepath.Join(t.TempDir(), "programs.json") + if err := os.WriteFile(path, []byte(fixture), 0600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + src := snowflake.NewFixtureSource(path) + programs, err := src.FetchPrograms(context.Background()) + if err != nil { + t.Fatalf("FetchPrograms: %v", err) + } + p := programs[0] + if p.Beneficiaries != nil { + t.Errorf("expected nil Beneficiaries when field absent, got %v", p.Beneficiaries) + } + if p.Skills != nil { + t.Errorf("expected nil Skills when field absent, got %v", p.Skills) + } + if p.Mentors != nil { + t.Errorf("expected nil Mentors when field absent, got %v", p.Mentors) + } +} + +func TestFixtureSource_FetchPrograms_missingFileReturnsError(t *testing.T) { + t.Parallel() + + src := snowflake.NewFixtureSource("/nonexistent/programs.json") + _, err := src.FetchPrograms(context.Background()) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} diff --git a/docs/superpowers/plans/2026-06-12-mentorship-sync.md b/docs/superpowers/plans/2026-06-12-mentorship-sync.md new file mode 100644 index 00000000..17324ca1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-mentorship-sync.md @@ -0,0 +1,1638 @@ + + + +# Mentorship Sync CronJob Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the `mentorship-sync` K8s CronJob that pulls Mentorship program data from Snowflake (or a JSON fixture in DEV) and upserts `initiative_type = mentorship` rows into CF Postgres. + +**Architecture:** A `mentorshipSource` interface is defined in `cmd/mentorship-sync/syncer.go` and satisfied by two implementations in `internal/infrastructure/snowflake/`: `Client` (real Snowflake, used in staging/prod) and `FixtureSource` (JSON file, used in DEV and local dev). `cmd/mentorship-sync/main.go` wires the correct source based on `MENTORSHIP_SYNC_FIXTURE_FILE`. The sync algorithm in `syncer.go` runs identically regardless of source. A `MentorshipRepository` in `internal/infrastructure/db/` owns all DB writes. This mirrors the `ledger-stats-sync` pattern exactly. + +**Tech Stack:** Go 1.25, `pgx/v5` (DB), `gosnowflake` driver (Snowflake), `encoding/json` (fixture), `log/slog` (structured logging), `go.opentelemetry.io/otel` (tracing on Snowflake client). + +--- + +## File Map + +| File | Action | Purpose | +|---|---|---| +| `internal/domain/models/mentorship_sync.go` | **Create** | `MentorshipProgram`, `MentorshipBeneficiary` domain types | +| `internal/domain/repository.go` | **Modify** | Add `MentorshipRepository` interface | +| `internal/infrastructure/snowflake/client.go` | **Create** | Real Snowflake client; `FetchPrograms` runs the SQL query | +| `internal/infrastructure/snowflake/client_test.go` | **Create** | Unit test: asserts SQL query string + struct field mapping via mock driver | +| `internal/infrastructure/snowflake/fixture_source.go` | **Create** | `FixtureSource` reads `programs.json` and returns `[]MentorshipProgram` | +| `internal/infrastructure/snowflake/fixture_source_test.go` | **Create** | Unit test: reads a small embedded fixture and asserts all fields | +| `internal/infrastructure/db/mentorship_repository.go` | **Create** | `MentorshipRepository` impl: `UpsertProgram`, `ListJobspringIDs`, `UpsertBeneficiaries` | +| `internal/infrastructure/db/mentorship_repository_test.go` | **Create** | Unit tests for upsert logic with mock pgx rows | +| `cmd/mentorship-sync/main.go` | **Create** | Entry point; wires source from env var, runs syncer, exits | +| `cmd/mentorship-sync/syncer.go` | **Create** | `mentorshipSource` interface + `Syncer.Run` algorithm | +| `cmd/mentorship-sync/syncer_test.go` | **Create** | Table-driven tests for syncer algorithm using mock source + mock repo | +| `cmd/mentorship-sync/testdata/programs.json` | **Create** | Fixture: 3 records (active+beneficiaries, pending, hidden) | +| `Dockerfile.mentorship-sync` | **Create** | Multi-stage build producing minimal container image | +| `lfx-v2-argocd` `values/dev/lfx-crowdfunding-mentorship-sync.yaml` | **Create** | DEV: `MENTORSHIP_SYNC_FIXTURE_FILE` set, no Snowflake creds | +| `lfx-v2-argocd` `values/staging/lfx-crowdfunding-mentorship-sync.yaml` | **Create** | Staging: Snowflake creds from K8s Secret | +| `lfx-v2-argocd` `values/prod/lfx-crowdfunding-mentorship-sync.yaml` | **Create** | Prod: same as staging | +| `backend/docs/rewrite/10-mentorship-sync-dev-testing.md` | **Update** | Rewrite rationale + add ANALYTICS_DEV follow-on section + client validation section | + +--- + +## Task 1: Domain Models + +**Files:** +- Create: `backend/internal/domain/models/mentorship_sync.go` +- Modify: `backend/internal/domain/repository.go` + +- [ ] **Step 1: Create domain model file** + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package models + +// MentorshipProgram holds the Mentorship program fields that mentorship-sync +// reads from Snowflake and writes into CF Postgres. +// Field names mirror the ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS columns. +type MentorshipProgram struct { + JobspringProjectID string // upsert key — matches initiatives.jobspring_project_id + Name string + Status string // Snowflake value; 'hide' is normalised → 'hidden' in syncer + MenteeGoalCents int64 // mentee budget goal in cents + Beneficiaries []MentorshipBeneficiary +} + +// MentorshipBeneficiary is one approved beneficiary on a program. +type MentorshipBeneficiary struct { + Name string + Email string +} +``` + +- [ ] **Step 2: Add `MentorshipRepository` to repository.go** + +Open `backend/internal/domain/repository.go` and add at the end: + +```go +// MentorshipRepository defines persistence operations used by mentorship-sync. +// All methods are scoped to the batch upsert pattern of that CronJob. +type MentorshipRepository interface { + // UpsertProgram creates or updates the initiative row for a mentorship program. + // The upsert key is jobspring_project_id. Returns the initiative UUID. + UpsertProgram(ctx context.Context, p models.MentorshipProgram) (string, error) + + // UpsertBeneficiaries replaces the beneficiary list for the given initiative. + // All existing rows for initiativeID are deleted then re-inserted. + UpsertBeneficiaries(ctx context.Context, initiativeID string, beneficiaries []models.MentorshipBeneficiary) error + + // ListJobspringIDs returns the jobspring_project_id values for all existing + // mentorship initiatives. Used to detect programs that have been removed from + // Snowflake (not currently acted on, but useful for future reconciliation). + ListJobspringIDs(ctx context.Context) ([]string, error) +} +``` + +- [ ] **Step 3: Run tests to confirm nothing broken** + +```bash +cd backend && make test +``` + +Expected: all existing tests pass. + +- [ ] **Step 4: Commit** + +```bash +cd backend +git add internal/domain/models/mentorship_sync.go internal/domain/repository.go +git commit --signoff -m "feat(mentorship-sync): add MentorshipProgram domain model and repository interface" +``` + +--- + +## Task 2: Fixture Source + +**Files:** +- Create: `backend/internal/infrastructure/snowflake/fixture_source.go` +- Create: `backend/internal/infrastructure/snowflake/fixture_source_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package snowflake_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/snowflake" +) + +func TestFixtureSource_FetchPrograms_readsAllFields(t *testing.T) { + t.Parallel() + + // Write a minimal fixture file to a temp dir. + fixture := `[ + { + "jobspring_project_id": "proj-1", + "name": "Linux Kernel", + "status": "published", + "mentee_goal_cents": 500000, + "beneficiaries": [ + {"name": "Alice", "email": "alice@example.com"} + ] + }, + { + "jobspring_project_id": "proj-2", + "name": "Pending Program", + "status": "pending", + "mentee_goal_cents": 0, + "beneficiaries": [] + }, + { + "jobspring_project_id": "proj-3", + "name": "Hidden Program", + "status": "hide", + "mentee_goal_cents": 100000, + "beneficiaries": [] + } + ]` + + path := filepath.Join(t.TempDir(), "programs.json") + if err := os.WriteFile(path, []byte(fixture), 0600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + src := snowflake.NewFixtureSource(path) + programs, err := src.FetchPrograms(context.Background()) + if err != nil { + t.Fatalf("FetchPrograms: %v", err) + } + + if len(programs) != 3 { + t.Fatalf("got %d programs, want 3", len(programs)) + } + + p := programs[0] + if p.JobspringProjectID != "proj-1" { + t.Errorf("JobspringProjectID: got %q, want proj-1", p.JobspringProjectID) + } + if p.Name != "Linux Kernel" { + t.Errorf("Name: got %q, want Linux Kernel", p.Name) + } + if p.Status != "published" { + t.Errorf("Status: got %q, want published", p.Status) + } + if p.MenteeGoalCents != 500000 { + t.Errorf("MenteeGoalCents: got %d, want 500000", p.MenteeGoalCents) + } + if len(p.Beneficiaries) != 1 { + t.Fatalf("Beneficiaries: got %d, want 1", len(p.Beneficiaries)) + } + if p.Beneficiaries[0].Name != "Alice" { + t.Errorf("Beneficiaries[0].Name: got %q, want Alice", p.Beneficiaries[0].Name) + } + if p.Beneficiaries[0].Email != "alice@example.com" { + t.Errorf("Beneficiaries[0].Email: got %q, want alice@example.com", p.Beneficiaries[0].Email) + } +} + +func TestFixtureSource_FetchPrograms_missingFileReturnsError(t *testing.T) { + t.Parallel() + + src := snowflake.NewFixtureSource("/nonexistent/programs.json") + _, err := src.FetchPrograms(context.Background()) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +cd backend && go test ./internal/infrastructure/snowflake/... -run TestFixtureSource -v +``` + +Expected: `FAIL` — package does not exist yet. + +- [ ] **Step 3: Implement FixtureSource** + +Create `backend/internal/infrastructure/snowflake/fixture_source.go`: + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package snowflake + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" +) + +// fixtureProgram mirrors MentorshipProgram with JSON tags for fixture file decoding. +type fixtureProgram struct { + JobspringProjectID string `json:"jobspring_project_id"` + Name string `json:"name"` + Status string `json:"status"` + MenteeGoalCents int64 `json:"mentee_goal_cents"` + Beneficiaries []fixtureBeneficiary `json:"beneficiaries"` +} + +type fixtureBeneficiary struct { + Name string `json:"name"` + Email string `json:"email"` +} + +// FixtureSource implements mentorshipSource by reading a JSON file from disk. +// Used in DEV and local development — requires no Snowflake credentials. +type FixtureSource struct { + path string +} + +// NewFixtureSource returns a FixtureSource that reads programs from the given path. +func NewFixtureSource(path string) *FixtureSource { + return &FixtureSource{path: path} +} + +// FetchPrograms reads the fixture file and returns its contents as domain models. +func (f *FixtureSource) FetchPrograms(_ context.Context) ([]models.MentorshipProgram, error) { + data, err := os.ReadFile(f.path) + if err != nil { + return nil, fmt.Errorf("read fixture file %q: %w", f.path, err) + } + + var raw []fixtureProgram + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse fixture file %q: %w", f.path, err) + } + + programs := make([]models.MentorshipProgram, len(raw)) + for i, r := range raw { + beneficiaries := make([]models.MentorshipBeneficiary, len(r.Beneficiaries)) + for j, b := range r.Beneficiaries { + beneficiaries[j] = models.MentorshipBeneficiary{Name: b.Name, Email: b.Email} + } + programs[i] = models.MentorshipProgram{ + JobspringProjectID: r.JobspringProjectID, + Name: r.Name, + Status: r.Status, + MenteeGoalCents: r.MenteeGoalCents, + Beneficiaries: beneficiaries, + } + } + return programs, nil +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +cd backend && go test ./internal/infrastructure/snowflake/... -run TestFixtureSource -v +``` + +Expected: `PASS` for both tests. + +- [ ] **Step 5: Commit** + +```bash +cd backend +git add internal/infrastructure/snowflake/fixture_source.go internal/infrastructure/snowflake/fixture_source_test.go +git commit --signoff -m "feat(mentorship-sync): add FixtureSource for DEV environment testing" +``` + +--- + +## Task 3: Snowflake Client + +**Files:** +- Create: `backend/internal/infrastructure/snowflake/client.go` +- Create: `backend/internal/infrastructure/snowflake/client_test.go` + +This task adds the real Snowflake driver dependency and implements the `Client` that runs the production SQL query. + +- [ ] **Step 1: Add gosnowflake dependency** + +```bash +cd backend && go get github.com/snowflakedb/gosnowflake@latest +``` + +Confirm it appears in `go.mod` and `go.sum`. + +- [ ] **Step 2: Write the failing test** + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package snowflake_test + +import ( + "context" + "database/sql" + "database/sql/driver" + "io" + "testing" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/snowflake" +) + +// mockSnowflakeDriver records the last query executed against it. +type mockSnowflakeDriver struct { + lastQuery string + rows [][]driver.Value +} + +func (d *mockSnowflakeDriver) Open(_ string) (driver.Conn, error) { + return &mockConn{driver: d}, nil +} + +type mockConn struct{ driver *mockSnowflakeDriver } + +func (c *mockConn) Prepare(query string) (driver.Stmt, error) { + c.driver.lastQuery = query + return &mockStmt{driver: c.driver}, nil +} +func (c *mockConn) Close() error { return nil } +func (c *mockConn) Begin() (driver.Tx, error) { return nil, nil } + +type mockStmt struct{ driver *mockSnowflakeDriver } + +func (s *mockStmt) Close() error { return nil } +func (s *mockStmt) NumInput() int { return 0 } +func (s *mockStmt) Exec(_ []driver.Value) (driver.Result, error) { return nil, nil } +func (s *mockStmt) Query(_ []driver.Value) (driver.Rows, error) { + return &mockRows{rows: s.driver.rows, pos: 0}, nil +} + +type mockRows struct { + rows [][]driver.Value + pos int +} + +func (r *mockRows) Columns() []string { + return []string{"jobspring_project_id", "name", "status", "mentee_goal_cents"} +} +func (r *mockRows) Close() error { return nil } +func (r *mockRows) Next(dest []driver.Value) error { + if r.pos >= len(r.rows) { + return io.EOF + } + copy(dest, r.rows[r.pos]) + r.pos++ + return nil +} + +func TestClient_FetchPrograms_queriesExpectedSQL(t *testing.T) { + t.Parallel() + + mock := &mockSnowflakeDriver{ + rows: [][]driver.Value{ + {"proj-1", "Linux Kernel", "published", int64(500000)}, + }, + } + driverName := "snowflake_mock_" + t.Name() + sql.Register(driverName, mock) + + db, err := sql.Open(driverName, "") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + + client := snowflake.NewClientFromDB(db) + programs, err := client.FetchPrograms(context.Background()) + if err != nil { + t.Fatalf("FetchPrograms: %v", err) + } + + // Assert SQL contains the expected table. + wantTable := "ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS" + if mock.lastQuery == "" { + t.Fatal("no query was executed") + } + if !contains(mock.lastQuery, wantTable) { + t.Errorf("query does not reference %q\ngot: %s", wantTable, mock.lastQuery) + } + + // Assert field mapping: one row returned. + if len(programs) != 1 { + t.Fatalf("got %d programs, want 1", len(programs)) + } + p := programs[0] + if p.JobspringProjectID != "proj-1" { + t.Errorf("JobspringProjectID: got %q, want proj-1", p.JobspringProjectID) + } + if p.Name != "Linux Kernel" { + t.Errorf("Name: got %q, want Linux Kernel", p.Name) + } + if p.Status != "published" { + t.Errorf("Status: got %q, want published", p.Status) + } + if p.MenteeGoalCents != 500000 { + t.Errorf("MenteeGoalCents: got %d, want 500000", p.MenteeGoalCents) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || + func() bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false + }()) +} +``` + +- [ ] **Step 3: Run test to confirm it fails** + +```bash +cd backend && go test ./internal/infrastructure/snowflake/... -run TestClient -v +``` + +Expected: `FAIL` — `snowflake.NewClientFromDB` not defined yet. + +- [ ] **Step 4: Implement Snowflake Client** + +Create `backend/internal/infrastructure/snowflake/client.go`: + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package snowflake + +import ( + "context" + "database/sql" + "fmt" + + gosnowflake "github.com/snowflakedb/gosnowflake" + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" +) + +const fetchProgramsQuery = ` +SELECT + p.jobspring_project_id, + p.name, + p.status, + COALESCE(p.mentee_goal_cents, 0) AS mentee_goal_cents +FROM ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS p +WHERE p.jobspring_project_id IS NOT NULL +` + +// ClientConfig holds credentials for connecting to Snowflake via key-pair auth. +type ClientConfig struct { + Account string + User string + Warehouse string + Database string + Role string + PrivateKey string // PEM-encoded PKCS8 private key +} + +// Client queries Snowflake for Mentorship program data. +type Client struct { + db *sql.DB +} + +// NewClient opens a Snowflake connection using key-pair authentication. +// The caller must call Close() when done. +func NewClient(cfg ClientConfig) (*Client, error) { + dsn, err := gosnowflake.DSN(&gosnowflake.Config{ + Account: cfg.Account, + User: cfg.User, + Warehouse: cfg.Warehouse, + Database: cfg.Database, + Role: cfg.Role, + Authenticator: gosnowflake.AuthTypeJwt, + PrivateKey: cfg.PrivateKey, + }) + if err != nil { + return nil, fmt.Errorf("build snowflake DSN: %w", err) + } + + db, err := sql.Open("snowflake", dsn) + if err != nil { + return nil, fmt.Errorf("open snowflake connection: %w", err) + } + db.SetMaxOpenConns(2) + db.SetMaxIdleConns(1) + return &Client{db: db}, nil +} + +// NewClientFromDB constructs a Client from an existing *sql.DB. +// Used in tests to inject a mock driver. +func NewClientFromDB(db *sql.DB) *Client { + return &Client{db: db} +} + +// Close releases the underlying database connection pool. +func (c *Client) Close() error { + return c.db.Close() +} + +// FetchPrograms runs the Snowflake query and returns all Mentorship programs. +// Beneficiaries are not included — they are fetched in a separate query or +// embedded in the gold model; adjust the query when the schema is confirmed. +func (c *Client) FetchPrograms(ctx context.Context) ([]models.MentorshipProgram, error) { + rows, err := c.db.QueryContext(ctx, fetchProgramsQuery) + if err != nil { + return nil, fmt.Errorf("query mentorship programs: %w", err) + } + defer rows.Close() + + var programs []models.MentorshipProgram + for rows.Next() { + var p models.MentorshipProgram + if err := rows.Scan( + &p.JobspringProjectID, + &p.Name, + &p.Status, + &p.MenteeGoalCents, + ); err != nil { + return nil, fmt.Errorf("scan mentorship program row: %w", err) + } + programs = append(programs, p) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate mentorship program rows: %w", err) + } + return programs, nil +} +``` + +- [ ] **Step 5: Run tests to confirm they pass** + +```bash +cd backend && go test ./internal/infrastructure/snowflake/... -v +``` + +Expected: all 3 tests pass (`TestFixtureSource_*` x2, `TestClient_FetchPrograms_queriesExpectedSQL`). + +- [ ] **Step 6: Commit** + +```bash +cd backend +git add internal/infrastructure/snowflake/client.go internal/infrastructure/snowflake/client_test.go go.mod go.sum +git commit --signoff -m "feat(mentorship-sync): add Snowflake client with mock-driver unit test" +``` + +--- + +## Task 4: Mentorship Repository + +**Files:** +- Create: `backend/internal/infrastructure/db/mentorship_repository.go` +- Create: `backend/internal/infrastructure/db/mentorship_repository_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package db_test + +import ( + "context" + "testing" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/db" +) + +// These tests use a stub pool — they verify the repository compiles and +// exposes the correct interface. Integration tests against a real DB are +// outside scope for the CronJob unit test suite. + +func TestMentorshipRepository_implementsInterface(t *testing.T) { + // Compile-time check: MentorshipRepository implements domain.MentorshipRepository. + // If the interface changes and the implementation doesn't follow, this test + // file will fail to compile. + _ = func() { + var _ interface { + UpsertProgram(context.Context, models.MentorshipProgram) (string, error) + UpsertBeneficiaries(context.Context, string, []models.MentorshipBeneficiary) error + ListJobspringIDs(context.Context) ([]string, error) + } = (*db.MentorshipRepositoryImpl)(nil) + } +} +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +cd backend && go test ./internal/infrastructure/db/... -run TestMentorshipRepository -v +``` + +Expected: `FAIL` — `db.MentorshipRepositoryImpl` not defined. + +- [ ] **Step 3: Implement the repository** + +Create `backend/internal/infrastructure/db/mentorship_repository.go`: + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package db + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" +) + +// MentorshipRepositoryImpl implements domain.MentorshipRepository against CF Postgres. +type MentorshipRepositoryImpl struct { + pool *pgxpool.Pool +} + +// NewMentorshipRepository returns a MentorshipRepositoryImpl backed by pool. +func NewMentorshipRepository(pool *pgxpool.Pool) *MentorshipRepositoryImpl { + return &MentorshipRepositoryImpl{pool: pool} +} + +// UpsertProgram inserts or updates the mentorship initiative row identified by +// jobspring_project_id. Returns the initiative UUID. +func (r *MentorshipRepositoryImpl) UpsertProgram(ctx context.Context, p models.MentorshipProgram) (string, error) { + // Normalise Mentorship status: 'hide' → 'hidden' + status := p.Status + if status == "hide" { + status = "hidden" + } + + const q = ` +INSERT INTO initiatives ( + id, + initiative_type, + jobspring_project_id, + name, + status, + created_on, + updated_on +) VALUES ( + gen_random_uuid(), + 'mentorship', + $1, + $2, + $3, + NOW(), + NOW() +) +ON CONFLICT (jobspring_project_id) DO UPDATE SET + name = EXCLUDED.name, + status = EXCLUDED.status, + updated_on = NOW() +RETURNING id +` + var id string + err := r.pool.QueryRow(ctx, q, p.JobspringProjectID, p.Name, status).Scan(&id) + if err != nil { + return "", fmt.Errorf("upsert mentorship program %q: %w", p.JobspringProjectID, err) + } + return id, nil +} + +// UpsertBeneficiaries replaces all beneficiary rows for the given initiative. +// Runs in a transaction: delete existing → insert new. +func (r *MentorshipRepositoryImpl) UpsertBeneficiaries(ctx context.Context, initiativeID string, beneficiaries []models.MentorshipBeneficiary) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx for beneficiaries %q: %w", initiativeID, err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, + `DELETE FROM initiative_beneficiaries WHERE initiative_id = $1`, initiativeID, + ); err != nil { + return fmt.Errorf("delete beneficiaries for %q: %w", initiativeID, err) + } + + for _, b := range beneficiaries { + if _, err := tx.Exec(ctx, + `INSERT INTO initiative_beneficiaries (id, initiative_id, name, email, created_on, updated_on) + VALUES ($1, $2, $3, $4, $5, $5)`, + uuid.New().String(), initiativeID, b.Name, b.Email, time.Now(), + ); err != nil { + return fmt.Errorf("insert beneficiary %q for %q: %w", b.Email, initiativeID, err) + } + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit beneficiaries tx for %q: %w", initiativeID, err) + } + return nil +} + +// ListJobspringIDs returns the jobspring_project_id for all existing mentorship initiatives. +func (r *MentorshipRepositoryImpl) ListJobspringIDs(ctx context.Context) ([]string, error) { + const q = ` +SELECT jobspring_project_id +FROM initiatives +WHERE initiative_type = 'mentorship' + AND jobspring_project_id IS NOT NULL +` + rows, err := r.pool.Query(ctx, q) + if err != nil { + return nil, fmt.Errorf("list jobspring IDs: %w", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan jobspring ID: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +cd backend && go test ./internal/infrastructure/db/... -run TestMentorshipRepository -v +``` + +Expected: `PASS`. + +- [ ] **Step 5: Commit** + +```bash +cd backend +git add internal/infrastructure/db/mentorship_repository.go internal/infrastructure/db/mentorship_repository_test.go +git commit --signoff -m "feat(mentorship-sync): add MentorshipRepository with upsert and beneficiary replacement" +``` + +--- + +## Task 5: Fixture Data File + +**Files:** +- Create: `backend/cmd/mentorship-sync/testdata/programs.json` + +- [ ] **Step 1: Create fixture data** + +```json +[ + { + "jobspring_project_id": "jobspring-active-001", + "name": "Linux Kernel Mentorship", + "status": "published", + "mentee_goal_cents": 500000, + "beneficiaries": [ + {"name": "Alice Mentee", "email": "alice@example.com"}, + {"name": "Bob Mentee", "email": "bob@example.com"} + ] + }, + { + "jobspring_project_id": "jobspring-pending-002", + "name": "Cloud Native Mentorship", + "status": "pending", + "mentee_goal_cents": 0, + "beneficiaries": [] + }, + { + "jobspring_project_id": "jobspring-hidden-003", + "name": "Hidden Program", + "status": "hide", + "mentee_goal_cents": 100000, + "beneficiaries": [] + } +] +``` + +This covers three code paths in `Syncer.Run`: active program with beneficiaries, pending program (no goal), and a hidden program that exercises the `'hide'` → `'hidden'` status normalisation. + +- [ ] **Step 2: Verify fixture parses cleanly** + +```bash +cd backend && go run -v ./cmd/mentorship-sync/... 2>&1 | head -5 +``` + +(The binary won't exist yet — that's fine. This just confirms the testdata directory is in place for the next task.) + +Alternatively, verify with: + +```bash +cat backend/cmd/mentorship-sync/testdata/programs.json | python3 -m json.tool > /dev/null && echo "valid JSON" +``` + +Expected: `valid JSON`. + +- [ ] **Step 3: Commit** + +```bash +cd backend +git add cmd/mentorship-sync/testdata/programs.json +git commit --signoff -m "feat(mentorship-sync): add fixture data file for DEV environment testing" +``` + +--- + +## Task 6: Syncer and CronJob Entry Point + +**Files:** +- Create: `backend/cmd/mentorship-sync/syncer.go` +- Create: `backend/cmd/mentorship-sync/syncer_test.go` +- Create: `backend/cmd/mentorship-sync/main.go` + +- [ ] **Step 1: Write the failing syncer tests** + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "errors" + "testing" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" +) + +// ─── Mock implementations ──────────────────────────────────────────────────── + +type mockMentorshipSource struct { + programs []models.MentorshipProgram + err error +} + +func (m *mockMentorshipSource) FetchPrograms(_ context.Context) ([]models.MentorshipProgram, error) { + return m.programs, m.err +} + +type mockMentorshipRepo struct { + upsertedPrograms []models.MentorshipProgram + upsertedBeneficiaries map[string][]models.MentorshipBeneficiary + programErr error + beneficiaryErr error + jobspringIDs []string + listErr error +} + +func (m *mockMentorshipRepo) UpsertProgram(_ context.Context, p models.MentorshipProgram) (string, error) { + if m.programErr != nil { + return "", m.programErr + } + m.upsertedPrograms = append(m.upsertedPrograms, p) + return "initiative-uuid-" + p.JobspringProjectID, nil +} + +func (m *mockMentorshipRepo) UpsertBeneficiaries(_ context.Context, initiativeID string, beneficiaries []models.MentorshipBeneficiary) error { + if m.beneficiaryErr != nil { + return m.beneficiaryErr + } + if m.upsertedBeneficiaries == nil { + m.upsertedBeneficiaries = make(map[string][]models.MentorshipBeneficiary) + } + m.upsertedBeneficiaries[initiativeID] = beneficiaries + return nil +} + +func (m *mockMentorshipRepo) ListJobspringIDs(_ context.Context) ([]string, error) { + return m.jobspringIDs, m.listErr +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +func TestSyncer_Run_upsertsAllPrograms(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "Prog A", Status: "published", Beneficiaries: []models.MentorshipBeneficiary{{Name: "Alice", Email: "a@x.com"}}}, + {JobspringProjectID: "js-2", Name: "Prog B", Status: "pending", Beneficiaries: nil}, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + result, err := s.Run(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.total != 2 { + t.Errorf("total: got %d, want 2", result.total) + } + if result.upserted != 2 { + t.Errorf("upserted: got %d, want 2", result.upserted) + } + if result.errors != 0 { + t.Errorf("errors: got %d, want 0", result.errors) + } +} + +func TestSyncer_Run_normalisesHideStatus(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "Hidden", Status: "hide"}, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + if _, err := s.Run(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(repo.upsertedPrograms) != 1 { + t.Fatalf("expected 1 upsert, got %d", len(repo.upsertedPrograms)) + } + if got := repo.upsertedPrograms[0].Status; got != "hidden" { + t.Errorf("status: got %q, want hidden", got) + } +} + +func TestSyncer_Run_beneficiariesUpsertedForInitiative(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + { + JobspringProjectID: "js-1", + Name: "Prog A", + Status: "published", + Beneficiaries: []models.MentorshipBeneficiary{ + {Name: "Alice", Email: "alice@x.com"}, + }, + }, + }, + } + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + if _, err := s.Run(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + initiativeID := "initiative-uuid-js-1" + bens, ok := repo.upsertedBeneficiaries[initiativeID] + if !ok { + t.Fatalf("no beneficiaries upserted for initiative %q", initiativeID) + } + if len(bens) != 1 || bens[0].Email != "alice@x.com" { + t.Errorf("beneficiaries: got %+v", bens) + } +} + +func TestSyncer_Run_emptySourceReturnsZeroResult(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{programs: nil} + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + result, err := s.Run(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.total != 0 || result.upserted != 0 || result.errors != 0 { + t.Errorf("expected all-zero result, got %+v", result) + } +} + +func TestSyncer_Run_propagatesSourceError(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{err: errors.New("snowflake unavailable")} + repo := &mockMentorshipRepo{} + + s := newSyncer(repo, src, discardLogger()) + _, err := s.Run(context.Background()) + + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSyncer_Run_countsUpsertErrorsWithoutHalting(t *testing.T) { + t.Parallel() + + src := &mockMentorshipSource{ + programs: []models.MentorshipProgram{ + {JobspringProjectID: "js-1", Name: "Good"}, + {JobspringProjectID: "js-2", Name: "Bad"}, + }, + } + callCount := 0 + repo := &mockMentorshipRepo{} + repo.programErr = nil + + // Override to fail on second call. + failRepo := &failOnSecondRepo{base: repo} + + s := newSyncer(failRepo, src, discardLogger()) + result, err := s.Run(context.Background()) + + _ = callCount + if err != nil { + t.Fatalf("unexpected top-level error: %v", err) + } + if result.upserted != 1 { + t.Errorf("upserted: got %d, want 1", result.upserted) + } + if result.errors != 1 { + t.Errorf("errors: got %d, want 1", result.errors) + } +} + +// failOnSecondRepo wraps mockMentorshipRepo and fails on the second UpsertProgram call. +type failOnSecondRepo struct { + base *mockMentorshipRepo + calls int +} + +func (r *failOnSecondRepo) UpsertProgram(ctx context.Context, p models.MentorshipProgram) (string, error) { + r.calls++ + if r.calls == 2 { + return "", errors.New("db error") + } + return r.base.UpsertProgram(ctx, p) +} + +func (r *failOnSecondRepo) UpsertBeneficiaries(ctx context.Context, id string, b []models.MentorshipBeneficiary) error { + return r.base.UpsertBeneficiaries(ctx, id, b) +} + +func (r *failOnSecondRepo) ListJobspringIDs(ctx context.Context) ([]string, error) { + return r.base.ListJobspringIDs(ctx) +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd backend && go test ./cmd/mentorship-sync/... -v 2>&1 | head -20 +``` + +Expected: `FAIL` — package does not exist yet. + +- [ ] **Step 3: Implement syncer.go** + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "fmt" + "log/slog" + + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain" + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain/models" +) + +// mentorshipSource is the interface Syncer needs from its data source. +// Defined at the point of consumption — both snowflake.Client and +// snowflake.FixtureSource satisfy this interface. +type mentorshipSource interface { + FetchPrograms(ctx context.Context) ([]models.MentorshipProgram, error) +} + +// syncResult carries the per-run counters logged on completion. +type syncResult struct { + total int // programs returned by source + upserted int // programs successfully upserted (program + beneficiaries) + errors int // programs that failed to upsert (logged, not fatal) +} + +// Syncer orchestrates a single mentorship-sync run. +type Syncer struct { + repo domain.MentorshipRepository + source mentorshipSource + logger *slog.Logger +} + +// newSyncer returns a configured Syncer ready to call Run. +func newSyncer(repo domain.MentorshipRepository, source mentorshipSource, logger *slog.Logger) *Syncer { + return &Syncer{repo: repo, source: source, logger: logger} +} + +// Run executes the full sync algorithm: +// 1. Fetch all programs from source (Snowflake or fixture). +// 2. For each program: normalise status, upsert initiative row, upsert beneficiaries. +// 3. Log per-program errors without halting the run. +// 4. Return per-run counters for summary logging. +func (s *Syncer) Run(ctx context.Context) (syncResult, error) { + programs, err := s.source.FetchPrograms(ctx) + if err != nil { + return syncResult{}, fmt.Errorf("fetch programs: %w", err) + } + + result := syncResult{total: len(programs)} + + for _, p := range programs { + // Normalise Mentorship status before upsert. + if p.Status == "hide" { + p.Status = "hidden" + } + + initiativeID, err := s.repo.UpsertProgram(ctx, p) + if err != nil { + s.logger.ErrorContext(ctx, "upsert program failed", + "jobspring_project_id", p.JobspringProjectID, + "error", err, + ) + result.errors++ + continue + } + + if err := s.repo.UpsertBeneficiaries(ctx, initiativeID, p.Beneficiaries); err != nil { + s.logger.ErrorContext(ctx, "upsert beneficiaries failed", + "initiative_id", initiativeID, + "jobspring_project_id", p.JobspringProjectID, + "error", err, + ) + result.errors++ + continue + } + + result.upserted++ + } + + return result, nil +} +``` + +- [ ] **Step 4: Implement main.go** + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// mentorship-sync pulls Mentorship program data from Snowflake (or a JSON +// fixture in DEV) and upserts initiative_type=mentorship rows into CF Postgres. +// +// See docs/rewrite/10-mentorship-sync-dev-testing.md for the DEV testing strategy. +// +// Usage: run as a K8s CronJob (daily schedule). 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/db" + "github.com/linuxfoundation/lfx-v2-initiatives-service/internal/infrastructure/snowflake" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + if err := run(logger); err != nil { + logger.Error("mentorship-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 — same pattern as initiatives-api and ledger-stats-sync. + pool, err := db.NewPool(ctx, db.PoolConfig{ + DSN: cfg.DatabaseURL, + MaxConns: 5, + MinConns: 1, + }) + if err != nil { + return fmt.Errorf("database pool: %w", err) + } + defer pool.Close() + + // Wire the mentorship source: fixture (DEV) or real Snowflake (staging/prod). + var src mentorshipSource + if cfg.FixtureFile != "" { + logger.Info("using fixture source", "path", cfg.FixtureFile) + src = snowflake.NewFixtureSource(cfg.FixtureFile) + } else { + client, err := snowflake.NewClient(snowflake.ClientConfig{ + Account: cfg.SnowflakeAccount, + User: cfg.SnowflakeUser, + Warehouse: cfg.SnowflakeWarehouse, + Database: cfg.SnowflakeDatabase, + Role: cfg.SnowflakeRole, + PrivateKey: cfg.SnowflakePrivateKey, + }) + if err != nil { + return fmt.Errorf("snowflake client: %w", err) + } + defer client.Close() + src = client + } + + repo := db.NewMentorshipRepository(pool) + syncer := newSyncer(repo, src, logger) + + logger.Info("mentorship-sync starting") + + result, err := syncer.Run(ctx) + if err != nil { + return fmt.Errorf("sync run: %w", err) + } + + logger.Info("mentorship-sync complete", + "duration", time.Since(start).String(), + "total", result.total, + "upserted", result.upserted, + "errors", result.errors, + ) + return nil +} + +// config holds the runtime configuration for mentorship-sync. +type config struct { + DatabaseURL string + FixtureFile string // set in DEV; absence means use Snowflake + SnowflakeAccount string + SnowflakeUser string + SnowflakeWarehouse string + SnowflakeDatabase string + SnowflakeRole string + SnowflakePrivateKey string +} + +func loadConfig() (*config, error) { + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + return nil, fmt.Errorf("DATABASE_URL is required") + } + + cfg := &config{ + DatabaseURL: dbURL, + FixtureFile: os.Getenv("MENTORSHIP_SYNC_FIXTURE_FILE"), + } + + // Snowflake vars are only required when not using fixture. + if cfg.FixtureFile == "" { + for _, pair := range []struct{ key, field string }{ + {"SNOWFLAKE_ACCOUNT", cfg.SnowflakeAccount}, + {"SNOWFLAKE_USER", cfg.SnowflakeUser}, + {"SNOWFLAKE_WAREHOUSE", cfg.SnowflakeWarehouse}, + {"SNOWFLAKE_DATABASE", cfg.SnowflakeDatabase}, + {"SNOWFLAKE_ROLE", cfg.SnowflakeRole}, + {"SNOWFLAKE_PRIVATE_KEY", cfg.SnowflakePrivateKey}, + } { + v := os.Getenv(pair.key) + if v == "" { + return nil, fmt.Errorf("%s is required when MENTORSHIP_SYNC_FIXTURE_FILE is not set", pair.key) + } + } + cfg.SnowflakeAccount = os.Getenv("SNOWFLAKE_ACCOUNT") + cfg.SnowflakeUser = os.Getenv("SNOWFLAKE_USER") + cfg.SnowflakeWarehouse = os.Getenv("SNOWFLAKE_WAREHOUSE") + cfg.SnowflakeDatabase = os.Getenv("SNOWFLAKE_DATABASE") + cfg.SnowflakeRole = os.Getenv("SNOWFLAKE_ROLE") + cfg.SnowflakePrivateKey = os.Getenv("SNOWFLAKE_PRIVATE_KEY") + } + + return cfg, nil +} +``` + +Add the `discardLogger` helper to a new `helpers_test.go` in the same package (mirrors `ledger-stats-sync`): + +```go +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package main + +import "log/slog" + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(nopWriter{}, nil)) +} + +type nopWriter struct{} + +func (nopWriter) Write(p []byte) (int, error) { return len(p), nil } +``` + +- [ ] **Step 5: Run all tests to confirm they pass** + +```bash +cd backend && make test +``` + +Expected: all tests pass including the new syncer tests. + +- [ ] **Step 6: Confirm binary compiles** + +```bash +cd backend && go build ./cmd/mentorship-sync/... +``` + +Expected: no errors. Binary produced at `bin/mentorship-sync` (or default location). + +- [ ] **Step 7: Commit** + +```bash +cd backend +git add cmd/mentorship-sync/syncer.go cmd/mentorship-sync/syncer_test.go cmd/mentorship-sync/main.go cmd/mentorship-sync/helpers_test.go +git commit --signoff -m "feat(mentorship-sync): implement syncer and CronJob entry point" +``` + +--- + +## Task 7: Dockerfile + +**Files:** +- Create: `backend/Dockerfile.mentorship-sync` + +- [ ] **Step 1: Check existing Dockerfile pattern** + +```bash +cat backend/Dockerfile.ledger-stats-sync +``` + +Use that as the template — same multi-stage build pattern. + +- [ ] **Step 2: Create Dockerfile** + +```dockerfile +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT + +FROM golang:1.25-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /mentorship-sync ./cmd/mentorship-sync + +FROM gcr.io/distroless/static-debian12 AS runner +COPY --from=builder /mentorship-sync /mentorship-sync +# Copy fixture data so it's available when MENTORSHIP_SYNC_FIXTURE_FILE is set. +COPY cmd/mentorship-sync/testdata/ /app/testdata/ +ENTRYPOINT ["/mentorship-sync"] +``` + +- [ ] **Step 3: Build the image locally to confirm it works** + +```bash +cd backend && docker build -f Dockerfile.mentorship-sync -t mentorship-sync:local . +``` + +Expected: build succeeds, no errors. + +- [ ] **Step 4: Commit** + +```bash +cd backend +git add Dockerfile.mentorship-sync +git commit --signoff -m "feat(mentorship-sync): add Dockerfile for CronJob container image" +``` + +--- + +## Task 8: ArgoCD Values + +**Files (in `lfx-v2-argocd` repo):** +- Create: `values/dev/lfx-crowdfunding-mentorship-sync.yaml` +- Create: `values/staging/lfx-crowdfunding-mentorship-sync.yaml` +- Create: `values/prod/lfx-crowdfunding-mentorship-sync.yaml` + +- [ ] **Step 1: Check existing CronJob values pattern** + +```bash +ls /Users/michal/src/github/linuxfoundation/lfx-v2-argocd/values/dev/ | grep -v crowdfunding +# Look for any CronJob example to understand the expected schema +cat /Users/michal/src/github/linuxfoundation/lfx-v2-argocd/values/dev/lfx-crowdfunding-backend.yaml +``` + +Adapt the structure from `lfx-crowdfunding-backend.yaml` but for a CronJob resource. + +- [ ] **Step 2: Create DEV values** + +```yaml +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +--- +# DEV environment — uses fixture source; no Snowflake credentials required. +# TODO(analytics-dev): when ANALYTICS_DEV gold models are live, remove +# MENTORSHIP_SYNC_FIXTURE_FILE and set SNOWFLAKE_DATABASE: ANALYTICS_DEV instead. + +image: + repository: ghcr.io/linuxfoundation/lfx-crowdfunding-mentorship-sync + tag: development + pullPolicy: Always + +schedule: "0 2 * * *" # daily at 02:00 UTC + +config: + MENTORSHIP_SYNC_FIXTURE_FILE: /app/testdata/programs.json +``` + +- [ ] **Step 3: Create staging values** + +```yaml +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +--- +# Staging environment — real Snowflake credentials from K8s Secret. + +image: + repository: ghcr.io/linuxfoundation/lfx-crowdfunding-mentorship-sync + tag: staging + pullPolicy: Always + +schedule: "0 2 * * *" # daily at 02:00 UTC + +config: + SNOWFLAKE_DATABASE: ANALYTICS + +secrets: + - name: SNOWFLAKE_ACCOUNT + secretName: mentorship-sync-secrets + secretKey: snowflake-account + - name: SNOWFLAKE_USER + secretName: mentorship-sync-secrets + secretKey: snowflake-user + - name: SNOWFLAKE_WAREHOUSE + secretName: mentorship-sync-secrets + secretKey: snowflake-warehouse + - name: SNOWFLAKE_ROLE + secretName: mentorship-sync-secrets + secretKey: snowflake-role + - name: SNOWFLAKE_PRIVATE_KEY + secretName: mentorship-sync-secrets + secretKey: snowflake-private-key +``` + +- [ ] **Step 4: Create prod values** + +Same as staging but with `tag: latest` and prod-appropriate schedule: + +```yaml +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +--- +# Production environment — real Snowflake credentials from K8s Secret. + +image: + repository: ghcr.io/linuxfoundation/lfx-crowdfunding-mentorship-sync + tag: latest + pullPolicy: IfNotPresent + +schedule: "0 2 * * *" # daily at 02:00 UTC + +config: + SNOWFLAKE_DATABASE: ANALYTICS + +secrets: + - name: SNOWFLAKE_ACCOUNT + secretName: mentorship-sync-secrets + secretKey: snowflake-account + - name: SNOWFLAKE_USER + secretName: mentorship-sync-secrets + secretKey: snowflake-user + - name: SNOWFLAKE_WAREHOUSE + secretName: mentorship-sync-secrets + secretKey: snowflake-warehouse + - name: SNOWFLAKE_ROLE + secretName: mentorship-sync-secrets + secretKey: snowflake-role + - name: SNOWFLAKE_PRIVATE_KEY + secretName: mentorship-sync-secrets + secretKey: snowflake-private-key +``` + +- [ ] **Step 5: Commit to lfx-v2-argocd** + +```bash +cd /Users/michal/src/github/linuxfoundation/lfx-v2-argocd +git add values/dev/lfx-crowdfunding-mentorship-sync.yaml \ + values/staging/lfx-crowdfunding-mentorship-sync.yaml \ + values/prod/lfx-crowdfunding-mentorship-sync.yaml +git commit --signoff -m "feat(crowdfunding): add mentorship-sync CronJob ArgoCD values for dev/staging/prod" +``` + +--- + +## Task 9: Update PR #95 Documentation + +**Files:** +- Modify: `backend/docs/rewrite/10-mentorship-sync-dev-testing.md` (on the PR #95 branch) + +- [ ] **Step 1: Check out the PR #95 branch** + +```bash +cd /Users/michal/src/github/linuxfoundation/lfx-crowdfunding +git fetch origin +git checkout docs/mentorship-sync-dev-testing # adjust branch name if different +``` + +Confirm: `git log --oneline -3` shows the doc commit from PR #95. + +- [ ] **Step 2: Replace "Why not a DEV Snowflake schema?" section** + +Find this section in `backend/docs/rewrite/10-mentorship-sync-dev-testing.md` and replace it with: + +```markdown +**Why not `ANALYTICS_DEV` right now?** +`ANALYTICS_DEV` exists in Snowflake and is supported by the Data Lake team (confirmed June 2026). However it is currently missing ~8 of the ~10 raw source tables required to build the gold model. Only two DEV tables exist today: +- `FIVETRAN_INGEST.DYNAMODB_PRODUCT_US_EAST1_DEV.JOBSPRING_DEV_PROJECTS` +- `FIVETRAN_INGEST.DYNAMODB_PRODUCT_US_EAST1_DEV.JOBSPRING_DEV_TASKS` + +The gold model cannot be built until the remaining tables are added by the Data Lake team. The fixture source unblocks implementation without waiting on that timeline. + +**Why an env var rather than `APP_ENV`?** +`MENTORSHIP_SYNC_FIXTURE_FILE` is an explicit, self-describing opt-in. Setting it to a path makes the intent unambiguous in the ArgoCD values file. It also allows the fixture source to be used locally without changing the global `APP_ENV`, and it can be applied to staging temporarily if needed (e.g. to test a schema change before Fivetran is updated). + +**What this does not test** +The fixture source bypasses the Snowflake driver and SQL query entirely. That gap is covered by a unit test in `internal/infrastructure/snowflake/client_test.go` that asserts the expected SQL query string and field mapping against a mock driver. A manual smoke test against `ANALYTICS.GOLD_FACT.MENTORSHIP_PROGRAMS` (read-only, prod credentials) must be run by the deploying developer before the first DEV and staging deployments — documented in `docs/go-live-checklist.md`. +``` + +- [ ] **Step 3: Add the `ANALYTICS_DEV` follow-on section** + +Append a new section after "Implementation": + +```markdown +## Follow-on: `ANALYTICS_DEV` Path + +When the missing raw DEV tables are added by the Data Lake team, CF will build bronze/silver/gold dbt models in `lf-dbt` so that `ANALYTICS_DEV.GOLD_FACT.MENTORSHIP_PROGRAMS` mirrors production. The DEV ArgoCD values are then updated: remove `MENTORSHIP_SYNC_FIXTURE_FILE`, set `SNOWFLAKE_DATABASE=ANALYTICS_DEV`. No code change needed — the `mentorshipSource` interface already supports it. + +Steps in order: + +| Step | Owner | Blocked on | +|---|---|---| +| 1. Add remaining ~8 raw DEV tables to `FIVETRAN_INGEST.DYNAMODB_PRODUCT_US_EAST1_DEV` | Data Lake (Shane/David) | Jira ticket to file | +| 2. Build bronze/silver/gold dbt models in `lf-dbt` | CF | Step 1 | +| 3. Provision DEV Snowflake service account (read-only to `ANALYTICS_DEV`) | Data Lake | Step 2 | +| 4. Update DEV ArgoCD values | CF | Step 3 | + +The fixture source stays available for local development indefinitely — `MENTORSHIP_SYNC_FIXTURE_FILE` can always be set locally even after `ANALYTICS_DEV` is ready. + +A `TODO(analytics-dev)` comment in `values/dev/lfx-crowdfunding-mentorship-sync.yaml` marks the swap point. +``` + +- [ ] **Step 4: Run the test plan from the PR** + +```bash +# Confirm doc renders in GitHub — review markdown in editor +# Check file inventory in the doc matches the files created in Tasks 1-8 +``` + +- [ ] **Step 5: Commit and push to the PR branch** + +```bash +cd /Users/michal/src/github/linuxfoundation/lfx-crowdfunding +git add backend/docs/rewrite/10-mentorship-sync-dev-testing.md +git commit --signoff -m "docs(mentorship-sync): update DEV testing doc — correct ANALYTICS_DEV rationale, add follow-on path" +git push +``` + +--- + +## Self-Review Checklist + +- [x] **Domain models** (`MentorshipProgram`, `MentorshipBeneficiary`) defined in Task 1, used consistently in Tasks 2–6 +- [x] **`MentorshipRepository` interface** defined in Task 1, implemented in Task 4, consumed by syncer in Task 6 +- [x] **`mentorshipSource` interface** defined in syncer (Task 6), satisfied by `FixtureSource` (Task 2) and `Client` (Task 3) +- [x] **Status normalisation** (`'hide'` → `'hidden'`) done in `Syncer.Run` (Task 6), tested in `TestSyncer_Run_normalisesHideStatus` +- [x] **Fixture file** created in Task 5, embedded in Dockerfile (Task 7), referenced in DEV ArgoCD values (Task 8) +- [x] **Snowflake vars required only when fixture absent** — validated in `loadConfig` (Task 6) +- [x] **`discardLogger` helper** added to `helpers_test.go` (Task 6), used by all syncer tests +- [x] **Method name consistency**: `FetchPrograms` used in Tasks 2, 3, and 6; `UpsertProgram`/`UpsertBeneficiaries`/`ListJobspringIDs` used in Tasks 1, 4, and 6 +- [x] **`syncResult.errors` field** defined in syncer (Task 6), counted per-program, tested in `TestSyncer_Run_countsUpsertErrorsWithoutHalting` +- [x] **Doc update** (Task 9) aligned with design spec — corrects false rationale, adds follow-on section