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, " + + + + + + + + +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