diff --git a/go/cmd/emailnotifierjob/main.go b/go/cmd/emailnotifierjob/main.go
index 2a227c8..e536279 100644
--- a/go/cmd/emailnotifierjob/main.go
+++ b/go/cmd/emailnotifierjob/main.go
@@ -67,7 +67,7 @@ func main() {
// Initialize services
humanSvc := human.NewService(db.Pool())
- networkSvc := network.NewService(db.Pool())
+ networkSvc := network.NewService(db.Pool(), aeroSvc)
slog.Info("starting email notification cycle")
if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil {
diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go
index eb1fc22..9880ad8 100644
--- a/go/cmd/orion/main.go
+++ b/go/cmd/orion/main.go
@@ -67,7 +67,7 @@ func main() {
// Initialize services
authSvc := auth.NewAuthService(redisClient, aeroSvc)
humanSvc := human.NewService(db.Pool())
- networkSvc := network.NewService(db.Pool())
+ networkSvc := network.NewService(db.Pool(), aeroSvc)
particleSvc := particle.NewService(db.Pool(), networkSvc)
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
diff --git a/go/cmd/pusherservice/main.go b/go/cmd/pusherservice/main.go
index 2d0c253..2e36865 100644
--- a/go/cmd/pusherservice/main.go
+++ b/go/cmd/pusherservice/main.go
@@ -37,7 +37,7 @@ func main() {
// Services
authSvc := auth.NewAuthService(authRedis, nil) // nil aeroSvc — pusher only calls GetSession
- networkSvc := network.NewService(db.Pool())
+ networkSvc := network.NewService(db.Pool(), nil) // nil aeroSvc — pusher never calls InviteByEmail
// Pod identity (use hostname in k8s, which is the pod name)
podID, err := os.Hostname()
diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go
index 2925942..3db4cee 100644
--- a/go/internal/handler/handler.go
+++ b/go/internal/handler/handler.go
@@ -58,11 +58,11 @@ type Human struct {
}
type Network struct {
- Id string `json:"id"`
- Name string `json:"name"`
- AdminHuman Human `json:"admin_human"`
- Humans []Human `json:"humans"`
- CreatedAt time.Time `json:"created_at"`
+ Id string `json:"id"`
+ Name string `json:"name"`
+ AdminHuman Human `json:"admin_human"`
+ Humans []Human `json:"humans"`
+ CreatedAt time.Time `json:"created_at"`
}
// Auth Request/Response DTOs
@@ -1008,11 +1008,11 @@ func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network
}
return Network{
- Id: n.ID,
- Name: n.Name,
- AdminHuman: humanToDTO(adminHuman),
- Humans: humans,
- CreatedAt: n.CreatedAt,
+ Id: n.ID,
+ Name: n.Name,
+ AdminHuman: humanToDTO(adminHuman),
+ Humans: humans,
+ CreatedAt: n.CreatedAt,
}, nil
}
diff --git a/go/internal/network/mocks/mock_aero.go b/go/internal/network/mocks/mock_aero.go
new file mode 100644
index 0000000..1b6adcc
--- /dev/null
+++ b/go/internal/network/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/network/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/network/service.go b/go/internal/network/service.go
index b3d841e..738ed21 100644
--- a/go/internal/network/service.go
+++ b/go/internal/network/service.go
@@ -4,8 +4,11 @@ import (
"context"
"errors"
"fmt"
+ "html"
+ "log/slog"
"strings"
+ pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -38,11 +41,12 @@ type Service interface {
}
type serviceImpl struct {
- repo repository
+ repo repository
+ aeroSvc pbaero.PrimaryClient
}
-func NewService(pool *pgxpool.Pool) Service {
- return &serviceImpl{repo: newRepository(pool)}
+func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient) Service {
+ return &serviceImpl{repo: newRepository(pool), aeroSvc: aeroSvc}
}
func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) {
@@ -125,6 +129,11 @@ func (s *serviceImpl) ListAll(ctx context.Context) ([]*Network, error) {
// Invitation methods
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
+ network, err := s.repo.getByID(ctx, networkID)
+ if err != nil {
+ return ErrNotFound
+ }
+
for _, email := range emails {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
@@ -133,6 +142,19 @@ func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, email
if err := s.repo.createInvitation(ctx, networkID, normalized); err != nil {
return err
}
+
+ _, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
+ ToEmails: []string{email},
+ Subject: fmt.Sprintf("Invitation to Join %s on Flowy.llink", network.Name),
+ TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
+ SimpleHtmlData: &pbaero.SimpleHtmlData{
+ Html: buildInvitationHTML(network.Name),
+ },
+ },
+ })
+ if err != nil {
+ slog.Warn("unable to send email notification", "email", email, "network", network.Name)
+ }
}
return nil
}
@@ -171,3 +193,38 @@ func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email str
}
return s.repo.deleteInvitation(ctx, networkID, normalized)
}
+
+func buildInvitationHTML(networkName string) string {
+ const downloadURL = "https://flowylabs.ai/llink"
+ safeName := html.EscapeString(networkName)
+ return fmt.Sprintf(`
+
+
+
+
+
+
+
+ |
+ You've been invited to join %s on Flowy.llink
+ |
+
+
+ |
+ Download the app to accept your invitation and connect with your team.
+ |
+
+
+ |
+
+ Download Flowy.llink
+
+ |
+
+
+ |
+
+
+
+`, safeName, downloadURL)
+}
diff --git a/go/internal/network/service_test.go b/go/internal/network/service_test.go
index eeb6d3e..b2f2032 100644
--- a/go/internal/network/service_test.go
+++ b/go/internal/network/service_test.go
@@ -5,10 +5,13 @@ import (
"os"
"testing"
+ pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal/network"
+ "github.com/flowy-live/llink/internal/network/mocks"
"github.com/flowy-live/llink/internal/testhelper"
"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) network.Service {
+ ctrl := gomock.NewController(t)
+ mockAero := mocks.NewMockPrimaryClient(ctrl)
+ mockAero.EXPECT().
+ ShootEmail(gomock.Any(), gomock.Any()).
+ Return(&pbaero.ShootEmailResponse{}, nil).
+ AnyTimes()
+ return network.NewService(dbPool, mockAero)
+}
+
func TestNetworkService(t *testing.T) {
ctx := context.Background()
- svc := network.NewService(dbPool)
+ svc := newTestService(t)
adminHumanId := "human_admin123"
member1HumanId := "human_member1abc"
@@ -115,7 +128,7 @@ func TestNetworkService(t *testing.T) {
func TestNetworkInvitations(t *testing.T) {
ctx := context.Background()
- svc := network.NewService(dbPool)
+ svc := newTestService(t)
adminHumanId := "human_invtest_admin"
inviteeEmail := "invitee@example.com"
diff --git a/go/internal/particle/service_test.go b/go/internal/particle/service_test.go
index ec8a2a2..b85ac72 100644
--- a/go/internal/particle/service_test.go
+++ b/go/internal/particle/service_test.go
@@ -33,7 +33,7 @@ func getStreamStatus(data json.RawMessage) string {
func TestParticleService_CreateAndGet(t *testing.T) {
ctx := context.Background()
- networkSvc := network.NewService(dbPool)
+ networkSvc := network.NewService(dbPool, nil)
svc := particle.NewService(dbPool, networkSvc)
// Create a network first
@@ -72,7 +72,7 @@ func TestParticleService_CreateAndGet(t *testing.T) {
func TestParticleService_NestedParticles(t *testing.T) {
ctx := context.Background()
- networkSvc := network.NewService(dbPool)
+ networkSvc := network.NewService(dbPool, nil)
svc := particle.NewService(dbPool, networkSvc)
// Create a network
@@ -118,7 +118,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
func TestParticleService_CustomVisibility(t *testing.T) {
ctx := context.Background()
- networkSvc := network.NewService(dbPool)
+ networkSvc := network.NewService(dbPool, nil)
svc := particle.NewService(dbPool, networkSvc)
// Create a network with a member
@@ -160,7 +160,7 @@ func TestParticleService_CustomVisibility(t *testing.T) {
func TestParticleService_UpdateAndDelete(t *testing.T) {
ctx := context.Background()
- networkSvc := network.NewService(dbPool)
+ networkSvc := network.NewService(dbPool, nil)
svc := particle.NewService(dbPool, networkSvc)
// Create a network
@@ -198,7 +198,7 @@ func TestParticleService_UpdateAndDelete(t *testing.T) {
func TestParticleService_ListRootParticles(t *testing.T) {
ctx := context.Background()
- networkSvc := network.NewService(dbPool)
+ networkSvc := network.NewService(dbPool, nil)
svc := particle.NewService(dbPool, networkSvc)
// Create a network
@@ -224,7 +224,7 @@ func TestParticleService_ListRootParticles(t *testing.T) {
func TestParticleService_OpenCloseStream(t *testing.T) {
ctx := context.Background()
- networkSvc := network.NewService(dbPool)
+ networkSvc := network.NewService(dbPool, nil)
svc := particle.NewService(dbPool, networkSvc)
// Create a network
@@ -272,7 +272,7 @@ func TestParticleService_OpenCloseStream(t *testing.T) {
func TestParticleService_NotAStream(t *testing.T) {
ctx := context.Background()
- networkSvc := network.NewService(dbPool)
+ networkSvc := network.NewService(dbPool, nil)
svc := particle.NewService(dbPool, networkSvc)
// Create a network
@@ -301,7 +301,7 @@ func TestParticleService_NotAStream(t *testing.T) {
func TestParticleService_AccessInheritance(t *testing.T) {
ctx := context.Background()
- networkSvc := network.NewService(dbPool)
+ networkSvc := network.NewService(dbPool, nil)
svc := particle.NewService(dbPool, networkSvc)
// Create a network with members