diff --git a/.github/workflows/license-header-check.yml b/.github/workflows/license-header-check.yml index 82eb524..945a2c7 100644 --- a/.github/workflows/license-header-check.yml +++ b/.github/workflows/license-header-check.yml @@ -15,4 +15,4 @@ jobs: uses: linuxfoundation/lfx-public-workflows/.github/workflows/license-header-check.yml@main with: copyright_line: "Copyright The Linux Foundation and each contributor to LFX." - exclude_pattern: "gen" + exclude_pattern: "gen,templates" diff --git a/CLAUDE.md b/CLAUDE.md index e65de4c..b3a9df2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The LFX V2 Project Service is a RESTful API service that manages projects within ### Key Technologies -- **Language**: Go 1.23+ +- **Language**: Go 1.24+ - **API Framework**: Goa v3 (code generation framework) - **Messaging**: NATS with JetStream for event-driven architecture - **Storage**: NATS Key-Value stores (no traditional database) @@ -39,11 +39,13 @@ cmd/project-api/ # Presentation Layer (HTTP/NATS entry point) internal/ # Core business logic ├── domain/ # Domain layer (interfaces, models, errors) │ └── models/ # Domain entities -├── service/ # Service layer (business logic) -├── infrastructure/ # Infrastructure layer -│ ├── auth/ # JWT authentication -│ └── nats/ # NATS repository implementation -└── middleware/ # HTTP middleware +├── service/ # Service layer (business logic) +│ └── email/ # Email template rendering (one file per email type) +└── infrastructure/ # Infrastructure layer + ├── auth/ # JWT authentication + ├── log/ # Structured logging helpers (AppendCtx, InitStructureLogConfig) + ├── middleware/ # HTTP middleware (auth, request ID, body limit, logger) + └── nats/ # NATS repository and message builder pkg/ # Shared packages across services └── constants/ # Shared constants @@ -151,23 +153,32 @@ The service uses NATS for: ### API Endpoints and Message Subjects -Complete API endpoint documentation and NATS message handlers are now documented in README.md. Key RPC subjects handled by this service: +Complete API endpoint documentation and NATS message handlers are now documented in README.md. + +There are two distinct NATS patterns in this service — both use `QueueSubscribe` but for different purposes: + +**Request/reply RPC** (`internal/service/project_handlers.go`): another service sends a request and blocks waiting for a response. The handler calls `msg.Respond(data)` to return data to the caller. + +**Event subscriptions** (`internal/service/project_subscriber.go`): the service reacts to events that were already published (including by itself). No caller is waiting — the handler is fire-and-forget and never calls `msg.Respond`. ```go -// Inbound RPC (handled by this service) -"lfx.projects-api.queue" // Queue for projects API operations +// Inbound RPC — request/reply, caller blocks waiting for response "lfx.projects-api.get_name" // Get project name by UID "lfx.projects-api.get_slug" // Get project slug by UID "lfx.projects-api.get_logo" // Get project logo URL by UID "lfx.projects-api.slug_to_uid" // Convert slug to UID "lfx.projects-api.get_parent_uid" // Get parent project UID +// Inbound events — fire-and-forget, no reply expected +"lfx.projects-api.project_settings.updated" // Sends role notification emails on member additions + // Outbound events (published by this service) "lfx.index.project" // Project created/updated/deleted for indexing "lfx.index.project_settings" // Settings created/updated for indexing -"lfx.projects-api.project_settings.updated" // Settings changed (before/after) +"lfx.projects-api.project_settings.updated" // Settings changed (before/after snapshot) "lfx.fga-sync.update_access" // Generic FGA access control updates "lfx.fga-sync.delete_access" // Generic FGA access control deletion +"lfx.email-service.send_email" // Request/reply to email service for role notifications ``` ### FGA Sync Message Format @@ -256,6 +267,7 @@ func TestEndpoint(t *testing.T) { | `JWKS_URL` | JWT verification endpoint | - | No | | `AUDIENCE` | JWT audience | lfx-v2-project-service | No | | `JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL` | Mock auth for local dev | - | No | +| `LFX_SELF_SERVE_BASE_URL` | Base URL for project links in notification emails | derived from `LFX_ENVIRONMENT` | No | ## Authorization (OpenFGA) @@ -406,7 +418,17 @@ Every data modification publishes NATS messages: - Index messages for search service - Access control updates for authorization service -### 3. Request Context +### 3. NATS Event Wire Types (`pkg/events/`) + +NATS message payload types that other services consume belong in `pkg/events/`, **not** `internal/`. This lets downstream services (e.g., `lfx-v2-invite-service`) import the canonical struct definitions directly. + +- Domain types in `internal/domain/models/` may differ from wire types and can evolve independently. +- Explicit converter functions in `internal/service/converters.go` map from domain → event type before publishing. +- Example: `DomainSettingsToEvent(*models.ProjectSettings) events.ProjectSettings` + +**Rule:** if a struct appears in a NATS message payload, it belongs in `pkg/events/`, not `internal/`. + +### 4. Request Context Important context values: @@ -414,7 +436,7 @@ Important context values: - `authorization`: JWT token from header - `etag`: ETag value for optimistic concurrency (sent as If-Match header in requests) -### 4. Error Handling +### 5. Error Handling Domain errors are mapped to HTTP status codes: diff --git a/Makefile b/Makefile index 653b7ac..96d7570 100644 --- a/Makefile +++ b/Makefile @@ -152,21 +152,13 @@ fmt: # Check license headers (basic validation - full check runs in CI) .PHONY: license-check license-check: - @echo "==> Checking license headers (basic validation)..." - @missing_files=$$(find . -name "*.go" \ - -not -path "./api/project/v1/gen/*" \ - -not -path "./vendor/*" \ - -exec sh -c 'head -10 "$$1" | grep -q "Copyright The Linux Foundation and each contributor to LFX" && head -10 "$$1" | grep -q "SPDX-License-Identifier: MIT" || echo "$$1"' _ {} \;); \ - if [ -n "$$missing_files" ]; then \ - echo "Files missing required license headers:"; \ - echo "$$missing_files"; \ - echo "Required headers:"; \ - echo " # Copyright The Linux Foundation and each contributor to LFX."; \ - echo " # SPDX-License-Identifier: MIT"; \ - echo "Note: Full license validation runs in CI"; \ - exit 1; \ - fi - @echo "==> Basic license header check passed" + @echo "==> Checking license headers..." + @missing=$$(git ls-files | grep -E '\.(go|html|txt)$$' | grep -v "^api/project/v1/gen/" | grep -v "^internal/service/email/templates/" | while IFS= read -r f; do \ + head -4 "$$f" | grep -q "Copyright The Linux Foundation and each contributor to LFX" || echo "Missing copyright: $$f"; \ + head -4 "$$f" | grep -q "SPDX-License-Identifier: MIT" || echo "Missing SPDX: $$f"; \ + done); \ + if [ -n "$$missing" ]; then echo "$$missing"; exit 1; fi + @echo "==> License header check passed" # Check formatting and linting without modifying files .PHONY: check diff --git a/cmd/project-api/main.go b/cmd/project-api/main.go index 5f4da3d..8975412 100644 --- a/cmd/project-api/main.go +++ b/cmd/project-api/main.go @@ -24,11 +24,12 @@ import ( genhttp "github.com/linuxfoundation/lfx-v2-project-service/api/project/v1/gen/http/project_service/server" genquerysvc "github.com/linuxfoundation/lfx-v2-project-service/api/project/v1/gen/project_service" + "github.com/linuxfoundation/lfx-v2-project-service/internal/domain" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/auth" + "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/log" + "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/middleware" internalnats "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/nats" - "github.com/linuxfoundation/lfx-v2-project-service/internal/log" - "github.com/linuxfoundation/lfx-v2-project-service/internal/middleware" "github.com/linuxfoundation/lfx-v2-project-service/internal/service" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" "github.com/linuxfoundation/lfx-v2-project-service/pkg/utils" @@ -94,7 +95,8 @@ func main() { // Generated service initialization. service := service.NewProjectsService(jwtAuth, service.ServiceConfig{ - SkipEtagValidation: env.SkipEtagValidation, + SkipEtagValidation: env.SkipEtagValidation, + LFXSelfServeBaseURL: env.LFXSelfServeBaseURL, }) svc := NewProjectsAPI(service) @@ -156,9 +158,10 @@ func parseFlags(defaultPort string) flags { // environment are the environment variables for the project service. type environment struct { - NatsURL string - Port string - SkipEtagValidation bool + NatsURL string + Port string + SkipEtagValidation bool + LFXSelfServeBaseURL string } func parseEnv() environment { @@ -175,10 +178,22 @@ func parseEnv() environment { if skipEtagValidationStr == "true" { skipEtagValidation = true } + lfxSelfServeBaseURL := os.Getenv("LFX_SELF_SERVE_BASE_URL") + if lfxSelfServeBaseURL == "" { + switch os.Getenv("LFX_ENVIRONMENT") { + case "prod": + lfxSelfServeBaseURL = "https://app.lfx.dev" + case "staging", "stg": + lfxSelfServeBaseURL = "https://staging.app.lfx.dev" + default: + lfxSelfServeBaseURL = "https://dev.app.lfx.dev" + } + } return environment{ - NatsURL: natsURL, - Port: port, - SkipEtagValidation: skipEtagValidation, + NatsURL: natsURL, + Port: port, + SkipEtagValidation: skipEtagValidation, + LFXSelfServeBaseURL: lfxSelfServeBaseURL, } } @@ -330,6 +345,9 @@ func setupNATS(ctx context.Context, env environment, svc *ProjectsAPI, gracefulC svc.service.MessageBuilder = &internalnats.MessageBuilder{ NatsConn: natsConn, } + svc.service.UserReader = &internalnats.UserReaderNATS{ + NatsConn: natsConn, + } // Create NATS subscriptions for the service. err = createNatsSubcriptions(ctx, svc, natsConn) @@ -422,6 +440,26 @@ func createNatsSubcriptions(ctx context.Context, svc *ProjectsAPI, natsConn *nat } } + type eventHandler struct { + subject string + handle func(ctx context.Context, msg domain.Message) error + } + for _, eh := range []eventHandler{ + {constants.ProjectSettingsUpdatedSubject, svc.service.HandleProjectSettingsUpdated}, + } { + slog.With("subject", eh.subject, "queue", queueName).Debug("subscribing to NATS subject") + _, err := natsConn.QueueSubscribe(eh.subject, queueName, func(msg *nats.Msg) { + natsMsg := &internalnats.NatsMsg{Msg: msg} + if handlerErr := eh.handle(ctx, natsMsg); handlerErr != nil { + slog.WarnContext(ctx, "event handler failed", errKey, handlerErr, "subject", eh.subject) + } + }) + if err != nil { + slog.ErrorContext(ctx, "error creating NATS queue subscription", errKey, err) + return err + } + } + return nil } diff --git a/go.mod b/go.mod index f02288c..46786e5 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/google/uuid v1.6.0 + github.com/linuxfoundation/lfx-v2-email-service v0.0.0-20260512222125-bfe795580bd6 + github.com/linuxfoundation/lfx-v2-fga-sync v0.2.17 github.com/linuxfoundation/lfx-v2-indexer-service v0.4.14-0.20260109191409-7371e293d8b5 github.com/nats-io/nats.go v1.47.0 github.com/remychantenay/slog-otel v1.3.4 @@ -63,23 +65,18 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/klauspost/compress v1.18.1 // indirect - github.com/linuxfoundation/lfx-v2-fga-sync v0.2.17 // indirect github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect - github.com/openfga/go-sdk v0.7.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rustyoz/Mtransform v0.0.0-20250628105438-00796a985d0a // indirect github.com/rustyoz/genericlexer v0.0.0-20250522144106-d3cfee480384 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect github.com/stretchr/objx v0.5.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.uber.org/atomic v1.7.0 // indirect - go.uber.org/multierr v1.9.0 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.49.0 // indirect diff --git a/go.sum b/go.sum index b4266ec..e69254c 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 h1:SKI1/fuSdodxmNNyVBR8d7X/HuLnRpvvFO0AgyQk764= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 h1:MGKhKyiYrvMDZsmLR/+RGffQSXwEkXgfLSA08qDn9AI= @@ -81,6 +79,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/linuxfoundation/lfx-v2-email-service v0.0.0-20260512222125-bfe795580bd6 h1:FysNg3z1JLyJushe0iHj7+2Myv3EzE9E3aBXdETYPHQ= +github.com/linuxfoundation/lfx-v2-email-service v0.0.0-20260512222125-bfe795580bd6/go.mod h1:gx+JU/rpQj62C4/GcEYzpZVFuZpcpaHGO14cEj/CGXM= github.com/linuxfoundation/lfx-v2-fga-sync v0.2.17 h1:ZW2PyrEPB6SmT14qa3qlrcU4rB/eKRumPUOwaoS5or4= github.com/linuxfoundation/lfx-v2-fga-sync v0.2.17/go.mod h1:075J7/39UbsuLsJCXgVkbpKK1VIuPa7gbyhLsoTBAXo= github.com/linuxfoundation/lfx-v2-indexer-service v0.4.14-0.20260109191409-7371e293d8b5 h1:Ic06r/3OO4wP2Y2eL9InXEKRSVq2cqbNGBTsdr493YA= @@ -95,9 +95,6 @@ github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/openfga/go-sdk v0.7.1 h1:ZFFDRoSWAHcbOzPFUWPLUpoIOJZRoQ6KgJp2vyfB82g= -github.com/openfga/go-sdk v0.7.1/go.mod h1:Fu00XYLWkfgmo3PV45EwSOhpaBNcuVMBOdklpKoaazw= -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/remychantenay/slog-otel v1.3.4 h1:xoM41ayLff2U8zlK5PH31XwD7Lk3W9wKfl4+RcmKom4= @@ -110,12 +107,8 @@ github.com/rustyoz/genericlexer v0.0.0-20250522144106-d3cfee480384 h1:jrCaAewj72 github.com/rustyoz/genericlexer v0.0.0-20250522144106-d3cfee480384/go.mod h1:m65JtsVg785EjQvQylesseVucezoQZqJozlPAfjXmbE= github.com/rustyoz/svg v0.0.0-20250705135709-8b1786137cb3 h1:dFappt+gj/o9cCFfMmXV8Jq+hShQmFlM6Uh2Vd0YlzE= github.com/rustyoz/svg v0.0.0-20250705135709-8b1786137cb3/go.mod h1:33v4CGNONT4+QIwIt3o1GVdBqTfLCeptHNc0HpZ1N14= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -156,12 +149,8 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= goa.design/goa/v3 v3.22.6 h1:D2qDkAvdpf6ePr2iXKT+Ple5WDrjyes3iOfYD2yCpw0= goa.design/goa/v3 v3.22.6/go.mod h1:rhssEXxox3+sKnYp18hPNFCz65I4hLWHEtJKewoNJWk= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= diff --git a/internal/domain/message.go b/internal/domain/message.go index cea4d9f..d675673 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -5,6 +5,8 @@ package domain import ( "context" + + emailapi "github.com/linuxfoundation/lfx-v2-email-service/pkg/api" ) // Message represents a domain message interface @@ -24,4 +26,5 @@ type MessageBuilder interface { SendIndexerMessage(ctx context.Context, subject string, message any, sync bool) error SendAccessMessage(ctx context.Context, subject string, message any, sync bool) error SendProjectEventMessage(ctx context.Context, subject string, message any) error + SendEmailRequest(ctx context.Context, req emailapi.SendEmailRequest) error } diff --git a/internal/domain/mock.go b/internal/domain/mock.go index 2670b32..52ba3e6 100644 --- a/internal/domain/mock.go +++ b/internal/domain/mock.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/mock" + emailapi "github.com/linuxfoundation/lfx-v2-email-service/pkg/api" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" ) @@ -250,6 +251,11 @@ func (m *MockMessageBuilder) SendProjectEventMessage(ctx context.Context, subjec return args.Error(0) } +func (m *MockMessageBuilder) SendEmailRequest(ctx context.Context, req emailapi.SendEmailRequest) error { + args := m.Called(ctx, req) + return args.Error(0) +} + // MockMessage implements Message for testing type MockMessage struct { mock.Mock diff --git a/internal/domain/models/message.go b/internal/domain/models/message.go deleted file mode 100644 index 6354d6b..0000000 --- a/internal/domain/models/message.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -package models - -// ProjectSettingsUpdatedMessage is a NATS message published when project settings are updated. -// It contains both the before and after states to allow downstream services to react to changes. -type ProjectSettingsUpdatedMessage struct { - ProjectUID string `json:"project_uid"` - OldSettings ProjectSettings `json:"old_settings"` - NewSettings ProjectSettings `json:"new_settings"` -} diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 0000000..447deca --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,19 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package domain + +import "context" + +// UserMetadata holds profile information for a user returned by the auth service. +type UserMetadata struct { + Name string + GivenName string + FamilyName string +} + +// UserReader retrieves user profile information from the auth service. +type UserReader interface { + // UserMetadataByPrincipal retrieves profile metadata for a user by their principal. + UserMetadataByPrincipal(ctx context.Context, principal string) (*UserMetadata, error) +} diff --git a/internal/log/log.go b/internal/infrastructure/log/log.go similarity index 100% rename from internal/log/log.go rename to internal/infrastructure/log/log.go diff --git a/internal/log/log_test.go b/internal/infrastructure/log/log_test.go similarity index 100% rename from internal/log/log_test.go rename to internal/infrastructure/log/log_test.go diff --git a/internal/middleware/authorization.go b/internal/infrastructure/middleware/authorization.go similarity index 100% rename from internal/middleware/authorization.go rename to internal/infrastructure/middleware/authorization.go diff --git a/internal/middleware/authorization_test.go b/internal/infrastructure/middleware/authorization_test.go similarity index 100% rename from internal/middleware/authorization_test.go rename to internal/infrastructure/middleware/authorization_test.go diff --git a/internal/middleware/body_limit.go b/internal/infrastructure/middleware/body_limit.go similarity index 100% rename from internal/middleware/body_limit.go rename to internal/infrastructure/middleware/body_limit.go diff --git a/internal/middleware/body_limit_test.go b/internal/infrastructure/middleware/body_limit_test.go similarity index 100% rename from internal/middleware/body_limit_test.go rename to internal/infrastructure/middleware/body_limit_test.go diff --git a/internal/middleware/request_id.go b/internal/infrastructure/middleware/request_id.go similarity index 95% rename from internal/middleware/request_id.go rename to internal/infrastructure/middleware/request_id.go index 326635c..8c61275 100644 --- a/internal/middleware/request_id.go +++ b/internal/infrastructure/middleware/request_id.go @@ -9,7 +9,7 @@ import ( "log/slog" "net/http" - "github.com/linuxfoundation/lfx-v2-project-service/internal/log" + "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/log" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" "github.com/google/uuid" diff --git a/internal/middleware/request_id_test.go b/internal/infrastructure/middleware/request_id_test.go similarity index 100% rename from internal/middleware/request_id_test.go rename to internal/infrastructure/middleware/request_id_test.go diff --git a/internal/middleware/request_logger.go b/internal/infrastructure/middleware/request_logger.go similarity index 96% rename from internal/middleware/request_logger.go rename to internal/infrastructure/middleware/request_logger.go index 10489b6..de770d6 100644 --- a/internal/middleware/request_logger.go +++ b/internal/infrastructure/middleware/request_logger.go @@ -8,7 +8,7 @@ import ( "net/http" "time" - "github.com/linuxfoundation/lfx-v2-project-service/internal/log" + "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/log" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" ) diff --git a/internal/middleware/request_logger_test.go b/internal/infrastructure/middleware/request_logger_test.go similarity index 100% rename from internal/middleware/request_logger_test.go rename to internal/infrastructure/middleware/request_logger_test.go diff --git a/internal/infrastructure/nats/message.go b/internal/infrastructure/nats/message.go index 7c7c879..0cbfe5f 100644 --- a/internal/infrastructure/nats/message.go +++ b/internal/infrastructure/nats/message.go @@ -11,6 +11,7 @@ import ( "time" "github.com/go-viper/mapstructure/v2" + emailapi "github.com/linuxfoundation/lfx-v2-email-service/pkg/api" fgatypes "github.com/linuxfoundation/lfx-v2-fga-sync/pkg/types" indexerConstants "github.com/linuxfoundation/lfx-v2-indexer-service/pkg/constants" indexerTypes "github.com/linuxfoundation/lfx-v2-indexer-service/pkg/types" @@ -197,3 +198,30 @@ func (m *MessageBuilder) SendProjectEventMessage(ctx context.Context, subject st slog.DebugContext(ctx, "published project event message to NATS", "subject", subject) return nil } + +// SendEmailRequest sends a request to the email service and waits for a reply. +func (m *MessageBuilder) SendEmailRequest(ctx context.Context, req emailapi.SendEmailRequest) error { + data, err := json.Marshal(req) + if err != nil { + slog.ErrorContext(ctx, "error marshalling email request into JSON", constants.ErrKey, err) + return err + } + + reply, err := m.NatsConn.RequestMsgWithContext(ctx, &nats.Msg{ + Subject: emailapi.SendEmailSubject, + Data: data, + }) + if err != nil { + slog.ErrorContext(ctx, "email service request failed", constants.ErrKey, err) + return fmt.Errorf("email service request: %w", err) + } + + if len(reply.Data) > 0 { + var errResp emailapi.SendEmailErrorResponse + if jsonErr := json.Unmarshal(reply.Data, &errResp); jsonErr == nil && errResp.Error != "" { + return fmt.Errorf("email service error: %s", errResp.Error) + } + } + + return nil +} diff --git a/internal/infrastructure/nats/message_test.go b/internal/infrastructure/nats/message_test.go index b0c4af0..1a87174 100644 --- a/internal/infrastructure/nats/message_test.go +++ b/internal/infrastructure/nats/message_test.go @@ -9,12 +9,14 @@ import ( "errors" "testing" + emailapi "github.com/linuxfoundation/lfx-v2-email-service/pkg/api" fgaconstants "github.com/linuxfoundation/lfx-v2-fga-sync/pkg/constants" fgatypes "github.com/linuxfoundation/lfx-v2-fga-sync/pkg/types" indexerConstants "github.com/linuxfoundation/lfx-v2-indexer-service/pkg/constants" indexerTypes "github.com/linuxfoundation/lfx-v2-indexer-service/pkg/types" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" + "github.com/linuxfoundation/lfx-v2-project-service/pkg/events" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -418,6 +420,74 @@ func TestMessageBuilder_PublishAccessMessage_Sync(t *testing.T) { } } +func TestMessageBuilder_SendEmailRequest(t *testing.T) { + req := emailapi.SendEmailRequest{ + To: "alice@example.com", + Subject: "You've been added", + HTML: "

