Skip to content
quarry Artifact [6b2c2ae540]
Login

Artifact [6b2c2ae540]

Artifact 6b2c2ae540dea76387f976c5da5210a4b3f534cca339677db5023e9278169b27:


// 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"

	"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) {
	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 {
		for _, who := range subs {
			if who == actor {
				continue
			}
			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 mentions(body) {
		if who == actor {
			continue
		}
		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",
		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.repoFor(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
			}
			return forumSubOut{Root: thread, State: in.State}, nil
		},
	})
}