+123
-14
@@ -16,6 +16,7 @@ import (
|
||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||
"github.com/flowy-live/llink/internal/billing"
|
||||
"github.com/flowy-live/llink/internal/constants"
|
||||
"github.com/flowy-live/llink/internal/human"
|
||||
"github.com/flowy-live/llink/internal/livestore"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
)
|
||||
@@ -27,6 +28,10 @@ var ErrInvalidHumanId = errors.New("invalid humanId")
|
||||
var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
|
||||
var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336")
|
||||
|
||||
type humanLookup interface {
|
||||
GetByID(ctx context.Context, id string) (*human.Human, error)
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
Reader
|
||||
|
||||
@@ -54,17 +59,25 @@ type Service interface {
|
||||
|
||||
type serviceImpl struct {
|
||||
*readerImpl
|
||||
aeroSvc pbaero.PrimaryClient
|
||||
billingSvc billing.Service
|
||||
pub livestore.MembershipPublisher
|
||||
aeroSvc pbaero.PrimaryClient
|
||||
billingSvc billing.Service
|
||||
pub livestore.MembershipPublisher
|
||||
humanLookup humanLookup
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service, pub livestore.MembershipPublisher) Service {
|
||||
func NewService(
|
||||
pool *pgxpool.Pool,
|
||||
aeroSvc pbaero.PrimaryClient,
|
||||
billingSvc billing.Service,
|
||||
pub livestore.MembershipPublisher,
|
||||
humanLookup humanLookup,
|
||||
) Service {
|
||||
return &serviceImpl{
|
||||
readerImpl: newReader(pool),
|
||||
aeroSvc: aeroSvc,
|
||||
billingSvc: billingSvc,
|
||||
pub: pub,
|
||||
readerImpl: newReader(pool),
|
||||
aeroSvc: aeroSvc,
|
||||
billingSvc: billingSvc,
|
||||
pub: pub,
|
||||
humanLookup: humanLookup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +236,14 @@ func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, email
|
||||
}
|
||||
|
||||
func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error {
|
||||
network, err := s.repo.getByID(ctx, networkID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
normalized, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
|
||||
@@ -231,6 +252,11 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu
|
||||
return ErrInvalidHumanId
|
||||
}
|
||||
|
||||
prevMembersHumanIds, membersErr := s.repo.getMemberHumanIds(ctx, networkID)
|
||||
if membersErr != nil {
|
||||
flog.Warn("unable to get member human ids", "error", membersErr)
|
||||
}
|
||||
|
||||
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
|
||||
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
|
||||
if err != nil {
|
||||
@@ -241,6 +267,35 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu
|
||||
return err
|
||||
}
|
||||
|
||||
if membersErr == nil && len(prevMembersHumanIds) > 0 {
|
||||
emailRecipients := make([]string, 0, len(prevMembersHumanIds))
|
||||
for _, memberHumanId := range prevMembersHumanIds {
|
||||
if memberHumanId != "" {
|
||||
human, err := s.humanLookup.GetByID(ctx, memberHumanId)
|
||||
if err != nil {
|
||||
flog.Warn("unable to find human", "error", err, "humanId", memberHumanId)
|
||||
continue
|
||||
}
|
||||
|
||||
emailRecipients = append(emailRecipients, human.Email)
|
||||
}
|
||||
}
|
||||
|
||||
newMemberEmailPrefix := strings.Split(normalized, "@")[0]
|
||||
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
|
||||
ToEmails: emailRecipients,
|
||||
Subject: fmt.Sprintf("A new member has joined %s", network.Name),
|
||||
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
|
||||
SimpleHtmlData: &pbaero.SimpleHtmlData{
|
||||
Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
flog.Warn("unable to send email notification", "email", email, "network", network.Name)
|
||||
}
|
||||
}
|
||||
|
||||
s.mirrorAddMembership(ctx, humanId, networkID)
|
||||
return nil
|
||||
}
|
||||
@@ -253,8 +308,24 @@ func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email str
|
||||
return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized)
|
||||
}
|
||||
|
||||
const (
|
||||
emailWebAppURL = "https://llink.flowy.live"
|
||||
emailDesktopURL = "llink://"
|
||||
emailDownloadURL = "https://flowylabs.ai/llink/download"
|
||||
)
|
||||
|
||||
// emailDesktopFooter is the shared secondary line offering the desktop app.
|
||||
// The web app is always the primary CTA (no install required), so desktop is
|
||||
// kept quiet here and shared across templates so the two can't drift apart.
|
||||
func emailDesktopFooter() string {
|
||||
return fmt.Sprintf(`<tr>
|
||||
<td style="font-size:13px;color:#888888;">
|
||||
Prefer the desktop app? <a href="%s" style="color:#111111;">Open it</a> or <a href="%s" style="color:#111111;">download here</a>.
|
||||
</td>
|
||||
</tr>`, emailDesktopURL, emailDownloadURL)
|
||||
}
|
||||
|
||||
func buildInvitationHTML(networkName string) string {
|
||||
const downloadURL = "https://flowylabs.ai/llink/download"
|
||||
safeName := html.EscapeString(networkName)
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -265,25 +336,63 @@ func buildInvitationHTML(networkName string) string {
|
||||
<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
|
||||
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;">
|
||||
Launch (or download) the app to accept your invitation and connect with your team.
|
||||
Open the app to accept your invitation. If you're new, you'll be prompted to create a free account first.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<td style="padding-bottom:24px;">
|
||||
<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;">
|
||||
Launch Flowy.llink
|
||||
Open Flowy.llink (web)
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
%s
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`, safeName, downloadURL)
|
||||
</html>`, safeName, emailWebAppURL, emailDesktopFooter())
|
||||
}
|
||||
|
||||
// buildNewMemberHTML is an email template to notify other members that a new member has joined
|
||||
func buildNewMemberHTML(networkName, newMemberEmailPrefix string) string {
|
||||
safeName := html.EscapeString(networkName)
|
||||
safeEmail := html.EscapeString(newMemberEmailPrefix)
|
||||
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;">
|
||||
A new member joined %s
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
|
||||
<strong style="color:#111111;">%s</strong> just joined your network on Flowy.llink. Say hello and bring them up to speed.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-bottom:24px;">
|
||||
<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;">
|
||||
Open Flowy.llink (web)
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
%s
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`, safeName, safeEmail, emailWebAppURL, emailDesktopFooter())
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||
mock_billing "github.com/flowy-live/llink/internal/billing/mocks"
|
||||
"github.com/flowy-live/llink/internal/human"
|
||||
mock_human "github.com/flowy-live/llink/internal/human/mocks"
|
||||
mock_livestore "github.com/flowy-live/llink/internal/livestore/mocks"
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
"github.com/flowy-live/llink/internal/testhelper"
|
||||
@@ -41,7 +43,15 @@ func newTestService(t *testing.T) network.Service {
|
||||
mockPub.EXPECT().Add(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
|
||||
mockPub.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
|
||||
|
||||
return network.NewService(dbPool, mockAero, mockBilling, mockPub)
|
||||
mockHuman := mock_human.NewMockService(ctrl)
|
||||
mockHuman.EXPECT().GetByID(gomock.Any(), gomock.Any()).Return(&human.Human{
|
||||
ID: "test_human",
|
||||
Email: "[email protected]",
|
||||
EmailPrefix: "test",
|
||||
EmailNotificationsEnabled: false,
|
||||
}, nil).AnyTimes()
|
||||
|
||||
return network.NewService(dbPool, mockAero, mockBilling, mockPub, mockHuman)
|
||||
}
|
||||
|
||||
func TestNetworkService(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user