package admin import ( "context" "errors" "slices" "strings" "time" "tug/internal/storage" ) const ( InvitationBatchMax = 50 InvitationGroupChoicesShown = 100 InvitationExpiryDefault = 7 * 24 * time.Hour ) var InvitationExpiries = []time.Duration{24 * time.Hour, InvitationExpiryDefault, 30 * 24 * time.Hour} type NewInvitations struct { Labels []string GroupIDs []string GroupNames []string Expiry time.Duration } type IssuedInvitation struct { Label string URL string } type CreatedInvitations struct { Invitations []IssuedInvitation ExpiresAt time.Time Groups []string } type GroupChoice struct { ID string Name string } type InvitationGroupChoices struct { Groups []GroupChoice More bool Defaults []string } func (s *Service) CreateInvitations(ctx context.Context, actor string, n NewInvitations) (CreatedInvitations, error) { var labels []string for _, label := range n.Labels { if label = strings.TrimSpace(label); label != "" { labels = append(labels, label) } } if len(labels) == 0 || len(labels) > InvitationBatchMax { return CreatedInvitations{}, ProblemBatchSize } for _, label := range labels { if storage.CheckInvitationLabel(label) != nil { return CreatedInvitations{}, ProblemLabelInvalid } } if !slices.Contains(InvitationExpiries, n.Expiry) { return CreatedInvitations{}, ProblemExpiryInvalid } var ids []string defaults, _ := s.configuredGroups() for i, name := range append(slices.Clone(defaults), n.GroupNames...) { group, err := s.store.GroupByName(ctx, name) if errors.Is(err, storage.ErrNotFound) && i < len(defaults) { continue } if errors.Is(err, storage.ErrNotFound) { return CreatedInvitations{}, ProblemUnknownGroup } if err != nil { return CreatedInvitations{}, failed("read a group by name", err) } ids = append(ids, group.ID) } ids = append(ids, n.GroupIDs...) issued, err := s.invites.CreateMany(ctx, actor, labels, ids, n.Expiry) switch { case errors.Is(err, storage.ErrAdminGroupGrant): return CreatedInvitations{}, ProblemAdminGroupGrant case errors.Is(err, storage.ErrNotFound): return CreatedInvitations{}, ProblemUnknownGroup case err != nil: return CreatedInvitations{}, failed("create invitations", err) } out := CreatedInvitations{ExpiresAt: issued[0].ExpiresAt} for _, i := range issued { out.Invitations = append(out.Invitations, IssuedInvitation{Label: i.Label, URL: i.URL}) } if out.Groups, err = s.invitationGroupNames(ctx, ids); err != nil { return CreatedInvitations{}, err } return out, nil } func (s *Service) invitationGroupNames(ctx context.Context, ids []string) ([]string, error) { configured, _ := s.configuredGroups() var defaults, chosen []string for _, id := range ids { group, err := s.store.GroupByID(ctx, id) if err != nil { return nil, failed("read a granted group", err) } switch { case slices.Contains(configured, group.Name): if !slices.Contains(defaults, group.Name) { defaults = append(defaults, group.Name) } case !slices.Contains(chosen, group.Name): chosen = append(chosen, group.Name) } } slices.Sort(chosen) return append(defaults, chosen...), nil } func (s *Service) InvitationGroupChoices(ctx context.Context) (InvitationGroupChoices, error) { all, err := s.store.ListGroups(ctx) if err != nil { return InvitationGroupChoices{}, failed("list groups", err) } defaults, _ := s.configuredGroups() out := InvitationGroupChoices{Defaults: slices.Clone(defaults)} for _, g := range all { if g.Name == s.adminGroup || g.Name == s.primaryGroup || slices.Contains(defaults, g.Name) { continue } if len(out.Groups) == InvitationGroupChoicesShown { out.More = true break } out.Groups = append(out.Groups, GroupChoice{ID: g.ID, Name: g.Name}) } return out, nil }