tenseleyflow/shithub / 8d3abff

Browse files

S26: social orchestrator (stars, watches, auto-watch, events)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
8d3abffa02337151c39027e2a29948b2d351206c
Parents
87c9ed8
Tree
797cb3f

6 changed files

StatusFile+-
M internal/auth/audit/audit.go 4 0
A internal/social/auto_watch.go 52 0
A internal/social/events.go 56 0
A internal/social/social.go 38 0
A internal/social/stars.go 154 0
A internal/social/watches.go 92 0
internal/auth/audit/audit.gomodified
@@ -65,6 +65,10 @@ const (
6565
 	ActionIssueCommentCreated    Action = "issue_comment_created"
6666
 	ActionPullStateChanged       Action = "pull_state_changed"
6767
 	ActionPullMerged             Action = "pull_merged"
68
+	ActionStarCreated            Action = "star_created"
69
+	ActionStarDeleted            Action = "star_deleted"
70
+	ActionWatchSet               Action = "watch_set"
71
+	ActionWatchUnset             Action = "watch_unset"
6872
 )
6973
 
7074
 // Target is a typed target-type constant.
internal/social/auto_watch.goadded
@@ -0,0 +1,52 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package social
4
+
5
+import (
6
+	"context"
7
+	"fmt"
8
+
9
+	socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
10
+)
11
+
12
+// AutoWatchOnCollab inserts a `level='all'` row when no preference
13
+// exists. Triggered by S15's collaborator-add path. Non-destructive:
14
+// if the user has already chosen a level (including `ignore`), their
15
+// choice is preserved.
16
+//
17
+// Matches GitHub: collaborators get full notifications by default;
18
+// they can opt down to `participating` or `ignore` later.
19
+func AutoWatchOnCollab(ctx context.Context, deps Deps, userID, repoID int64) error {
20
+	return autoWatch(ctx, deps, userID, repoID, WatchAll)
21
+}
22
+
23
+// AutoWatchOnInvolvement inserts a `level='participating'` row when
24
+// no preference exists. Triggered by issues.AddComment, mention
25
+// resolution, assignment, review-requested. Non-destructive.
26
+//
27
+// Matches GitHub: any first-touch involvement (commenting, getting
28
+// mentioned, getting assigned) auto-subscribes the user to that
29
+// thread's notifications, but only at the participating level so a
30
+// drive-by mention doesn't flood them with future events.
31
+func AutoWatchOnInvolvement(ctx context.Context, deps Deps, userID, repoID int64) error {
32
+	return autoWatch(ctx, deps, userID, repoID, WatchParticipating)
33
+}
34
+
35
+// autoWatch is the shared implementation. The InsertIfAbsent query's
36
+// ON CONFLICT DO NOTHING is what makes this safe to call repeatedly
37
+// from any orchestrator without coordinating "first call" semantics.
38
+func autoWatch(ctx context.Context, deps Deps, userID, repoID int64, level WatchLevel) error {
39
+	if userID == 0 {
40
+		// Anonymous can't auto-watch. Not an error — the caller may
41
+		// not know whether the actor is logged in.
42
+		return nil
43
+	}
44
+	if err := socialdb.New().InsertWatchIfAbsent(ctx, deps.Pool, socialdb.InsertWatchIfAbsentParams{
45
+		UserID: userID,
46
+		RepoID: repoID,
47
+		Level:  socialdb.WatchLevel(level),
48
+	}); err != nil {
49
+		return fmt.Errorf("auto-watch %s: %w", level, err)
50
+	}
51
+	return nil
52
+}
internal/social/events.goadded
@@ -0,0 +1,56 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package social
4
+
5
+import (
6
+	"context"
7
+
8
+	"github.com/jackc/pgx/v5/pgtype"
9
+
10
+	socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
11
+)
12
+
13
+// EmitEvent is the canonical seam for inserting a row into
14
+// `domain_events`. Other packages (issues, pulls, repos lifecycle)
15
+// will call this rather than reaching into the sqlc query directly,
16
+// so the event-shape contract has one place to evolve as S29 and
17
+// S33 wire up their consumers.
18
+//
19
+// Pass repoID = 0 for user-scoped events (where there's no repo
20
+// involved). The handler / orchestrator owns the public-flag
21
+// decision: public-repo events should set true; private-repo events
22
+// must set false.
23
+type EmitParams struct {
24
+	ActorUserID int64
25
+	Kind        string
26
+	RepoID      int64 // 0 for user-scoped
27
+	SourceKind  string
28
+	SourceID    int64
29
+	Public      bool
30
+	Payload     []byte // already-marshaled JSON; pass `[]byte("{}")` for empty
31
+}
32
+
33
+// Emit writes one row to domain_events. Non-fatal at the caller's
34
+// discretion; orchestrators typically log on error rather than
35
+// failing the whole transaction (the in-band action is more
36
+// important than the event log).
37
+func Emit(ctx context.Context, deps Deps, p EmitParams) error {
38
+	payload := p.Payload
39
+	if len(payload) == 0 {
40
+		payload = []byte("{}")
41
+	}
42
+	_, err := socialdb.New().InsertDomainEvent(ctx, deps.Pool, socialdb.InsertDomainEventParams{
43
+		ActorUserID: pgInt(p.ActorUserID),
44
+		Kind:        p.Kind,
45
+		RepoID:      pgInt(p.RepoID),
46
+		SourceKind:  p.SourceKind,
47
+		SourceID:    p.SourceID,
48
+		Public:      p.Public,
49
+		Payload:     payload,
50
+	})
51
+	return err
52
+}
53
+
54
+func pgInt(v int64) pgtype.Int8 {
55
+	return pgtype.Int8{Int64: v, Valid: v != 0}
56
+}
internal/social/social.goadded
@@ -0,0 +1,38 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package social owns S26's stars / watches / events surface. The
4
+// package is read-mostly from the rest of the runtime: notifications
5
+// fan-out (S29) reads `watches` for routing, the activity feed
6
+// (post-MVP) reads `domain_events` for public timelines, and the
7
+// repo profile reads cached counts. Mutations come through the
8
+// orchestrator entrypoints below.
9
+package social
10
+
11
+import (
12
+	"errors"
13
+	"log/slog"
14
+
15
+	"github.com/jackc/pgx/v5/pgxpool"
16
+
17
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
18
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
19
+)
20
+
21
+// Deps wires the package against the rest of the runtime. Pool is
22
+// required. Limiter governs the per-user star/unstar rate cap (spec
23
+// pitfall: trending-manipulation abuse vector). Logger is optional
24
+// (falls back to discarding when nil). Audit is optional; when set,
25
+// state-changing operations record an audit row.
26
+type Deps struct {
27
+	Pool    *pgxpool.Pool
28
+	Limiter *throttle.Limiter
29
+	Logger  *slog.Logger
30
+	Audit   *audit.Recorder
31
+}
32
+
33
+// Errors surfaced to handlers.
34
+var (
35
+	ErrNotLoggedIn      = errors.New("social: login required")
36
+	ErrInvalidWatchLevel = errors.New("social: watch level must be all, participating, or ignore")
37
+	ErrStarRateLimit    = errors.New("social: star/unstar rate limit exceeded")
38
+)
internal/social/stars.goadded
@@ -0,0 +1,154 @@
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
+// starRateMax is the spec's day-1 rate cap: 100 star/unstar actions
18
+// per hour per user. Defends against trending-manipulation abuse
19
+// (S35 owns the abuse-heuristics layer; this is the floor).
20
+const (
21
+	starRateMax    = 100
22
+	starRateWindow = time.Hour
23
+)
24
+
25
+// Star idempotently records a star. Re-starring an already-starred
26
+// repo is a no-op (the underlying INSERT … ON CONFLICT DO NOTHING +
27
+// AFTER INSERT trigger combination keeps star_count consistent).
28
+//
29
+// The handler MUST authorize via policy.Can(ActionStarCreate, repo)
30
+// before calling — this orchestrator trusts that the actor has read
31
+// access to the repo and is logged in.
32
+//
33
+// Emits a `star` event into domain_events with `public = repoIsPublic`.
34
+func Star(ctx context.Context, deps Deps, actorUserID, repoID int64, repoIsPublic bool) error {
35
+	if actorUserID == 0 {
36
+		return ErrNotLoggedIn
37
+	}
38
+	if err := hitStarLimit(ctx, deps, actorUserID); err != nil {
39
+		return err
40
+	}
41
+
42
+	tx, err := deps.Pool.Begin(ctx)
43
+	if err != nil {
44
+		return err
45
+	}
46
+	committed := false
47
+	defer func() {
48
+		if !committed {
49
+			_ = tx.Rollback(ctx)
50
+		}
51
+	}()
52
+
53
+	q := socialdb.New()
54
+	if err := q.InsertStar(ctx, tx, socialdb.InsertStarParams{
55
+		UserID: actorUserID, RepoID: repoID,
56
+	}); err != nil {
57
+		return fmt.Errorf("insert star: %w", err)
58
+	}
59
+	if _, err := q.InsertDomainEvent(ctx, tx, socialdb.InsertDomainEventParams{
60
+		ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: true},
61
+		Kind:        "star",
62
+		RepoID:      pgtype.Int8{Int64: repoID, Valid: true},
63
+		SourceKind:  "repo",
64
+		SourceID:    repoID,
65
+		Public:      repoIsPublic,
66
+		Payload:     []byte("{}"),
67
+	}); err != nil {
68
+		return fmt.Errorf("insert event: %w", err)
69
+	}
70
+	if err := tx.Commit(ctx); err != nil {
71
+		return err
72
+	}
73
+	committed = true
74
+	if deps.Audit != nil {
75
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
76
+			audit.ActionStarCreated, audit.TargetRepo, repoID, nil)
77
+	}
78
+	return nil
79
+}
80
+
81
+// Unstar removes the star idempotently. Unstarring a non-starred
82
+// repo is a no-op. Emits an `unstar` event for symmetry.
83
+func Unstar(ctx context.Context, deps Deps, actorUserID, repoID int64, repoIsPublic bool) error {
84
+	if actorUserID == 0 {
85
+		return ErrNotLoggedIn
86
+	}
87
+	if err := hitStarLimit(ctx, deps, actorUserID); err != nil {
88
+		return err
89
+	}
90
+
91
+	tx, err := deps.Pool.Begin(ctx)
92
+	if err != nil {
93
+		return err
94
+	}
95
+	committed := false
96
+	defer func() {
97
+		if !committed {
98
+			_ = tx.Rollback(ctx)
99
+		}
100
+	}()
101
+
102
+	q := socialdb.New()
103
+	if err := q.DeleteStar(ctx, tx, socialdb.DeleteStarParams{
104
+		UserID: actorUserID, RepoID: repoID,
105
+	}); err != nil {
106
+		return fmt.Errorf("delete star: %w", err)
107
+	}
108
+	if _, err := q.InsertDomainEvent(ctx, tx, socialdb.InsertDomainEventParams{
109
+		ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: true},
110
+		Kind:        "unstar",
111
+		RepoID:      pgtype.Int8{Int64: repoID, Valid: true},
112
+		SourceKind:  "repo",
113
+		SourceID:    repoID,
114
+		Public:      repoIsPublic,
115
+		Payload:     []byte("{}"),
116
+	}); err != nil {
117
+		return fmt.Errorf("insert event: %w", err)
118
+	}
119
+	if err := tx.Commit(ctx); err != nil {
120
+		return err
121
+	}
122
+	committed = true
123
+	if deps.Audit != nil {
124
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
125
+			audit.ActionStarDeleted, audit.TargetRepo, repoID, nil)
126
+	}
127
+	return nil
128
+}
129
+
130
+// HasStar reports whether the user has starred the repo. Used by the
131
+// star button on the repo page to render the toggled state.
132
+func HasStar(ctx context.Context, deps Deps, userID, repoID int64) (bool, error) {
133
+	if userID == 0 {
134
+		return false, nil
135
+	}
136
+	return socialdb.New().HasStar(ctx, deps.Pool, socialdb.HasStarParams{
137
+		UserID: userID, RepoID: repoID,
138
+	})
139
+}
140
+
141
+func hitStarLimit(ctx context.Context, deps Deps, userID int64) error {
142
+	if deps.Limiter == nil {
143
+		return nil
144
+	}
145
+	if err := deps.Limiter.Hit(ctx, deps.Pool, throttle.Limit{
146
+		Scope:      "star",
147
+		Identifier: fmt.Sprintf("user:%d", userID),
148
+		Max:        starRateMax,
149
+		Window:     starRateWindow,
150
+	}); err != nil {
151
+		return ErrStarRateLimit
152
+	}
153
+	return nil
154
+}
internal/social/watches.goadded
@@ -0,0 +1,92 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package social
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+
10
+	"github.com/jackc/pgx/v5"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
13
+	socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
14
+)
15
+
16
+// WatchLevel mirrors the DB enum so handlers can pass typed values
17
+// without importing socialdb.
18
+type WatchLevel string
19
+
20
+const (
21
+	WatchAll           WatchLevel = "all"
22
+	WatchParticipating WatchLevel = "participating"
23
+	WatchIgnore        WatchLevel = "ignore"
24
+)
25
+
26
+func (w WatchLevel) valid() bool {
27
+	switch w {
28
+	case WatchAll, WatchParticipating, WatchIgnore:
29
+		return true
30
+	}
31
+	return false
32
+}
33
+
34
+// SetWatch upserts an explicit watch level. The handler authorizes
35
+// via policy.Can(ActionWatchSet, repo) before calling.
36
+func SetWatch(ctx context.Context, deps Deps, actorUserID, repoID int64, level WatchLevel) error {
37
+	if actorUserID == 0 {
38
+		return ErrNotLoggedIn
39
+	}
40
+	if !level.valid() {
41
+		return ErrInvalidWatchLevel
42
+	}
43
+	if err := socialdb.New().UpsertWatch(ctx, deps.Pool, socialdb.UpsertWatchParams{
44
+		UserID: actorUserID,
45
+		RepoID: repoID,
46
+		Level:  socialdb.WatchLevel(level),
47
+	}); err != nil {
48
+		return fmt.Errorf("upsert watch: %w", err)
49
+	}
50
+	if deps.Audit != nil {
51
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
52
+			audit.ActionWatchSet, audit.TargetRepo, repoID,
53
+			map[string]any{"level": string(level)})
54
+	}
55
+	return nil
56
+}
57
+
58
+// UnsetWatch removes the explicit row, returning the user to the
59
+// implicit `participating` default.
60
+func UnsetWatch(ctx context.Context, deps Deps, actorUserID, repoID int64) error {
61
+	if actorUserID == 0 {
62
+		return ErrNotLoggedIn
63
+	}
64
+	if err := socialdb.New().DeleteWatch(ctx, deps.Pool, socialdb.DeleteWatchParams{
65
+		UserID: actorUserID, RepoID: repoID,
66
+	}); err != nil {
67
+		return fmt.Errorf("delete watch: %w", err)
68
+	}
69
+	if deps.Audit != nil {
70
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
71
+			audit.ActionWatchUnset, audit.TargetRepo, repoID, nil)
72
+	}
73
+	return nil
74
+}
75
+
76
+// CurrentLevel returns the actor's current watch level for the repo,
77
+// resolving the implicit `participating` default when no row exists.
78
+func CurrentLevel(ctx context.Context, deps Deps, userID, repoID int64) (WatchLevel, error) {
79
+	if userID == 0 {
80
+		return WatchParticipating, nil
81
+	}
82
+	row, err := socialdb.New().GetWatch(ctx, deps.Pool, socialdb.GetWatchParams{
83
+		UserID: userID, RepoID: repoID,
84
+	})
85
+	if err != nil {
86
+		if errors.Is(err, pgx.ErrNoRows) {
87
+			return WatchParticipating, nil
88
+		}
89
+		return "", err
90
+	}
91
+	return WatchLevel(row.Level), nil
92
+}