chore: send admin email on adding waitlist entry

This commit is contained in:
talksik
2026-03-29 09:43:21 -07:00
parent be966d78a8
commit 3d71dbaed0
7 changed files with 201 additions and 16 deletions
+2 -2
View File
@@ -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)
+1
View File
@@ -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
+2
View File
@@ -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=
+2 -2
View File
@@ -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"`
}
+150
View File
@@ -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))
}
+22 -3
View File
@@ -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
}
+22 -9
View File
@@ -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)