From 3d71dbaed0acfa9a4bfe7d8b26d3b912839a8056 Mon Sep 17 00:00:00 2001 From: talksik Date: Sun, 29 Mar 2026 09:43:21 -0700 Subject: [PATCH] chore: send admin email on adding waitlist entry --- go/cmd/orion/main.go | 4 +- go/go.mod | 1 + go/go.sum | 2 + go/internal/handler/handler.go | 4 +- go/internal/waitlist/mocks/mock_aero.go | 150 ++++++++++++++++++++++++ go/internal/waitlist/service.go | 25 +++- go/internal/waitlist/service_test.go | 31 +++-- 7 files changed, 201 insertions(+), 16 deletions(-) create mode 100644 go/internal/waitlist/mocks/mock_aero.go diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index ab35aa4..9c4d229 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -76,7 +76,7 @@ func main() { GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"), BucketName: gcsBucket, }) - waitlistSvc := waitlist.NewService(db.Pool()) + waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc) // Initialize handler h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc) @@ -135,7 +135,7 @@ func main() { mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant)) // Apply middleware - // nil allows all origins (required for electron app) + // nil allows all origins (required for electron app) muxWithCors := middleware.CORS(nil)(mux) addr := fmt.Sprintf("0.0.0.0:%s", port) diff --git a/go/go.mod b/go/go.mod index 81fefdc..934136d 100644 --- a/go/go.mod +++ b/go/go.mod @@ -103,6 +103,7 @@ require ( go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/mock v0.6.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect diff --git a/go/go.sum b/go/go.sum index 0a47f91..70d5f49 100644 --- a/go/go.sum +++ b/go/go.sum @@ -244,6 +244,8 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 4aca8bf..1904748 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -42,8 +42,8 @@ func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc net type Human struct { Id string `json:"id"` - Email string `json:"email"` - EmailPrefix string `json:"email_prefix"` + Email string `json:"email"` + EmailPrefix string `json:"email_prefix"` CreatedAt time.Time `json:"created_at"` } diff --git a/go/internal/waitlist/mocks/mock_aero.go b/go/internal/waitlist/mocks/mock_aero.go new file mode 100644 index 0000000..2b1706e --- /dev/null +++ b/go/internal/waitlist/mocks/mock_aero.go @@ -0,0 +1,150 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: /Users/talksik/Documents/code/llink/go/genproto/aero/main_grpc.pb.go +// +// Generated by this command: +// +// mockgen -source=/Users/talksik/Documents/code/llink/go/genproto/aero/main_grpc.pb.go -destination=/Users/talksik/Documents/code/llink/go/internal/waitlist/mocks/mock_aero.go -package=mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + pbaero "github.com/flowy-live/llink/genproto/aero" + gomock "go.uber.org/mock/gomock" + grpc "google.golang.org/grpc" +) + +// MockPrimaryClient is a mock of PrimaryClient interface. +type MockPrimaryClient struct { + ctrl *gomock.Controller + recorder *MockPrimaryClientMockRecorder + isgomock struct{} +} + +// MockPrimaryClientMockRecorder is the mock recorder for MockPrimaryClient. +type MockPrimaryClientMockRecorder struct { + mock *MockPrimaryClient +} + +// NewMockPrimaryClient creates a new mock instance. +func NewMockPrimaryClient(ctrl *gomock.Controller) *MockPrimaryClient { + mock := &MockPrimaryClient{ctrl: ctrl} + mock.recorder = &MockPrimaryClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPrimaryClient) EXPECT() *MockPrimaryClientMockRecorder { + return m.recorder +} + +// ShootEmail mocks base method. +func (m *MockPrimaryClient) ShootEmail(ctx context.Context, in *pbaero.ShootEmailRequest, opts ...grpc.CallOption) (*pbaero.ShootEmailResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, in} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ShootEmail", varargs...) + ret0, _ := ret[0].(*pbaero.ShootEmailResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ShootEmail indicates an expected call of ShootEmail. +func (mr *MockPrimaryClientMockRecorder) ShootEmail(ctx, in any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, in}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShootEmail", reflect.TypeOf((*MockPrimaryClient)(nil).ShootEmail), varargs...) +} + +// MockPrimaryServer is a mock of PrimaryServer interface. +type MockPrimaryServer struct { + ctrl *gomock.Controller + recorder *MockPrimaryServerMockRecorder + isgomock struct{} +} + +// MockPrimaryServerMockRecorder is the mock recorder for MockPrimaryServer. +type MockPrimaryServerMockRecorder struct { + mock *MockPrimaryServer +} + +// NewMockPrimaryServer creates a new mock instance. +func NewMockPrimaryServer(ctrl *gomock.Controller) *MockPrimaryServer { + mock := &MockPrimaryServer{ctrl: ctrl} + mock.recorder = &MockPrimaryServerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPrimaryServer) EXPECT() *MockPrimaryServerMockRecorder { + return m.recorder +} + +// ShootEmail mocks base method. +func (m *MockPrimaryServer) ShootEmail(arg0 context.Context, arg1 *pbaero.ShootEmailRequest) (*pbaero.ShootEmailResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ShootEmail", arg0, arg1) + ret0, _ := ret[0].(*pbaero.ShootEmailResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ShootEmail indicates an expected call of ShootEmail. +func (mr *MockPrimaryServerMockRecorder) ShootEmail(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShootEmail", reflect.TypeOf((*MockPrimaryServer)(nil).ShootEmail), arg0, arg1) +} + +// mustEmbedUnimplementedPrimaryServer mocks base method. +func (m *MockPrimaryServer) mustEmbedUnimplementedPrimaryServer() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "mustEmbedUnimplementedPrimaryServer") +} + +// mustEmbedUnimplementedPrimaryServer indicates an expected call of mustEmbedUnimplementedPrimaryServer. +func (mr *MockPrimaryServerMockRecorder) mustEmbedUnimplementedPrimaryServer() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "mustEmbedUnimplementedPrimaryServer", reflect.TypeOf((*MockPrimaryServer)(nil).mustEmbedUnimplementedPrimaryServer)) +} + +// MockUnsafePrimaryServer is a mock of UnsafePrimaryServer interface. +type MockUnsafePrimaryServer struct { + ctrl *gomock.Controller + recorder *MockUnsafePrimaryServerMockRecorder + isgomock struct{} +} + +// MockUnsafePrimaryServerMockRecorder is the mock recorder for MockUnsafePrimaryServer. +type MockUnsafePrimaryServerMockRecorder struct { + mock *MockUnsafePrimaryServer +} + +// NewMockUnsafePrimaryServer creates a new mock instance. +func NewMockUnsafePrimaryServer(ctrl *gomock.Controller) *MockUnsafePrimaryServer { + mock := &MockUnsafePrimaryServer{ctrl: ctrl} + mock.recorder = &MockUnsafePrimaryServerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUnsafePrimaryServer) EXPECT() *MockUnsafePrimaryServerMockRecorder { + return m.recorder +} + +// mustEmbedUnimplementedPrimaryServer mocks base method. +func (m *MockUnsafePrimaryServer) mustEmbedUnimplementedPrimaryServer() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "mustEmbedUnimplementedPrimaryServer") +} + +// mustEmbedUnimplementedPrimaryServer indicates an expected call of mustEmbedUnimplementedPrimaryServer. +func (mr *MockUnsafePrimaryServerMockRecorder) mustEmbedUnimplementedPrimaryServer() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "mustEmbedUnimplementedPrimaryServer", reflect.TypeOf((*MockUnsafePrimaryServer)(nil).mustEmbedUnimplementedPrimaryServer)) +} diff --git a/go/internal/waitlist/service.go b/go/internal/waitlist/service.go index eebeb32..fddce24 100644 --- a/go/internal/waitlist/service.go +++ b/go/internal/waitlist/service.go @@ -3,8 +3,11 @@ package waitlist import ( "context" "errors" + "fmt" + "log/slog" "time" + pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/utils" "github.com/jackc/pgx/v5/pgxpool" ) @@ -41,12 +44,14 @@ type Service interface { } type serviceImpl struct { - repo repository + repo repository + aeroSvc pbaero.PrimaryClient } -func NewService(pool *pgxpool.Pool) Service { +func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient) Service { return &serviceImpl{ - repo: newRepository(pool), + repo: newRepository(pool), + aeroSvc: aeroSvc, } } @@ -68,6 +73,20 @@ func (s *serviceImpl) AddToWaitlist(ctx context.Context, email string, metadata return err } + message := fmt.Sprintf("New human added to the llink waitlist: %s", email) + _, err = s.aeroSvc.ShootEmail(context.Background(), &pbaero.ShootEmailRequest{ + ToEmails: []string{"team@flowylabs.ai"}, + Subject: "Flowy Llink - New Waitlist Subscriber", + TemplateData: &pbaero.ShootEmailRequest_GenericFlowyAdminAlertData{ + GenericFlowyAdminAlertData: &pbaero.GenericFlowyAdminAlertData{ + Message: message, + }, + }, + }) + if err != nil { + slog.Error("failed to add to waitlist", "error", err, "email", email) + } + return nil } diff --git a/go/internal/waitlist/service_test.go b/go/internal/waitlist/service_test.go index 3c76b5e..a97759e 100644 --- a/go/internal/waitlist/service_test.go +++ b/go/internal/waitlist/service_test.go @@ -5,10 +5,13 @@ import ( "os" "testing" + pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/testhelper" "github.com/flowy-live/llink/internal/waitlist" + "github.com/flowy-live/llink/internal/waitlist/mocks" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" ) var dbPool *pgxpool.Pool @@ -21,9 +24,19 @@ func TestMain(m *testing.M) { os.Exit(ret) } +func newTestService(t *testing.T) waitlist.Service { + ctrl := gomock.NewController(t) + mockAero := mocks.NewMockPrimaryClient(ctrl) + mockAero.EXPECT(). + ShootEmail(gomock.Any(), gomock.Any()). + Return(&pbaero.ShootEmailResponse{}, nil). + AnyTimes() + return waitlist.NewService(dbPool, mockAero) +} + func TestWaitlistService_AddAndGet(t *testing.T) { ctx := context.Background() - svc := waitlist.NewService(dbPool) + svc := newTestService(t) email := "newuser@example.com" metadata := map[string]string{"source": "landing-page", "plan": "pro"} @@ -48,7 +61,7 @@ func TestWaitlistService_AddAndGet(t *testing.T) { func TestWaitlistService_AddDuplicate(t *testing.T) { ctx := context.Background() - svc := waitlist.NewService(dbPool) + svc := newTestService(t) email := "duplicate@example.com" @@ -66,7 +79,7 @@ func TestWaitlistService_AddDuplicate(t *testing.T) { func TestWaitlistService_AddNilMetadata(t *testing.T) { ctx := context.Background() - svc := waitlist.NewService(dbPool) + svc := newTestService(t) email := "nilmeta@example.com" @@ -84,7 +97,7 @@ func TestWaitlistService_AddNilMetadata(t *testing.T) { func TestWaitlistService_GetEntryNotFound(t *testing.T) { ctx := context.Background() - svc := waitlist.NewService(dbPool) + svc := newTestService(t) _, err := svc.GetWaitlistEntryByEmail(ctx, "nonexistent@example.com") assert.ErrorIs(t, err, waitlist.EntryNotFoundError) @@ -92,7 +105,7 @@ func TestWaitlistService_GetEntryNotFound(t *testing.T) { func TestWaitlistService_MarkInvited(t *testing.T) { ctx := context.Background() - svc := waitlist.NewService(dbPool) + svc := newTestService(t) email := "invite-me@example.com" @@ -115,7 +128,7 @@ func TestWaitlistService_MarkInvited(t *testing.T) { func TestWaitlistService_MarkInvitedNotFound(t *testing.T) { ctx := context.Background() - svc := waitlist.NewService(dbPool) + svc := newTestService(t) err := svc.MarkWaitlistEntryInvited(ctx, "ghost@example.com") assert.ErrorIs(t, err, waitlist.EntryNotFoundError) @@ -123,7 +136,7 @@ func TestWaitlistService_MarkInvitedNotFound(t *testing.T) { func TestWaitlistService_GetWaitlistFilters(t *testing.T) { ctx := context.Background() - svc := waitlist.NewService(dbPool) + svc := newTestService(t) uninvitedEmail := "uninvited-filter@example.com" invitedEmail := "invited-filter@example.com" @@ -166,7 +179,7 @@ func TestWaitlistService_GetWaitlistFilters(t *testing.T) { func TestWaitlistService_EmailNormalization(t *testing.T) { ctx := context.Background() - svc := waitlist.NewService(dbPool) + svc := newTestService(t) // Add with uppercase email err := svc.AddToWaitlist(ctx, "UPPER@EXAMPLE.COM", nil) @@ -188,7 +201,7 @@ func TestWaitlistService_EmailNormalization(t *testing.T) { func TestWaitlistService_InvalidEmail(t *testing.T) { ctx := context.Background() - svc := waitlist.NewService(dbPool) + svc := newTestService(t) err := svc.AddToWaitlist(ctx, "not-an-email", nil) assert.Error(t, err)