Go · 5787 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package social
4
5 import (
6 "context"
7 "fmt"
8 "time"
9
10 "github.com/jackc/pgx/v5/pgtype"
11
12 "github.com/tenseleyFlow/shithub/internal/auth/audit"
13 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
14 socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
15 )
16
17 const (
18 followRateMax = 200
19 followRateWindow = time.Hour
20 )
21
22 // FollowUser idempotently records actorUserID following targetUserID.
23 // On a new edge it emits a public user-scoped domain event so S42 feed
24 // surfaces can show "alice followed bob" style rows.
25 func FollowUser(ctx context.Context, deps Deps, actorUserID, targetUserID int64) error {
26 if actorUserID == 0 {
27 return ErrNotLoggedIn
28 }
29 if actorUserID == targetUserID {
30 return ErrCannotFollowSelf
31 }
32 if err := hitFollowLimit(ctx, deps, actorUserID); err != nil {
33 return err
34 }
35
36 tx, err := deps.Pool.Begin(ctx)
37 if err != nil {
38 return err
39 }
40 committed := false
41 defer func() {
42 if !committed {
43 _ = tx.Rollback(ctx)
44 }
45 }()
46
47 q := socialdb.New()
48 inserted, err := q.FollowUser(ctx, tx, socialdb.FollowUserParams{
49 FollowerUserID: actorUserID,
50 FolloweeUserID: pgtype.Int8{Int64: targetUserID, Valid: true},
51 })
52 if err != nil {
53 return fmt.Errorf("follow user: %w", err)
54 }
55 if inserted {
56 if _, err := q.InsertDomainEvent(ctx, tx, socialdb.InsertDomainEventParams{
57 ActorUserID: pgInt(actorUserID),
58 Kind: "followed_user",
59 RepoID: pgInt(0),
60 SourceKind: "user",
61 SourceID: targetUserID,
62 Public: true,
63 Payload: []byte("{}"),
64 }); err != nil {
65 return fmt.Errorf("follow event: %w", err)
66 }
67 }
68 if err := tx.Commit(ctx); err != nil {
69 return err
70 }
71 committed = true
72
73 if inserted && deps.Audit != nil {
74 _ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
75 audit.ActionFollowCreated, audit.TargetUser, targetUserID, nil)
76 }
77 return nil
78 }
79
80 // UnfollowUser removes the actor->user follow edge. The operation is
81 // idempotent so duplicate form submits are harmless.
82 func UnfollowUser(ctx context.Context, deps Deps, actorUserID, targetUserID int64) error {
83 if actorUserID == 0 {
84 return ErrNotLoggedIn
85 }
86 if err := hitFollowLimit(ctx, deps, actorUserID); err != nil {
87 return err
88 }
89 rows, err := socialdb.New().UnfollowUser(ctx, deps.Pool, socialdb.UnfollowUserParams{
90 FollowerUserID: actorUserID,
91 FolloweeUserID: pgtype.Int8{Int64: targetUserID, Valid: true},
92 })
93 if err != nil {
94 return fmt.Errorf("unfollow user: %w", err)
95 }
96 if rows > 0 && deps.Audit != nil {
97 _ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
98 audit.ActionFollowDeleted, audit.TargetUser, targetUserID, nil)
99 }
100 return nil
101 }
102
103 // FollowOrg idempotently records actorUserID following an organization.
104 // This is an S42-compatible extension over the user-only sprint text so
105 // org-owned repos can participate in the same social feed.
106 func FollowOrg(ctx context.Context, deps Deps, actorUserID, orgID int64) error {
107 if actorUserID == 0 {
108 return ErrNotLoggedIn
109 }
110 if err := hitFollowLimit(ctx, deps, actorUserID); err != nil {
111 return err
112 }
113
114 tx, err := deps.Pool.Begin(ctx)
115 if err != nil {
116 return err
117 }
118 committed := false
119 defer func() {
120 if !committed {
121 _ = tx.Rollback(ctx)
122 }
123 }()
124
125 q := socialdb.New()
126 inserted, err := q.FollowOrg(ctx, tx, socialdb.FollowOrgParams{
127 FollowerUserID: actorUserID,
128 FolloweeOrgID: pgtype.Int8{Int64: orgID, Valid: true},
129 })
130 if err != nil {
131 return fmt.Errorf("follow org: %w", err)
132 }
133 if inserted {
134 if _, err := q.InsertDomainEvent(ctx, tx, socialdb.InsertDomainEventParams{
135 ActorUserID: pgInt(actorUserID),
136 Kind: "followed_org",
137 RepoID: pgInt(0),
138 SourceKind: "org",
139 SourceID: orgID,
140 Public: true,
141 Payload: []byte("{}"),
142 }); err != nil {
143 return fmt.Errorf("follow event: %w", err)
144 }
145 }
146 if err := tx.Commit(ctx); err != nil {
147 return err
148 }
149 committed = true
150
151 if inserted && deps.Audit != nil {
152 _ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
153 audit.ActionFollowCreated, audit.TargetOrg, orgID, nil)
154 }
155 return nil
156 }
157
158 // UnfollowOrg removes an actor->org follow edge.
159 func UnfollowOrg(ctx context.Context, deps Deps, actorUserID, orgID int64) error {
160 if actorUserID == 0 {
161 return ErrNotLoggedIn
162 }
163 if err := hitFollowLimit(ctx, deps, actorUserID); err != nil {
164 return err
165 }
166 rows, err := socialdb.New().UnfollowOrg(ctx, deps.Pool, socialdb.UnfollowOrgParams{
167 FollowerUserID: actorUserID,
168 FolloweeOrgID: pgtype.Int8{Int64: orgID, Valid: true},
169 })
170 if err != nil {
171 return fmt.Errorf("unfollow org: %w", err)
172 }
173 if rows > 0 && deps.Audit != nil {
174 _ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
175 audit.ActionFollowDeleted, audit.TargetOrg, orgID, nil)
176 }
177 return nil
178 }
179
180 func IsFollowingUser(ctx context.Context, deps Deps, followerUserID, targetUserID int64) (bool, error) {
181 if followerUserID == 0 {
182 return false, nil
183 }
184 return socialdb.New().IsFollowingUser(ctx, deps.Pool, socialdb.IsFollowingUserParams{
185 FollowerUserID: followerUserID,
186 FolloweeUserID: pgtype.Int8{Int64: targetUserID, Valid: true},
187 })
188 }
189
190 func IsFollowingOrg(ctx context.Context, deps Deps, followerUserID, orgID int64) (bool, error) {
191 if followerUserID == 0 {
192 return false, nil
193 }
194 return socialdb.New().IsFollowingOrg(ctx, deps.Pool, socialdb.IsFollowingOrgParams{
195 FollowerUserID: followerUserID,
196 FolloweeOrgID: pgtype.Int8{Int64: orgID, Valid: true},
197 })
198 }
199
200 func hitFollowLimit(ctx context.Context, deps Deps, userID int64) error {
201 if deps.Limiter == nil {
202 return nil
203 }
204 if err := deps.Limiter.Hit(ctx, deps.Pool, throttle.Limit{
205 Scope: "follow",
206 Identifier: fmt.Sprintf("user:%d", userID),
207 Max: followRateMax,
208 Window: followRateWindow,
209 }); err != nil {
210 return ErrFollowRateLimit
211 }
212 return nil
213 }
214