Files
llink/go/internal/network/service.go
T
Arjun Patel c1f5b6c8c1 Implement membership notifications and deep-link handling for Electron desktop app (#246)
* security: add cors for desktop app scheme

* Revert "security: add cors for desktop app scheme"

This reverts commit d450fced75.

* ignore tags

* add commands for windows dev

* cleanup unnecessary parts of main desktop process

* resolve lint errors

* add format command for go

* fix: send email on new member joining

Closes #228

* add proper deeplinking on desktop

The desktop app was merely focusing before, but now it will navigate to the proper route

* honor email notifications setting

* prevent sending email when no recipients
2026-06-09 08:04:18 -07:00

404 lines
13 KiB
Go

package network
import (
"context"
"errors"
"fmt"
"html"
"slices"
"strings"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
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"
)
var ErrNotFound = errors.New("network not found")
var ErrInvalidName = errors.New("name cannot be empty")
var ErrInvalidEmail = errors.New("invalid email")
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
// Create adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
Create(ctx context.Context, name, adminHumanId string) (*Network, error)
// SetName returns ErrNotFound or ErrInvalidName.
SetName(ctx context.Context, id, name string) error
// AddMembers inserts members and syncs the new seat count to billing
// atomically; a Stripe failure rolls the insert back.
// Returns ErrInvalidHumanId if any humanId is empty.
AddMembers(ctx context.Context, networkID string, humanIds []string) error
// RemoveMember returns ErrInvalidHumanId if humanId is empty.
RemoveMember(ctx context.Context, networkID, humanId string) error
// Invitations (email-based, for users who haven't registered yet)
// InviteByEmail returns ErrNotFound if the network doesn't exist
// or ErrInvalidEmail if any email fails normalization.
InviteByEmail(ctx context.Context, networkID string, emails []string) error
// AcceptInvitation returns ErrInvalidEmail or ErrInvalidHumanId.
AcceptInvitation(ctx context.Context, networkID, email, humanId string) error
// RevokeInvitation returns ErrInvalidEmail.
RevokeInvitation(ctx context.Context, networkID, email string) error
}
type serviceImpl struct {
*readerImpl
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,
humanLookup humanLookup,
) Service {
return &serviceImpl{
readerImpl: newReader(pool),
aeroSvc: aeroSvc,
billingSvc: billingSvc,
pub: pub,
humanLookup: humanLookup,
}
}
func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, ErrInvalidName
}
network, err := s.repo.create(ctx, name, adminHumanId)
if err != nil {
return nil, err
}
if err := s.AddMembers(ctx, network.ID, []string{adminHumanId}); err != nil {
return nil, err
}
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: []string{constants.FlowyAdminEmail},
Subject: fmt.Sprintf("New network created: %s", network.Name),
TemplateData: &pbaero.ShootEmailRequest_GenericFlowyAdminAlertData{
GenericFlowyAdminAlertData: &pbaero.GenericFlowyAdminAlertData{
Message: "This is a simple notification that a new network was created. Please attend to them.",
},
},
})
if err != nil {
flog.Warn("unable to send admin update email", "error", err)
}
return network, nil
}
func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
name = strings.TrimSpace(name)
if name == "" {
return ErrInvalidName
}
err := s.repo.updateName(ctx, id, name)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds []string) error {
if slices.Contains(humanIds, "") {
return ErrInvalidHumanId
}
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
for _, humanId := range humanIds {
if err := s.repo.addMember(ctx, tx, networkID, humanId); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
for _, humanId := range humanIds {
s.mirrorAddMembership(ctx, humanId, networkID)
}
return nil
}
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error {
if humanId == "" {
return ErrInvalidHumanId
}
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
return s.repo.removeMember(ctx, tx, networkID, humanId)
}); err != nil {
return err
}
s.mirrorRemoveMembership(ctx, humanId, networkID)
return nil
}
// Mirror the live store membership projection (humans/{humanId}.networks).
// Postgres is the source of truth: failures are logged and the reconciler heals drift.
func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) {
if err := s.pub.Add(ctx, humanId, networkID); err != nil {
flog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID)
}
}
func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, networkID string) {
if err := s.pub.Remove(ctx, humanId, networkID); err != nil {
flog.Error("membership publish remove failed", "error", err, "humanId", humanId, "networkID", networkID)
}
}
// Runs fn in a tx and syncs seats to billing atomically. Any error rolls back.
func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
if err := fn(tx); err != nil {
return err
}
seats, err := s.repo.countSeats(ctx, tx, networkID)
if err != nil {
return fmt.Errorf("count seats: %w", err)
}
if err := s.billingSvc.SyncSeats(ctx, networkID, seats); err != nil {
return fmt.Errorf("sync billing seats: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
network, err := s.repo.getByID(ctx, networkID)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
for _, email := range emails {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
return fmt.Errorf("%q: %w: %w", email, ErrInvalidEmail, err)
}
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 {
flog.Warn("unable to send email notification", "email", email, "network", network.Name)
}
}
return nil
}
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)
}
if humanId == "" {
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 {
return err
}
return s.repo.addMember(ctx, tx, networkID, humanId)
}); err != nil {
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
}
if human.Email == "" || !human.EmailNotificationsEnabled {
continue
}
emailRecipients = append(emailRecipients, human.Email)
}
}
if len(emailRecipients) > 0 {
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
}
func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
}
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 {
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&#39;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;">
Open the app to accept your invitation. If you&#39;re new, you&#39;ll be prompted to create a free account first.
</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, 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())
}