Skip to content
quarry Artifact [64d8e95f8e]
Login

Artifact [64d8e95f8e]

Artifact 64d8e95f8e4b861b82ceb332467a03a71a2581b9eed02f501f127d113772652b:


// 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
		},
	})
}