1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "errors"
8 "fmt"
9
10 "github.com/bnema/zerowrap"
11
12 "bnema.dev/ermite/internal/domain"
13 "bnema.dev/ermite/internal/ports"
14)
15
16// IssueModeration is the owner's moderation of the issues submitted through
17// the public form (ADR 0031): list, approve and reject, with an optional ban of
18// the submitter. Owner rights only; anyone else gets domain.ErrNotFound, as
19// for a page that does not exist.
20type IssueModeration struct {
21 meta ports.RepoMetaStore
22 pending ports.PendingIssueStore
23 bans ports.IssueBanStore
24 bus ports.EventBus
25 clock ports.Clock
26}
27
28var _ ports.IssueModerationService = (*IssueModeration)(nil)
29
30// IssueModerationConfig holds what the moderation needs. Every field is
31// required.
32type IssueModerationConfig struct {
33 Meta ports.RepoMetaStore
34 Pending ports.PendingIssueStore
35 Bans ports.IssueBanStore
36 Bus ports.EventBus
37 Clock ports.Clock
38}
39
40// NewIssueModeration builds the moderation use case.
41func NewIssueModeration(cfg IssueModerationConfig) *IssueModeration {
42 return &IssueModeration{meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, bus: cfg.Bus, clock: cfg.Clock}
43}
44
45// ListPending lists the pending issues of a repository, or of every repository
46// when repo is zero, oldest first.
47func (m *IssueModeration) ListPending(ctx context.Context, p domain.Principal, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
48 if !p.HasOwnerRights() {
49 return ports.PendingIssuePage{}, domain.ErrNotFound
50 }
51 size, err := pageSize(page.PageSize)
52 if err != nil {
53 return ports.PendingIssuePage{}, err
54 }
55 page.PageSize = size
56 out, err := m.pending.List(ctx, repo, page)
57 if err != nil {
58 return ports.PendingIssuePage{}, fmt.Errorf("list pending issues: %w", err)
59 }
60 return out, nil
61}
62
63// Approve gives the pending issue the next number of its repository and opens
64// it, then publishes IssueOpened. The repository must exist with issues on
65// (else domain.ErrNotFound), and so must the pending issue.
66func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID) (domain.Issue, error) {
67 if !p.HasOwnerRights() {
68 return domain.Issue{}, domain.ErrNotFound
69 }
70 r, err := lookupRepo(ctx, m.meta, repo)
71 if err != nil {
72 return domain.Issue{}, err
73 }
74 if r.Settings.Issues.OrDefault() == domain.IssuesOff {
75 return domain.Issue{}, domain.ErrNotFound
76 }
77 now := m.clock.Now().UTC()
78 issue, err := m.pending.Approve(ctx, repo, id, now)
79 if err != nil {
80 if errors.Is(err, domain.ErrNotFound) {
81 return domain.Issue{}, err
82 }
83 return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
84 }
85 log := zerowrap.FromCtx(ctx)
86 log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
87 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
88 m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
89 return issue, nil
90}
91
92// Reject deletes the pending issue. With ban, its submitter is first banned
93// for domain.BanDuration.
94func (m *IssueModeration) Reject(ctx context.Context, p domain.Principal, repo domain.RepoName, id domain.PendingIssueID, ban bool) error {
95 if !p.HasOwnerRights() {
96 return domain.ErrNotFound
97 }
98 pending, err := m.pending.Get(ctx, repo, id)
99 if err != nil {
100 return err
101 }
102 if ban {
103 until := m.clock.Now().UTC().Add(domain.BanDuration)
104 if err := m.bans.Ban(ctx, pending.IPHash, until); err != nil {
105 return fmt.Errorf("ban submitter of pending issue of %q: %w", repo, err)
106 }
107 }
108 if err := m.pending.Delete(ctx, repo, id); err != nil {
109 if errors.Is(err, domain.ErrNotFound) {
110 return err
111 }
112 return fmt.Errorf("delete pending issue of %q: %w", repo, err)
113 }
114 log := zerowrap.FromCtx(ctx)
115 log.Info().Str("repo", repo.String()).Bool("ban", ban).
116 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue rejected")
117 m.bus.Publish(ctx, domain.IssuePendingRemoved{Repo: repo, ID: id, At: m.clock.Now().UTC()})
118 return nil
119}