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(` Prefer the desktop app? Open it or download here. `, emailDesktopURL, emailDownloadURL) } func buildInvitationHTML(networkName string) string { safeName := html.EscapeString(networkName) return fmt.Sprintf(`
%s
You've been invited to join %s on Flowy.llink
Open the app to accept your invitation. If you're new, you'll be prompted to create a free account first.
Open Flowy.llink (web)
`, 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(`
%s
A new member joined %s
%s just joined your network on Flowy.llink. Say hello and bring them up to speed.
Open Flowy.llink (web)
`, safeName, safeEmail, emailWebAppURL, emailDesktopFooter()) }