Hi Alice

", + Text: "Hi Alice", + } + + tests := []struct { + name string + mockSetup func(*MockNATSConn) + wantErr bool + }{ + { + name: "success — empty reply body", + mockSetup: func(m *MockNATSConn) { + m.On("RequestMsgWithContext", mock.Anything, mock.MatchedBy(func(msg *nats.Msg) bool { + return msg.Subject == emailapi.SendEmailSubject + })).Return(&nats.Msg{Data: nil}, nil) + }, + wantErr: false, + }, + { + name: "success — non-error reply body", + mockSetup: func(m *MockNATSConn) { + m.On("RequestMsgWithContext", mock.Anything, mock.MatchedBy(func(msg *nats.Msg) bool { + return msg.Subject == emailapi.SendEmailSubject + })).Return(&nats.Msg{Data: []byte(`{}`)}, nil) + }, + wantErr: false, + }, + { + name: "NATS transport error", + mockSetup: func(m *MockNATSConn) { + m.On("RequestMsgWithContext", mock.Anything, mock.Anything). + Return(nil, errors.New("connection closed")) + }, + wantErr: true, + }, + { + name: "email service returns error response", + mockSetup: func(m *MockNATSConn) { + errBody, _ := json.Marshal(emailapi.SendEmailErrorResponse{Error: "smtp refused"}) + m.On("RequestMsgWithContext", mock.Anything, mock.Anything). + Return(&nats.Msg{Data: errBody}, nil) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockConn := &MockNATSConn{} + tt.mockSetup(mockConn) + + mb := &MessageBuilder{NatsConn: mockConn} + err := mb.SendEmailRequest(context.Background(), req) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + mockConn.AssertExpectations(t) + }) + } +} + func TestMessageBuilder_SendProjectEventMessage(t *testing.T) { tests := []struct { name string @@ -429,20 +499,20 @@ func TestMessageBuilder_SendProjectEventMessage(t *testing.T) { { name: "successful send project settings updated message", subject: constants.ProjectSettingsUpdatedSubject, - message: models.ProjectSettingsUpdatedMessage{ + message: events.ProjectSettingsUpdatedMessage{ ProjectUID: "test-project-uid", - OldSettings: models.ProjectSettings{ + OldSettings: events.ProjectSettings{ UID: "test-project-uid", MissionStatement: "old mission", }, - NewSettings: models.ProjectSettings{ + NewSettings: events.ProjectSettings{ UID: "test-project-uid", MissionStatement: "new mission", }, }, setupMocks: func(mockConn *MockNATSConn) { mockConn.On("Publish", constants.ProjectSettingsUpdatedSubject, mock.MatchedBy(func(data []byte) bool { - var msg models.ProjectSettingsUpdatedMessage + var msg events.ProjectSettingsUpdatedMessage err := json.Unmarshal(data, &msg) if err != nil { return false @@ -457,10 +527,10 @@ func TestMessageBuilder_SendProjectEventMessage(t *testing.T) { { name: "nats publish error", subject: constants.ProjectSettingsUpdatedSubject, - message: models.ProjectSettingsUpdatedMessage{ + message: events.ProjectSettingsUpdatedMessage{ ProjectUID: "test-project-uid", - OldSettings: models.ProjectSettings{UID: "test"}, - NewSettings: models.ProjectSettings{UID: "test"}, + OldSettings: events.ProjectSettings{UID: "test"}, + NewSettings: events.ProjectSettings{UID: "test"}, }, setupMocks: func(mockConn *MockNATSConn) { mockConn.On("Publish", constants.ProjectSettingsUpdatedSubject, mock.AnythingOfType("[]uint8")).Return(errors.New("nats error")) diff --git a/internal/infrastructure/nats/mock.go b/internal/infrastructure/nats/mock.go index 82d8112..e7359d5 100644 --- a/internal/infrastructure/nats/mock.go +++ b/internal/infrastructure/nats/mock.go @@ -20,6 +20,7 @@ type INatsConn interface { IsConnected() bool Publish(subj string, data []byte) error Request(subj string, data []byte, timeout time.Duration) (*nats.Msg, error) + RequestMsgWithContext(ctx context.Context, msg *nats.Msg) (*nats.Msg, error) } // MockNATSConn is a mock implementation of the [INatsConn] interface. @@ -48,6 +49,15 @@ func (m *MockNATSConn) Request(subj string, data []byte, timeout time.Duration) return args.Get(0).(*nats.Msg), args.Error(1) } +// RequestMsgWithContext is a mock method for the [INatsConn] interface. +func (m *MockNATSConn) RequestMsgWithContext(ctx context.Context, msg *nats.Msg) (*nats.Msg, error) { + args := m.Called(ctx, msg) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*nats.Msg), args.Error(1) +} + // INatsMsg is an interface for [nats.Msg] that allows for mocking. type INatsMsg interface { Respond(data []byte) error diff --git a/internal/infrastructure/nats/user_reader.go b/internal/infrastructure/nats/user_reader.go new file mode 100644 index 0000000..74c6740 --- /dev/null +++ b/internal/infrastructure/nats/user_reader.go @@ -0,0 +1,66 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package nats + +import ( + "context" + "encoding/json" + "fmt" + + natsgo "github.com/nats-io/nats.go" + + "github.com/linuxfoundation/lfx-v2-project-service/internal/domain" + "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" +) + +// userMetadataNATSResponse is the response envelope from lfx.auth-service.user_metadata.read. +type userMetadataNATSResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Data *userMetadataNATSDataBody `json:"data,omitempty"` +} + +// userMetadataNATSDataBody holds the profile fields from the auth-service response. +type userMetadataNATSDataBody struct { + Name *string `json:"name,omitempty"` + GivenName *string `json:"given_name,omitempty"` + FamilyName *string `json:"family_name,omitempty"` +} + +// UserReaderNATS implements domain.UserReader via NATS requests to the auth service. +type UserReaderNATS struct { + NatsConn INatsConn +} + +// UserMetadataByPrincipal retrieves profile metadata for a user from the auth service by principal. +func (u *UserReaderNATS) UserMetadataByPrincipal(ctx context.Context, principal string) (*domain.UserMetadata, error) { + reply, err := u.NatsConn.RequestMsgWithContext(ctx, &natsgo.Msg{ + Subject: constants.AuthUserMetadataReadSubject, + Data: []byte(principal), + }) + if err != nil { + return nil, err + } + + var response userMetadataNATSResponse + if err := json.Unmarshal(reply.Data, &response); err != nil { + return nil, fmt.Errorf("failed to parse user_metadata response: %w", err) + } + + if !response.Success || response.Data == nil { + return nil, fmt.Errorf("user metadata not found for principal") + } + + result := &domain.UserMetadata{} + if response.Data.Name != nil { + result.Name = *response.Data.Name + } + if response.Data.GivenName != nil { + result.GivenName = *response.Data.GivenName + } + if response.Data.FamilyName != nil { + result.FamilyName = *response.Data.FamilyName + } + return result, nil +} diff --git a/internal/service/converters.go b/internal/service/converters.go index 06a5980..084c619 100644 --- a/internal/service/converters.go +++ b/internal/service/converters.go @@ -10,6 +10,7 @@ import ( fgatypes "github.com/linuxfoundation/lfx-v2-fga-sync/pkg/types" projsvc "github.com/linuxfoundation/lfx-v2-project-service/api/project/v1/gen/project_service" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" + "github.com/linuxfoundation/lfx-v2-project-service/pkg/events" "github.com/linuxfoundation/lfx-v2-project-service/pkg/misc" ) @@ -553,6 +554,55 @@ func buildFGAUpdateAccessMessage(projectDB *models.ProjectBase, projectSettingsD } } +// DomainSettingsToEvent converts an internal ProjectSettings domain model to +// its event wire type for publishing on NATS. +func DomainSettingsToEvent(s *models.ProjectSettings) events.ProjectSettings { + if s == nil { + return events.ProjectSettings{} + } + return events.ProjectSettings{ + UID: s.UID, + MissionStatement: s.MissionStatement, + AnnouncementDate: s.AnnouncementDate, + Auditors: domainUsersToEvent(s.Auditors), + Writers: domainUsersToEvent(s.Writers), + MeetingCoordinators: domainUsersToEvent(s.MeetingCoordinators), + ExecutiveDirector: domainUserPtrToEvent(s.ExecutiveDirector), + ProgramManager: domainUserPtrToEvent(s.ProgramManager), + OpportunityOwner: domainUserPtrToEvent(s.OpportunityOwner), + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } +} + +func domainUsersToEvent(users []models.UserInfo) []events.UserInfo { + if users == nil { + return nil + } + result := make([]events.UserInfo, len(users)) + for i, u := range users { + result[i] = events.UserInfo{ + Name: u.Name, + Email: u.Email, + Username: u.Username, + Avatar: u.Avatar, + } + } + return result +} + +func domainUserPtrToEvent(u *models.UserInfo) *events.UserInfo { + if u == nil { + return nil + } + return &events.UserInfo{ + Name: u.Name, + Email: u.Email, + Username: u.Username, + Avatar: u.Avatar, + } +} + // createTestUserInfo creates a UserInfo for testing purposes func createTestUserInfo(username, name, email, avatar string) models.UserInfo { return models.UserInfo{ diff --git a/internal/service/converters_test.go b/internal/service/converters_test.go index f5b0723..78e60a8 100644 --- a/internal/service/converters_test.go +++ b/internal/service/converters_test.go @@ -9,6 +9,7 @@ import ( projsvc "github.com/linuxfoundation/lfx-v2-project-service/api/project/v1/gen/project_service" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" + "github.com/linuxfoundation/lfx-v2-project-service/pkg/events" "github.com/linuxfoundation/lfx-v2-project-service/pkg/misc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -458,3 +459,93 @@ func TestConvertToServiceProjectSettings(t *testing.T) { }) } } + +func TestDomainSettingsToEvent(t *testing.T) { + now := time.Now() + tests := []struct { + name string + input *models.ProjectSettings + expected events.ProjectSettings + }{ + { + name: "nil input returns zero value", + input: nil, + expected: events.ProjectSettings{}, + }, + { + name: "full settings mapped correctly", + input: &models.ProjectSettings{ + UID: "uid-1", + MissionStatement: "test mission", + AnnouncementDate: &now, + Auditors: []models.UserInfo{ + {Name: "A", Email: "a@example.com", Username: "auser", Avatar: "a.png"}, + }, + Writers: []models.UserInfo{ + {Name: "W", Email: "w@example.com", Username: "wuser", Avatar: "w.png"}, + }, + MeetingCoordinators: []models.UserInfo{ + {Name: "M", Email: "m@example.com", Username: "muser", Avatar: "m.png"}, + }, + ExecutiveDirector: &models.UserInfo{Name: "ED", Email: "ed@example.com", Username: "eduser", Avatar: "ed.png"}, + ProgramManager: &models.UserInfo{Name: "PM", Email: "pm@example.com", Username: "pmuser", Avatar: "pm.png"}, + OpportunityOwner: &models.UserInfo{Name: "OO", Email: "oo@example.com", Username: "oouser", Avatar: "oo.png"}, + CreatedAt: &now, + UpdatedAt: &now, + }, + expected: events.ProjectSettings{ + UID: "uid-1", + MissionStatement: "test mission", + AnnouncementDate: &now, + Auditors: []events.UserInfo{{Name: "A", Email: "a@example.com", Username: "auser", Avatar: "a.png"}}, + Writers: []events.UserInfo{{Name: "W", Email: "w@example.com", Username: "wuser", Avatar: "w.png"}}, + MeetingCoordinators: []events.UserInfo{ + {Name: "M", Email: "m@example.com", Username: "muser", Avatar: "m.png"}, + }, + ExecutiveDirector: &events.UserInfo{Name: "ED", Email: "ed@example.com", Username: "eduser", Avatar: "ed.png"}, + ProgramManager: &events.UserInfo{Name: "PM", Email: "pm@example.com", Username: "pmuser", Avatar: "pm.png"}, + OpportunityOwner: &events.UserInfo{Name: "OO", Email: "oo@example.com", Username: "oouser", Avatar: "oo.png"}, + CreatedAt: &now, + UpdatedAt: &now, + }, + }, + { + name: "nil optional pointers produce nil in output", + input: &models.ProjectSettings{ + UID: "uid-2", + MissionStatement: "no optionals", + }, + expected: events.ProjectSettings{ + UID: "uid-2", + MissionStatement: "no optionals", + }, + }, + { + name: "nil user slice preserves nil (serializes as JSON null not [])", + input: &models.ProjectSettings{ + UID: "uid-3", + Writers: nil, + }, + expected: events.ProjectSettings{ + UID: "uid-3", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DomainSettingsToEvent(tt.input) + assert.Equal(t, tt.expected.UID, result.UID) + assert.Equal(t, tt.expected.MissionStatement, result.MissionStatement) + assert.Equal(t, tt.expected.AnnouncementDate, result.AnnouncementDate) + assert.Equal(t, tt.expected.Auditors, result.Auditors) + assert.Equal(t, tt.expected.Writers, result.Writers) + assert.Equal(t, tt.expected.MeetingCoordinators, result.MeetingCoordinators) + assert.Equal(t, tt.expected.ExecutiveDirector, result.ExecutiveDirector) + assert.Equal(t, tt.expected.ProgramManager, result.ProgramManager) + assert.Equal(t, tt.expected.OpportunityOwner, result.OpportunityOwner) + assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt) + assert.Equal(t, tt.expected.UpdatedAt, result.UpdatedAt) + }) + } +} diff --git a/internal/service/document_operations.go b/internal/service/document_operations.go index b946483..4c76465 100644 --- a/internal/service/document_operations.go +++ b/internal/service/document_operations.go @@ -17,7 +17,7 @@ import ( indexerTypes "github.com/linuxfoundation/lfx-v2-indexer-service/pkg/types" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" - "github.com/linuxfoundation/lfx-v2-project-service/internal/log" + "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/log" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" ) diff --git a/internal/service/email/project_role_notification.go b/internal/service/email/project_role_notification.go new file mode 100644 index 0000000..eeddde1 --- /dev/null +++ b/internal/service/email/project_role_notification.go @@ -0,0 +1,53 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package email + +import ( + "bytes" + "embed" + htmltemplate "html/template" + texttemplate "text/template" +) + +//go:embed templates/project_role_notification.html templates/project_role_notification.txt +var notificationTemplates embed.FS + +var ( + projectRoleHTMLTemplate = htmltemplate.Must( + htmltemplate.New("project_role_notification.html"). + ParseFS(notificationTemplates, "templates/project_role_notification.html"), + ) + projectRoleTextTemplate = texttemplate.Must( + texttemplate.New("project_role_notification.txt"). + ParseFS(notificationTemplates, "templates/project_role_notification.txt"), + ) +) + +// ProjectRoleNotificationData holds the template variables for a project role notification email. +type ProjectRoleNotificationData struct { + RecipientName string + ProjectName string + Role string + ProjectURL string + InviterName string +} + +// RenderProjectRoleNotification renders the subject, HTML body, and plain-text body +// for a project role notification email. +func RenderProjectRoleNotification(data ProjectRoleNotificationData) (subject, html, text string, err error) { + subject = data.InviterName + " added you as a " + data.Role + " on " + data.ProjectName + + var htmlBuf bytes.Buffer + if err = projectRoleHTMLTemplate.Execute(&htmlBuf, data); err != nil { + return + } + html = htmlBuf.String() + + var textBuf bytes.Buffer + if err = projectRoleTextTemplate.Execute(&textBuf, data); err != nil { + return + } + text = textBuf.String() + return +} diff --git a/internal/service/email/project_role_notification_test.go b/internal/service/email/project_role_notification_test.go new file mode 100644 index 0000000..6514d46 --- /dev/null +++ b/internal/service/email/project_role_notification_test.go @@ -0,0 +1,43 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package email + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderProjectRoleNotification(t *testing.T) { + data := ProjectRoleNotificationData{ + RecipientName: "Alice", + ProjectName: "Demo Project", + Role: "Writer", + ProjectURL: "https://dev.app.lfx.dev/projects/demo-project", + InviterName: "Bob", + } + + subject, html, text, err := RenderProjectRoleNotification(data) + require.NoError(t, err) + + assert.Contains(t, subject, "Writer") + assert.Contains(t, subject, "Demo Project") + assert.Contains(t, subject, "Bob") + + assert.Contains(t, html, "Alice") + assert.Contains(t, html, "Demo Project") + assert.Contains(t, html, "Writer") + assert.Contains(t, html, "https://dev.app.lfx.dev/projects/demo-project") + assert.Contains(t, html, "Bob") + assert.True(t, strings.Contains(html, " + + + + + + + + + You've been added to {{.ProjectName}} + + + +
+ + +
+ +

