implement paywall (#161)
* implement core foundation * inject deps * fix incorrect migration * tail migration * use transaction for migration * fix: inject deps for tests * cleanup billing management for admin * upgrade stripe sdk to v85 * set price env variables * cleanup billing management * allow multiple dev windows * fix: settings scroll * feat: show nice video thumbnail in listview * feat: implement freemium restrictions * remove unnecessary comments * refactor * docs * format * tweak network settings better hierarchy
This commit was merged in pull request #161.
This commit is contained in:
@@ -9,8 +9,11 @@ import (
|
||||
"strings"
|
||||
|
||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||
"github.com/flowy-live/llink/internal/billing"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"slices"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("network not found")
|
||||
@@ -25,8 +28,11 @@ type Service interface {
|
||||
GetByID(ctx context.Context, id 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.
|
||||
AddMembers(ctx context.Context, networkID string, humanIds []string) error
|
||||
RemoveMember(ctx context.Context, networkID, humanId string) error
|
||||
CountSeats(ctx context.Context, networkID string) (int, 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
|
||||
@@ -41,12 +47,19 @@ type Service interface {
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
repo repository
|
||||
aeroSvc pbaero.PrimaryClient
|
||||
pool *pgxpool.Pool
|
||||
repo repository
|
||||
aeroSvc pbaero.PrimaryClient
|
||||
billingSvc billing.Service
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient) Service {
|
||||
return &serviceImpl{repo: newRepository(pool), aeroSvc: aeroSvc}
|
||||
func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service) Service {
|
||||
return &serviceImpl{
|
||||
pool: pool,
|
||||
repo: newRepository(pool),
|
||||
aeroSvc: aeroSvc,
|
||||
billingSvc: billingSvc,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) {
|
||||
@@ -60,8 +73,7 @@ func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*N
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.AddMembers(ctx, network.ID, []string{adminHumanId})
|
||||
if err != nil {
|
||||
if err := s.AddMembers(ctx, network.ID, []string{adminHumanId}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -90,22 +102,58 @@ func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if slices.Contains(humanIds, "") {
|
||||
return fmt.Errorf("invalid humanId")
|
||||
}
|
||||
return nil
|
||||
return 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
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
return s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
|
||||
return s.repo.removeMember(ctx, tx, networkID, humanId)
|
||||
})
|
||||
}
|
||||
|
||||
// mutateMembers runs fn in a tx, recounts seats, calls billing.SyncSeats,
|
||||
// and commits. Any error rolls the membership change 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) CountSeats(ctx context.Context, networkID string) (int, error) {
|
||||
return s.repo.countSeats(ctx, s.pool, networkID)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) {
|
||||
@@ -126,8 +174,6 @@ 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 {
|
||||
@@ -180,10 +226,13 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu
|
||||
return fmt.Errorf("invalid humanId")
|
||||
}
|
||||
|
||||
if err := s.repo.deleteInvitation(ctx, networkID, normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.addMember(ctx, networkID, humanId)
|
||||
return 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)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
|
||||
@@ -191,7 +240,7 @@ func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email str
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid email: %w", err)
|
||||
}
|
||||
return s.repo.deleteInvitation(ctx, networkID, normalized)
|
||||
return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized)
|
||||
}
|
||||
|
||||
func buildInvitationHTML(networkName string) string {
|
||||
|
||||
Reference in New Issue
Block a user