Files
Dialectic.Backend/internal/store/topic_store.go
hzhang a43ff2de62 feat: per-topic announce target (move guild+channel from env to topic row)
Operator decision: backend env hard-coding a single guild/channel was
wrong because (a) one Center can host many guilds and (b) one guild
can have many announce channels for different purposes. The
proposing agent now chooses where this topic's lifecycle events go,
passed as create-topic params and stored on the topic row.

Schema migration 002:
- ALTER topics ADD announce_guild_base_url VARCHAR(255) NULL,
                  announce_channel_id     VARCHAR(64)  NULL.
- Both nullable; one-of-two is rejected at POST time; both null =
  topic creator opted out of broadcasts (announcer skips with log).

handlers/topics.go: createTopicBody adds announce_guild_base_url +
announce_channel_id; validates both-or-neither.

fabric/announce.go: rewritten signature. NewAnnouncer takes only
the system api key. PostTopicAnnouncement + PostLifecycleEvent take
a Target {GuildBaseURL, ChannelID} per call. Zero-value Target -> skip.

orchestrator/ticker.go: new helper topicTarget(topic) extracts the
target from the topic row; all broadcasts route through it.

verdict.go: same per-topic target extraction at completion.

config: removed FabricGuildBaseURL, FabricAnnounceChannelID,
FabricBotBearerToken from the Config struct + env reads.
FabricSystemAPIKey env renamed to DIALECTIC_FABRIC_SYSTEM_API_KEY
to disambiguate from the Fabric backend's own
FABRIC_BACKEND_GUILD_SYSTEM_API_KEY (operator: paste the same value
into both - one says "I am the system caller", the other says "I
accept this caller as system").

FABRIC_BOT_BEARER_TOKEN is gone entirely. The upgraded Guild
ApiKeyGuard accepts x-fabric-system-key alone for announce posts;
no per-user Bearer needed. Pairs with the matching change on
nav/Fabric.Backend.Guild commit 985b06a.
2026-05-23 17:53:30 +01:00

123 lines
3.5 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models"
)
var ErrNotFound = errors.New("not found")
type TopicStore struct {
db *sqlx.DB
}
func NewTopicStore(db *sqlx.DB) *TopicStore { return &TopicStore{db: db} }
type CreateTopicInput struct {
Title string
Summary string
Visibility models.Visibility
VerdictSchemaID string
SignupOpenAt time.Time
SignupCloseAt time.Time
DebateStartAt time.Time
DebateEndAt time.Time
CreatorUserID string
AnnounceGuildBaseURL *string // optional; null = no broadcasts for this topic
AnnounceChannelID *string
}
func (s *TopicStore) Create(ctx context.Context, in CreateTopicInput) (*models.Topic, error) {
id := uuid.NewString()
_, err := s.db.ExecContext(ctx, `
INSERT INTO topics (id, title, summary, visibility, verdict_schema_id,
signup_open_at, signup_close_at, debate_start_at, debate_end_at,
creator_user_id, announce_guild_base_url, announce_channel_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, in.Title, in.Summary, in.Visibility, in.VerdictSchemaID,
in.SignupOpenAt, in.SignupCloseAt, in.DebateStartAt, in.DebateEndAt,
in.CreatorUserID, in.AnnounceGuildBaseURL, in.AnnounceChannelID)
if err != nil {
return nil, fmt.Errorf("insert topic: %w", err)
}
return s.GetByID(ctx, id)
}
func (s *TopicStore) GetByID(ctx context.Context, id string) (*models.Topic, error) {
var t models.Topic
err := s.db.GetContext(ctx, &t, `SELECT * FROM topics WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &t, nil
}
type ListFilter struct {
Status string // empty = all
Visibility string // empty = all
Limit int // 0 = default 50
Offset int
}
func (s *TopicStore) List(ctx context.Context, f ListFilter) ([]models.Topic, error) {
if f.Limit <= 0 || f.Limit > 200 {
f.Limit = 50
}
q := "SELECT * FROM topics"
args := []any{}
var clauses []string
if f.Status != "" {
clauses = append(clauses, "status = ?")
args = append(args, f.Status)
}
if f.Visibility != "" {
clauses = append(clauses, "visibility = ?")
args = append(args, f.Visibility)
}
if len(clauses) > 0 {
q += " WHERE " + strings.Join(clauses, " AND ")
}
q += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, f.Limit, f.Offset)
var rows []models.Topic
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
return nil, err
}
return rows, nil
}
// SetStatus is a low-level status update. Most transitions go through
// the orchestrator's tx-wrapped paths; this is for the verdict handler
// (debating → completed on successful judge submission) and admin tools.
func (s *TopicStore) SetStatus(ctx context.Context, id string, status models.TopicStatus) (*models.Topic, error) {
if _, err := s.db.ExecContext(ctx,
`UPDATE topics SET status = ? WHERE id = ?`, status, id); err != nil {
return nil, err
}
return s.GetByID(ctx, id)
}
// SetVisibility flips public/private; records who/when. Returns updated row.
func (s *TopicStore) SetVisibility(ctx context.Context, id string, v models.Visibility, byUserID string) (*models.Topic, error) {
_, err := s.db.ExecContext(ctx, `
UPDATE topics SET visibility = ?, visibility_changed_by = ?, visibility_changed_at = CURRENT_TIMESTAMP
WHERE id = ?`, v, byUserID, id)
if err != nil {
return nil, err
}
return s.GetByID(ctx, id)
}