@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(`<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
|
||||
<tr>
|
||||
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
|
||||
You've been invited to join %s on Flowy.llink
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
|
||||
Download the app to accept your invitation and connect with your team.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
|
||||
Download Flowy.llink
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`, safeName, downloadURL)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user