// subs.go — forum subscriptions and their notifications. Delivery,
// inbox, per-recipient dedup (TxnID) and email are the notify
// companion's job; quarry's job is deciding WHO hears about WHAT.
// Notifications fire only on the WRITE path — never at fold — so a
// reindex can never re-notify (0001 §5: replay must be silent).
package api
import (
"context"
"fmt"
"log"
"craftdesign.group/quarry/internal/ops"
)
// forumFanout is everything that happens socially after a post is born:
// the author auto-subscribes (never overriding an explicit unsub),
// thread subscribers other than the actor hear about it, and @mentions
// route to their people.
func forumFanout(d Deps, repo, thread, hash, actor, title, body string, repliedTo string) {
if d.Forum == nil {
return
}
d.Forum.AutoSubscribe(repo, actor, thread)
link := "/r/" + repo + "/forum/" + thread[:min(16, len(thread))]
subs, err := d.Forum.ForumSubscribers(repo, thread)
if err == nil {
// The author being ANSWERED hears about it whether or not they
// subscribe — fossil's reply DAG, carried into delivery. The
// TxnID matches the subscriber path's, so notify dedups when
// they are both.
if repliedTo != "" && repliedTo != actor {
subs = append(subs, repliedTo)
}
seen := map[string]bool{}
for _, who := range subs {
if who == actor || seen[who] {
continue
}
seen[who] = true
d.fireNotify(NotifyEvent{
Workflow: "quarry-forum-reply", To: who,
Payload: map[string]any{"repo": repo, "from": actor,
"title": title, "text": body, "link": link},
// Post hash + recipient: replays and retries collapse.
TxnID: "forum-reply:" + hash + ":" + who,
})
}
}
for _, who := range mentionsIn(body, d.mentionKnown(repo)) {
if who == actor {
continue
}
ref := "forum:" + hash
if d.Forum != nil {
if err := d.Forum.RecordMention(repo, ref, who, actor); err != nil {
log.Printf("mention: record %s at %s: %v", who, ref, err)
}
}
d.fireNotify(NotifyEvent{
Workflow: "quarry-mention", To: who,
Payload: map[string]any{"repo": repo, "from": actor,
"text": body, "link": link},
TxnID: "mention:forum:" + hash + ":" + who,
})
}
}
type forumSubIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Root string `json:"root" validate:"required,min=4,max=64"`
State string `json:"state" validate:"oneof=sub|unsub"`
}
type forumSubOut struct {
Root string `json:"root"`
State string `json:"state"`
}
func registerSubs(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[forumSubIn, forumSubOut]{
Name: "forum.subscribe",
Caps: "3",
Doc: "Subscribe to (or leave) a thread. An explicit unsubscribe outlives later replies — posting again never quietly re-subscribes.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/forum/{root}/subscribe",
Run: func(ctx context.Context, in forumSubIn) (forumSubOut, error) {
repo, err := d.forumRepoFor(ctx, in.Repo)
if err != nil {
return forumSubOut{}, err
}
account := ops.User(ctx)
if account == "" {
return forumSubOut{}, fmt.Errorf("subscribing needs a signed-in account: %w", ops.ErrInvalid)
}
_, thread, ok, err := repo.Index.ForumPostThread(in.Root)
if err != nil {
return forumSubOut{}, err
}
if !ok {
return forumSubOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound)
}
if err := d.Forum.SetForumSub(in.Repo, account, thread, in.State); err != nil {
return forumSubOut{}, err
}
// Subscribing to a thread means the repo's activity is worth
// hearing about at all (RFC 0011 §7); an unsubscribe from one
// thread says nothing about the others, so it does not remove
// repo-wide topic membership.
if in.State == "sub" {
repoName := in.Repo
d.provision(func(ctx context.Context) error {
return d.Provision.SetTopicMember(ctx, repoName, account, true)
})
}
return forumSubOut{Root: thread, State: in.State}, nil
},
})
}