package network 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" ) var ErrNotFound = errors.New("network not found") var ErrInvalidName = errors.New("name cannot be empty") var ErrCapacityExceeded = errors.New("active stream capacity exceeded") var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336") type Service interface { // Create creates a network and adds adminHumanId as the first member. Returns ErrInvalidName if name is empty. Create(ctx context.Context, name, adminHumanId string) (*Network, error) // GetByID returns ErrNotFound if network doesn't exist. GetByID(ctx context.Context, id string) (*Network, error) // SetName returns ErrNotFound or ErrInvalidName. SetName(ctx context.Context, id, name string) error AddMembers(ctx context.Context, networkID string, humanIds []string) error RemoveMember(ctx context.Context, networkID, humanId string) error ListForHuman(ctx context.Context, humanId string) ([]*Network, error) IsMember(ctx context.Context, networkID, humanId string) (bool, error) // ListAll returns all networks with their members ListAll(ctx context.Context) ([]*Network, error) // Invitations (email-based, for users who haven't registered yet) InviteByEmail(ctx context.Context, networkID string, emails []string) error ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error RevokeInvitation(ctx context.Context, networkID, email string) error } type serviceImpl struct { repo repository aeroSvc pbaero.PrimaryClient } 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) { name = strings.TrimSpace(name) if name == "" { return nil, ErrInvalidName } network, err := s.repo.create(ctx, name, adminHumanId) if err != nil { return nil, err } err = s.AddMembers(ctx, network.ID, []string{adminHumanId}) if err != nil { return nil, err } return network, nil } func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Network, error) { n, err := s.repo.getByID(ctx, id) if errors.Is(err, errNotFound) { return nil, ErrNotFound } return n, err } 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 { for _, humanId := range humanIds { if humanId == "" { return fmt.Errorf("invalid humanId") } if err := s.repo.addMember(ctx, networkID, humanId); err != nil { return err } } return nil } func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error { if humanId == "" { return fmt.Errorf("invalid humanId") } return s.repo.removeMember(ctx, networkID, humanId) } func (s *serviceImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) { if humanId == "" { return nil, fmt.Errorf("invalid humanId") } return s.repo.getNetworksForHuman(ctx, humanId) } func (s *serviceImpl) IsMember(ctx context.Context, networkID, humanId string) (bool, error) { if humanId == "" { return false, fmt.Errorf("invalid humanId") } return s.repo.isMember(ctx, networkID, humanId) } func (s *serviceImpl) ListAll(ctx context.Context) ([]*Network, error) { return s.repo.listAll(ctx) } // 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 { return fmt.Errorf("invalid email %q: %w", email, 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 { slog.Warn("unable to send email notification", "email", email, "network", network.Name) } } return nil } func (s *serviceImpl) ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) { normalized, err := utils.NormalizeEmail(email) if err != nil { return nil, fmt.Errorf("invalid email: %w", err) } return s.repo.getInvitationsByEmail(ctx, normalized) } func (s *serviceImpl) ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) { return s.repo.getInvitationsByNetwork(ctx, networkID) } func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error { normalized, err := utils.NormalizeEmail(email) if err != nil { return fmt.Errorf("invalid email: %w", err) } if humanId == "" { return fmt.Errorf("invalid humanId") } if err := s.repo.deleteInvitation(ctx, networkID, normalized); err != nil { return err } return s.repo.addMember(ctx, networkID, humanId) } func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error { normalized, err := utils.NormalizeEmail(email) if err != nil { return fmt.Errorf("invalid email: %w", err) } 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) }