Files
llink/go/internal/network/service.go
T

231 lines
7.3 KiB
Go

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(`<!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)
}