Hi {{.RecipientName}},

+ +

{{.InviterName}} has added you as a {{.Role}} on the {{.ProjectName}} project in LFX Self-Serve.

+

You can view the project and manage your responsibilities by visiting the link below.

+ + View Project +

If the button above doesn't work, paste this into your browser: {{.ProjectURL}}

+ +

Thank you,
The Linux Foundation Team

+ + + + +
+ + diff --git a/internal/service/email/templates/project_role_notification.txt b/internal/service/email/templates/project_role_notification.txt new file mode 100644 index 0000000..d235e4d --- /dev/null +++ b/internal/service/email/templates/project_role_notification.txt @@ -0,0 +1,12 @@ +{{- /* Copyright The Linux Foundation and each contributor to LFX. */ -}} +{{- /* SPDX-License-Identifier: MIT */ -}} +Hi {{.RecipientName}}, + +{{.InviterName}} has added you as a {{.Role}} on the {{.ProjectName}} project in LFX Self-Serve. + +You can view the project here: +{{.ProjectURL}} + +--- +You are receiving this email because you were added to a project on LFX Self-Serve. +© The Linux Foundation diff --git a/internal/service/folder_operations.go b/internal/service/folder_operations.go index 95e5f84..cfe8d47 100644 --- a/internal/service/folder_operations.go +++ b/internal/service/folder_operations.go @@ -15,7 +15,7 @@ import ( indexerTypes "github.com/linuxfoundation/lfx-v2-indexer-service/pkg/types" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" - "github.com/linuxfoundation/lfx-v2-project-service/internal/log" + "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/log" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" ) diff --git a/internal/service/link_operations.go b/internal/service/link_operations.go index 04da78c..43b7eaa 100644 --- a/internal/service/link_operations.go +++ b/internal/service/link_operations.go @@ -16,7 +16,7 @@ import ( indexerTypes "github.com/linuxfoundation/lfx-v2-indexer-service/pkg/types" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" - "github.com/linuxfoundation/lfx-v2-project-service/internal/log" + "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/log" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" ) diff --git a/internal/service/project_handlers.go b/internal/service/project_handlers.go index 4c54b06..258d56b 100644 --- a/internal/service/project_handlers.go +++ b/internal/service/project_handlers.go @@ -11,7 +11,7 @@ import ( "github.com/google/uuid" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain" - "github.com/linuxfoundation/lfx-v2-project-service/internal/log" + "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/log" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" structs "github.com/linuxfoundation/lfx-v2-project-service/pkg/struct" ) diff --git a/internal/service/project_operations.go b/internal/service/project_operations.go index 0bd4c16..a93437a 100644 --- a/internal/service/project_operations.go +++ b/internal/service/project_operations.go @@ -18,8 +18,9 @@ import ( projsvc "github.com/linuxfoundation/lfx-v2-project-service/api/project/v1/gen/project_service" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" - "github.com/linuxfoundation/lfx-v2-project-service/internal/log" + "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/log" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" + "github.com/linuxfoundation/lfx-v2-project-service/pkg/events" "github.com/linuxfoundation/lfx-v2-project-service/pkg/misc" "golang.org/x/sync/errgroup" ) @@ -584,10 +585,12 @@ func (s *ProjectsService) UpdateProjectSettings(ctx context.Context, payload *pr }) g.Go(func() error { - msg := models.ProjectSettingsUpdatedMessage{ + principal, _ := ctx.Value(constants.PrincipalContextID).(string) + msg := events.ProjectSettingsUpdatedMessage{ ProjectUID: *payload.UID, - OldSettings: *existingProjectSettingsDB, - NewSettings: *projectSettingsDB, + OldSettings: DomainSettingsToEvent(existingProjectSettingsDB), + NewSettings: DomainSettingsToEvent(projectSettingsDB), + Actor: events.Actor{Username: principal}, } return s.MessageBuilder.SendProjectEventMessage(ctx, constants.ProjectSettingsUpdatedSubject, msg) }) diff --git a/internal/service/project_service.go b/internal/service/project_service.go index 76e0bc1..5467fa1 100644 --- a/internal/service/project_service.go +++ b/internal/service/project_service.go @@ -14,6 +14,7 @@ type ProjectsService struct { LinkRepository domain.LinkRepository FolderRepository domain.FolderRepository MessageBuilder domain.MessageBuilder + UserReader domain.UserReader Auth domain.Authenticator Config ServiceConfig } @@ -36,4 +37,6 @@ func (s *ProjectsService) ServiceReady() bool { type ServiceConfig struct { // SkipEtagValidation is a flag to skip the Etag validation - only meant for local development. SkipEtagValidation bool + // LFXSelfServeBaseURL is the base URL for LFX Self-Serve, used to build project URLs in notification emails. + LFXSelfServeBaseURL string } diff --git a/internal/service/project_subscriber.go b/internal/service/project_subscriber.go new file mode 100644 index 0000000..b3499b0 --- /dev/null +++ b/internal/service/project_subscriber.go @@ -0,0 +1,203 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package service + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" + + emailapi "github.com/linuxfoundation/lfx-v2-email-service/pkg/api" + "github.com/linuxfoundation/lfx-v2-project-service/internal/domain" + "github.com/linuxfoundation/lfx-v2-project-service/internal/service/email" + "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" + "github.com/linuxfoundation/lfx-v2-project-service/pkg/events" + "golang.org/x/sync/errgroup" +) + +const emailSendTimeout = 5 * time.Second + +// HandleProjectSettingsUpdated handles project_settings.updated events and sends +// notification emails to any users newly added as writers, auditors, or meeting coordinators. +// Errors from individual sends are logged but never returned — the handler is best-effort. +func (s *ProjectsService) HandleProjectSettingsUpdated(ctx context.Context, msg domain.Message) error { + var event events.ProjectSettingsUpdatedMessage + if err := json.Unmarshal(msg.Data(), &event); err != nil { + slog.WarnContext(ctx, "project_subscriber: failed to unmarshal project settings updated event", constants.ErrKey, err) + return nil + } + + additions := diffNewMembers(event.OldSettings, event.NewSettings) + slog.DebugContext(ctx, "project_subscriber: received project_settings.updated event", + "project_uid", event.ProjectUID, "new_member_count", len(additions)) + if len(additions) == 0 { + return nil + } + + projectBase, err := s.ProjectRepository.GetProjectBase(ctx, event.ProjectUID) + if err != nil { + slog.WarnContext(ctx, "project_subscriber: failed to load project", constants.ErrKey, err, "project_uid", event.ProjectUID) + return nil + } + + projectURL := strings.TrimRight(s.Config.LFXSelfServeBaseURL, "/") + "/projects/overview" + + inviterName := s.resolveActorDisplayName(ctx, event.Actor) + + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(5) + + for _, add := range additions { + add := add + g.Go(func() error { + if add.User.Email == "" { + slog.WarnContext(gctx, "project_subscriber: skipping email — recipient has no email address", + "role", add.Role, "username", add.User.Username, "project_uid", event.ProjectUID) + return nil + } + + recipientName := add.User.Name + if recipientName == "" { + recipientName = add.User.Username + } + if recipientName == "" { + recipientName = add.User.Email + } + + subject, html, text, err := email.RenderProjectRoleNotification(email.ProjectRoleNotificationData{ + RecipientName: recipientName, + ProjectName: projectBase.Name, + Role: add.Role, + ProjectURL: projectURL, + InviterName: inviterName, + }) + if err != nil { + slog.WarnContext(gctx, "project_subscriber: failed to render email template", + constants.ErrKey, err, "role", add.Role, "project_uid", event.ProjectUID) + return nil + } + + sendCtx, cancel := context.WithTimeout(gctx, emailSendTimeout) + defer cancel() + sendErr := s.MessageBuilder.SendEmailRequest(sendCtx, emailapi.SendEmailRequest{ + To: add.User.Email, + Subject: subject, + HTML: html, + Text: text, + }) + if sendErr != nil { + slog.WarnContext(gctx, "project_subscriber: failed to send role notification email", + constants.ErrKey, sendErr, "role", add.Role, "project_uid", event.ProjectUID) + } else { + slog.DebugContext(gctx, "project_subscriber: sent role notification email", + "role", add.Role, "project_uid", event.ProjectUID, "to", add.User.Email) + } + return nil + }) + } + + _ = g.Wait() + return nil +} + +// resolveActorDisplayName looks up the actor's display name from the auth service. +// Falls back to "A project administrator" if the lookup fails or returns no name. +func (s *ProjectsService) resolveActorDisplayName(ctx context.Context, actor events.Actor) string { + if actor.Name != "" { + return actor.Name + } + if actor.Username != "" && s.UserReader != nil { + lookupCtx, cancel := context.WithTimeout(ctx, emailSendTimeout) + defer cancel() + if meta, err := s.UserReader.UserMetadataByPrincipal(lookupCtx, actor.Username); err == nil && meta != nil { + if meta.Name != "" { + return meta.Name + } + if full := strings.TrimSpace(meta.GivenName + " " + meta.FamilyName); full != "" { + return full + } + } + } + return "A project administrator" +} + +// roleAssignment pairs a user with the role they were added to. +type roleAssignment struct { + User events.UserInfo + Role string +} + +// diffNewMembers returns the users that appear in newSettings but not in oldSettings, +// across writers, auditors, and meeting_coordinators. Users are matched by Username +// when present, otherwise by Email. Users with neither Username nor Email are skipped. +func diffNewMembers(oldSettings, newSettings events.ProjectSettings) []roleAssignment { + var additions []roleAssignment + additions = append(additions, diffRole(oldSettings.Writers, newSettings.Writers, "Writer")...) + additions = append(additions, diffRole(oldSettings.Auditors, newSettings.Auditors, "Auditor")...) + additions = append(additions, diffRole(oldSettings.MeetingCoordinators, newSettings.MeetingCoordinators, "Meeting Coordinator")...) + return additions +} + +func diffRole(old, new []events.UserInfo, role string) []roleAssignment { + // Index every identity key from old so a user matched by either + // username or email is recognised regardless of which field was set. + oldSet := make(map[string]struct{}, len(old)*2) + for _, u := range old { + for _, key := range memberKeys(u) { + oldSet[key] = struct{}{} + } + } + seenNew := make(map[string]struct{}, len(new)*2) + var additions []roleAssignment + for _, u := range new { + keys := memberKeys(u) + if len(keys) == 0 { + continue + } + // Skip if this user was already seen under any of their identity keys, + // covering cases where the same person appears with different identity + // shapes (e.g. username+email in one entry, email-only in another). + alreadySeen := false + for _, key := range keys { + if _, ok := seenNew[key]; ok { + alreadySeen = true + break + } + } + if alreadySeen { + continue + } + for _, key := range keys { + seenNew[key] = struct{}{} + } + // The user is already present if ANY of their keys appear in oldSet. + present := false + for _, key := range keys { + if _, ok := oldSet[key]; ok { + present = true + break + } + } + if !present { + additions = append(additions, roleAssignment{User: u, Role: role}) + } + } + return additions +} + +// memberKeys returns all stable identity keys for a user. +// Username key comes first (preferred); Email key is appended when present. +// Returns an empty slice if neither field is set. +func memberKeys(u events.UserInfo) []string { + var keys []string + if u.Username != "" { + keys = append(keys, "username:"+u.Username) + } + if u.Email != "" { + keys = append(keys, "email:"+u.Email) + } + return keys +} diff --git a/internal/service/project_subscriber_test.go b/internal/service/project_subscriber_test.go new file mode 100644 index 0000000..21ce62a --- /dev/null +++ b/internal/service/project_subscriber_test.go @@ -0,0 +1,269 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package service + +import ( + "context" + "encoding/json" + "testing" + + emailapi "github.com/linuxfoundation/lfx-v2-email-service/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/linuxfoundation/lfx-v2-project-service/internal/domain" + "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" + "github.com/linuxfoundation/lfx-v2-project-service/pkg/events" +) + +func marshalEvent(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return b +} + +func makeProjectBase(uid, name, slug string) *models.ProjectBase { + return &models.ProjectBase{UID: uid, Name: name, Slug: slug} +} + +func TestHandleProjectSettingsUpdated(t *testing.T) { + alice := events.UserInfo{Username: "alice", Email: "alice@example.com", Name: "Alice"} + bob := events.UserInfo{Username: "bob", Email: "bob@example.com", Name: "Bob"} + + tests := []struct { + name string + event events.ProjectSettingsUpdatedMessage + projectBase *models.ProjectBase + projectBaseErr error + wantSendCount int + msgBuilderErr error + }{ + { + name: "no additions — no emails sent", + event: events.ProjectSettingsUpdatedMessage{ + ProjectUID: "proj-1", + OldSettings: events.ProjectSettings{Writers: []events.UserInfo{alice}}, + NewSettings: events.ProjectSettings{Writers: []events.UserInfo{alice}}, + }, + // projectBase intentionally nil: handler returns before GetProjectBase when no additions found + wantSendCount: 0, + }, + { + name: "one writer added — one email sent", + event: events.ProjectSettingsUpdatedMessage{ + ProjectUID: "proj-1", + OldSettings: events.ProjectSettings{}, + NewSettings: events.ProjectSettings{Writers: []events.UserInfo{alice}}, + Actor: events.Actor{Username: "admin", Name: "Admin User"}, + }, + projectBase: makeProjectBase("proj-1", "Demo", "demo"), + wantSendCount: 1, + }, + { + name: "two users added across roles — two emails sent", + event: events.ProjectSettingsUpdatedMessage{ + ProjectUID: "proj-1", + OldSettings: events.ProjectSettings{}, + NewSettings: events.ProjectSettings{ + Writers: []events.UserInfo{alice}, + Auditors: []events.UserInfo{bob}, + }, + Actor: events.Actor{Username: "admin"}, + }, + projectBase: makeProjectBase("proj-1", "Demo", "demo"), + wantSendCount: 2, + }, + { + name: "send error on one — other still attempted", + event: events.ProjectSettingsUpdatedMessage{ + ProjectUID: "proj-1", + OldSettings: events.ProjectSettings{}, + NewSettings: events.ProjectSettings{Writers: []events.UserInfo{alice}}, + Actor: events.Actor{Username: "admin"}, + }, + projectBase: makeProjectBase("proj-1", "Demo", "demo"), + wantSendCount: 1, + msgBuilderErr: assert.AnError, + }, + { + name: "user without email address — skipped, no send", + event: events.ProjectSettingsUpdatedMessage{ + ProjectUID: "proj-1", + OldSettings: events.ProjectSettings{}, + NewSettings: events.ProjectSettings{Writers: []events.UserInfo{{Username: "noemail", Name: "No Email"}}}, + Actor: events.Actor{Username: "admin"}, + }, + projectBase: makeProjectBase("proj-1", "Demo", "demo"), + wantSendCount: 0, + }, + { + name: "project load failure — no email sent", + event: events.ProjectSettingsUpdatedMessage{ + ProjectUID: "proj-1", + OldSettings: events.ProjectSettings{}, + NewSettings: events.ProjectSettings{Writers: []events.UserInfo{alice}}, + }, + projectBase: nil, + projectBaseErr: assert.AnError, + wantSendCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := &domain.MockProjectRepository{} + mockMsg := &domain.MockMessageBuilder{} + + if tt.projectBase != nil || tt.projectBaseErr != nil { + mockRepo.On("GetProjectBase", mock.Anything, tt.event.ProjectUID). + Return(tt.projectBase, tt.projectBaseErr) + } + + if tt.wantSendCount > 0 { + mockMsg.On("SendEmailRequest", mock.Anything, mock.AnythingOfType("api.SendEmailRequest")). + Return(tt.msgBuilderErr).Times(tt.wantSendCount) + } + + svc := &ProjectsService{ + ProjectRepository: mockRepo, + MessageBuilder: mockMsg, + Config: ServiceConfig{ + LFXSelfServeBaseURL: "https://dev.app.lfx.dev", + }, + } + + msg := domain.NewMockMessage(marshalEvent(t, tt.event), "") + err := svc.HandleProjectSettingsUpdated(context.Background(), msg) + assert.NoError(t, err) + + mockMsg.AssertNumberOfCalls(t, "SendEmailRequest", tt.wantSendCount) + mockRepo.AssertExpectations(t) + mockMsg.AssertExpectations(t) + }) + } + + t.Run("invalid JSON — returns nil", func(t *testing.T) { + svc := &ProjectsService{} + msg := domain.NewMockMessage([]byte("not json"), "") + err := svc.HandleProjectSettingsUpdated(context.Background(), msg) + assert.NoError(t, err) + }) +} + +func TestDiffNewMembers(t *testing.T) { + alice := events.UserInfo{Username: "alice", Email: "alice@example.com", Name: "Alice"} + bob := events.UserInfo{Username: "bob", Email: "bob@example.com", Name: "Bob"} + noUsername := events.UserInfo{Email: "nouser@example.com", Name: "No Username"} + empty := events.UserInfo{} + + tests := []struct { + name string + old events.ProjectSettings + new events.ProjectSettings + wantLen int + wantContains []roleAssignment + }{ + { + name: "no changes", + old: events.ProjectSettings{Writers: []events.UserInfo{alice}}, + new: events.ProjectSettings{Writers: []events.UserInfo{alice}}, + }, + { + name: "writer added", + old: events.ProjectSettings{}, + new: events.ProjectSettings{Writers: []events.UserInfo{alice}}, + wantLen: 1, + wantContains: []roleAssignment{{User: alice, Role: "Writer"}}, + }, + { + name: "auditor added", + old: events.ProjectSettings{}, + new: events.ProjectSettings{Auditors: []events.UserInfo{bob}}, + wantLen: 1, + wantContains: []roleAssignment{{User: bob, Role: "Auditor"}}, + }, + { + name: "meeting coordinator added", + old: events.ProjectSettings{}, + new: events.ProjectSettings{MeetingCoordinators: []events.UserInfo{alice}}, + wantLen: 1, + wantContains: []roleAssignment{{User: alice, Role: "Meeting Coordinator"}}, + }, + { + name: "multiple roles added", + old: events.ProjectSettings{}, + new: events.ProjectSettings{ + Writers: []events.UserInfo{alice}, + Auditors: []events.UserInfo{bob}, + }, + wantLen: 2, + }, + { + name: "removal only — no additions", + old: events.ProjectSettings{Writers: []events.UserInfo{alice, bob}}, + new: events.ProjectSettings{Writers: []events.UserInfo{alice}}, + }, + { + name: "user with no username matched by email", + old: events.ProjectSettings{}, + new: events.ProjectSettings{Writers: []events.UserInfo{noUsername}}, + wantLen: 1, + wantContains: []roleAssignment{{User: noUsername, Role: "Writer"}}, + }, + { + name: "user with neither username nor email is skipped", + old: events.ProjectSettings{}, + new: events.ProjectSettings{Writers: []events.UserInfo{empty}}, + }, + { + name: "existing user with no username skipped in old set", + old: events.ProjectSettings{Writers: []events.UserInfo{noUsername}}, + new: events.ProjectSettings{Writers: []events.UserInfo{noUsername}}, + }, + { + name: "duplicate entries in new — only one addition returned", + old: events.ProjectSettings{}, + new: events.ProjectSettings{Writers: []events.UserInfo{alice, alice}}, + wantLen: 1, + wantContains: []roleAssignment{{User: alice, Role: "Writer"}}, + }, + { + // Same person appears email-only in old, then gains a username in new. + // The multi-key lookup must recognise the shared email and not treat them as + // a new addition. + name: "identity shape change — email-only in old, username+email in new — not a new addition", + old: events.ProjectSettings{Writers: []events.UserInfo{{Email: "alice@example.com"}}}, + new: events.ProjectSettings{Writers: []events.UserInfo{{Username: "alice", Email: "alice@example.com"}}}, + wantLen: 0, + }, + { + // Same person listed twice in new with different identity shapes (username+email, + // then email-only). seenNew must index all keys so the second entry is recognised + // as a duplicate and only one notification is sent. + name: "same person twice in new with different identity shapes — only one addition", + old: events.ProjectSettings{}, + new: events.ProjectSettings{Writers: []events.UserInfo{ + {Username: "alice", Email: "alice@example.com"}, + {Email: "alice@example.com"}, + }}, + wantLen: 1, + wantContains: []roleAssignment{{User: events.UserInfo{Username: "alice", Email: "alice@example.com"}, Role: "Writer"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := diffNewMembers(tt.old, tt.new) + assert.Len(t, got, tt.wantLen) + for _, want := range tt.wantContains { + assert.Contains(t, got, want) + } + }) + } +} + +// Compile-time check: emailapi.SendEmailRequest is used to ensure the type alias is correct. +var _ emailapi.SendEmailRequest diff --git a/pkg/constants/nats.go b/pkg/constants/nats.go index b98a355..73fa12e 100644 --- a/pkg/constants/nats.go +++ b/pkg/constants/nats.go @@ -84,3 +84,10 @@ const ( // The subject is of the form: lfx.projects-api.get_parent_uid ProjectGetParentUIDSubject = "lfx.projects-api.get_parent_uid" ) + +// NATS subjects for external service lookups. +const ( + // AuthUserMetadataReadSubject is the subject for looking up a user's profile metadata by principal. + // The subject is of the form: lfx.auth-service.user_metadata.read + AuthUserMetadataReadSubject = "lfx.auth-service.user_metadata.read" +) diff --git a/pkg/events/project.go b/pkg/events/project.go new file mode 100644 index 0000000..eabc2a3 --- /dev/null +++ b/pkg/events/project.go @@ -0,0 +1,53 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Package events contains the wire types for NATS events published by the +// project service. Any struct that appears in a NATS message payload lives +// here so other services can import the canonical definitions. +// +// Convention: if a type is used in a NATS message payload it belongs in +// pkg/events, not internal/. Internal domain types may differ from event +// types; explicit converters in internal/service map between them. +package events + +import "time" + +// UserInfo is the user representation used in project event payloads. +type UserInfo struct { + Name string `json:"name"` + Email string `json:"email"` + Username string `json:"username"` + Avatar string `json:"avatar"` +} + +// ProjectSettings is the project-settings representation used in event payloads. +type ProjectSettings struct { + UID string `json:"uid"` + MissionStatement string `json:"mission_statement"` + AnnouncementDate *time.Time `json:"announcement_date"` + Auditors []UserInfo `json:"auditors"` + Writers []UserInfo `json:"writers"` + MeetingCoordinators []UserInfo `json:"meeting_coordinators"` + ExecutiveDirector *UserInfo `json:"executive_director,omitempty"` + ProgramManager *UserInfo `json:"program_manager,omitempty"` + OpportunityOwner *UserInfo `json:"opportunity_owner,omitempty"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +// Actor represents the user who triggered a settings change. +type Actor struct { + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` +} + +// ProjectSettingsUpdatedMessage is published on +// lfx.projects-api.project_settings.updated whenever project settings change. +// It carries both the before and after states so subscribers can diff them. +type ProjectSettingsUpdatedMessage struct { + ProjectUID string `json:"project_uid"` + OldSettings ProjectSettings `json:"old_settings"` + NewSettings ProjectSettings `json:"new_settings"` + Actor Actor `json:"actor"` +} diff --git a/scripts/root-project-setup/main.go b/scripts/root-project-setup/main.go index ab8c589..a35ffc0 100644 --- a/scripts/root-project-setup/main.go +++ b/scripts/root-project-setup/main.go @@ -21,8 +21,8 @@ import ( indexerConstants "github.com/linuxfoundation/lfx-v2-indexer-service/pkg/constants" indexerTypes "github.com/linuxfoundation/lfx-v2-indexer-service/pkg/types" "github.com/linuxfoundation/lfx-v2-project-service/internal/domain/models" + "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/log" "github.com/linuxfoundation/lfx-v2-project-service/internal/infrastructure/nats" - "github.com/linuxfoundation/lfx-v2-project-service/internal/log" "github.com/linuxfoundation/lfx-v2-project-service/pkg/constants" )