Files
member-console/internal/integrations/discourse/web/operator.go
T
cgalo5758 36e58cd821 Flatten operator sidebar to seven entries
Move sub-surfaces into their sections: billing views get a pill row,
org types a header button. Replace inline IdP handoff copy with an SVG
icon and tooltip, add help icons to dense form rows, and delete the
registry-driven sidebar nav plumbing. Update specs and tests.
2026-08-23 18:26:11 -05:00

353 lines
12 KiB
Go

package web
import (
"bytes"
"database/sql"
"encoding/json"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
dcclient "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/client"
dcmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows"
temporalsdk "go.temporal.io/sdk/client"
)
// groupMappingConstraints translates this form's plausible constraint
// violations (docs/operator-ux-conventions.md §6 — names live next to the
// form they belong to).
var groupMappingConstraints = web.ConstraintMessages{
"uq_group_mappings_group_name": {Field: "group_name", Message: "That group is already managed by another mapping."},
"fk_group_mappings_resource_key": {Field: "resource_key", Message: "Unknown resource key."},
}
// MappingViewModel is one entitlement→group mapping row.
type MappingViewModel struct {
MappingID string
ResourceKey string
// DisplayName is the resource key's friendly name, from the same
// resource-key catalog the mapping-form picker uses, so the mappings
// table leads with it instead of the raw key (ui-vocabulary 4.3).
DisplayName string
GroupName string
GroupID int64
CreatedAt string
DeleteURL string
}
// UnlinkedPersonViewModel is one entitled-but-unlinked person.
type UnlinkedPersonViewModel struct {
DisplayName string
Email string
}
// ConflictViewModel is one conflicted link.
type ConflictViewModel struct {
DiscourseUsername string
PersonID string
}
// ResourceKeyOption is a select option for the mapping form.
type ResourceKeyOption struct {
Key string
DisplayName string
}
// OperatorPageData is the discourse_operator.html body payload.
type OperatorPageData struct {
Configured bool
// Sync health (latest sweep execution, from Temporal).
SweepStatus string
SweepTime string
Mappings []MappingViewModel
ResourceKeys []ResourceKeyOption
Unlinked []UnlinkedPersonViewModel
Conflicts []ConflictViewModel
// Add-mapping form state (422 re-render path).
FieldErrors web.FieldErrors
FormValues map[string]string
Error string
}
// DiscourseOperatorHandler serves the Discourse integration's operator
// surface: entitlement→group mapping management and link/sync visibility
// (spec: discourse-operator-surface).
type DiscourseOperatorHandler struct {
db *sql.DB
logger *slog.Logger
authConfig *auth.Config
apiClient *dcclient.Client
temporal temporalsdk.Client
configured bool
sweepScheduleID string
templates *server.SafeTemplates
}
// DiscourseOperatorHandlerConfig configures the handler.
type DiscourseOperatorHandlerConfig struct {
DB *sql.DB
Logger *slog.Logger
AuthConfig *auth.Config
// Client is nil when the integration is unconfigured (the page then
// renders only the unconfigured notice; mutation routes are unmounted).
Client *dcclient.Client
Temporal temporalsdk.Client
Configured bool
// TemplatesFS is the integration's own template tree, injected by the
// adapter (same cycle argument as FedWiki's operator handler).
TemplatesFS fs.FS
// SweepScheduleID names the sweep SCHEDULE to report health from
// (this integration's workflows.SyncScheduleID; injected so this
// package needs no import of its own workflows package). Runs are
// resolved through the schedule because schedule-spawned executions
// get timestamp-suffixed workflow IDs — see
// workflows.ScheduleRunStatus.
SweepScheduleID string
}
// NewDiscourseOperatorHandler composes the operator shell with the
// integration's body partial.
func NewDiscourseOperatorHandler(cfg DiscourseOperatorHandlerConfig) (*DiscourseOperatorHandler, error) {
shellFS, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
return nil, err
}
var tmpl *template.Template
tmpl = template.New("discourse-operator").Funcs(template.FuncMap{
"renderBody": func(name string, data any) (template.HTML, error) {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
},
"deploymentName": config.DeploymentName,
})
// The shell's landing branch references operator_lookup_result.html;
// html/template's escape analysis requires every referenced template to
// be in the set even though BodyTemplate pages never execute that
// branch — parse it alongside the shell (core's operator.go does the
// same; FedWiki's operator handler omits it and 500s — findings.md #8).
tmpl, err = tmpl.ParseFS(shellFS, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html")
if err != nil {
return nil, err
}
tmpl, err = tmpl.ParseFS(cfg.TemplatesFS, "discourse_operator.html")
if err != nil {
return nil, err
}
return &DiscourseOperatorHandler{
db: cfg.DB,
logger: cfg.Logger,
authConfig: cfg.AuthConfig,
apiClient: cfg.Client,
temporal: cfg.Temporal,
configured: cfg.Configured,
sweepScheduleID: cfg.SweepScheduleID,
templates: server.NewSafeTemplates(tmpl, cfg.Logger),
}, nil
}
// buildPageData assembles the body payload. API/Temporal-dependent sections
// are only populated when configured (spec: the unconfigured page shows no
// error-state sync data).
func (h *DiscourseOperatorHandler) buildPageData(r *http.Request) OperatorPageData {
data := OperatorPageData{Configured: h.configured, FormValues: map[string]string{}}
if !h.configured {
return data
}
ctx := r.Context()
q := dcmod.New(h.db)
// Fetched before the mappings loop so the same catalog backs both the
// picker's friendly names and each mapping row's leading display name
// (ui-vocabulary 4.3) — one lookup, two consumers.
keys, err := q.ListOwnedResourceKeys(ctx)
if err != nil {
h.logger.Error("discourse operator: resource keys query failed", slog.Any("error", err))
}
displayNames := make(map[string]string, len(keys))
for _, k := range keys {
displayNames[k.ResourceKey] = k.DisplayName
data.ResourceKeys = append(data.ResourceKeys, ResourceKeyOption{Key: k.ResourceKey, DisplayName: k.DisplayName})
}
mappings, err := q.ListGroupMappings(ctx)
if err != nil {
h.logger.Error("discourse operator: list mappings failed", slog.Any("error", err))
data.Error = "Failed to load group mappings. Details are in the server logs."
return data
}
seenKeys := map[string]bool{}
seenPersons := map[string]bool{}
for _, m := range mappings {
data.Mappings = append(data.Mappings, MappingViewModel{
MappingID: m.MappingID,
ResourceKey: m.ResourceKey,
DisplayName: displayNames[m.ResourceKey],
GroupName: m.GroupName,
GroupID: m.DiscourseGroupID,
CreatedAt: m.CreatedAt.Format("Jan 2, 2006"),
DeleteURL: "/partials/operator/discourse/mappings/" + m.MappingID,
})
if seenKeys[m.ResourceKey] {
continue
}
seenKeys[m.ResourceKey] = true
unlinked, uErr := q.ListEntitledUnlinkedPersons(ctx, m.ResourceKey)
if uErr != nil {
h.logger.Error("discourse operator: unlinked query failed", slog.Any("error", uErr))
continue
}
for _, p := range unlinked {
if seenPersons[p.PersonID] {
continue
}
seenPersons[p.PersonID] = true
data.Unlinked = append(data.Unlinked, UnlinkedPersonViewModel{
DisplayName: p.DisplayName, Email: p.PrimaryEmail,
})
}
}
conflicts, err := q.ListConflictedUserLinks(ctx)
if err != nil {
h.logger.Error("discourse operator: conflicts query failed", slog.Any("error", err))
}
for _, c := range conflicts {
data.Conflicts = append(data.Conflicts, ConflictViewModel{
DiscourseUsername: c.DiscourseUsername, PersonID: c.PersonID,
})
}
data.SweepStatus, data.SweepTime = h.sweepHealth(r)
return data
}
// sweepHealth reports the most recent schedule-spawned sweep's status and
// time; "no sweep yet" when the schedule hasn't fired or Temporal is absent.
func (h *DiscourseOperatorHandler) sweepHealth(r *http.Request) (string, string) {
status, when := workflows.ScheduleRunStatus(r.Context(), h.temporal, h.sweepScheduleID)
if status == "" {
return "No sweep has run yet", ""
}
return status, when
}
// GetPage handles GET <OperatorSurfacePath>.
func (h *DiscourseOperatorHandler) GetPage(w http.ResponseWriter, r *http.Request) {
page := server.BuildOperatorPageData(r, h.authConfig, h.db, h.logger)
// Second-level page of the Integrations section: the section's sidebar
// entry lights up via ActiveCapability (the page is reached from the
// Integrations home, not the sidebar); ia-position nests under the landing.
page.IAPosition = "integration:integrations:discourse"
page.ActiveCapability = "integrations"
page.BodyTemplate = "discourse_operator.html"
page.BodyData = h.buildPageData(r)
h.templates.Render(w, "operator.html", page)
}
// renderBody renders the body partial alone (HTMX swap responses).
func (h *DiscourseOperatorHandler) renderBody(w http.ResponseWriter, status int, data OperatorPageData) {
w.WriteHeader(status)
h.templates.Render(w, "discourse_operator.html", data)
}
// CreateMapping handles POST /partials/operator/discourse/mappings: validate
// against the forum (group exists, not automatic), then insert (spec:
// "Entitlement-to-group mapping management").
func (h *DiscourseOperatorHandler) CreateMapping(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
resourceKey := strings.TrimSpace(r.PostFormValue("resource_key"))
groupName := strings.TrimSpace(r.PostFormValue("group_name"))
fail := func(field, msg string) {
data := h.buildPageData(r)
data.FieldErrors = web.FieldErrors{field: msg}
data.FormValues = map[string]string{"resource_key": resourceKey, "group_name": groupName}
h.renderBody(w, http.StatusUnprocessableEntity, data)
}
if resourceKey == "" {
fail("resource_key", "Choose the entitlement to deliver.")
return
}
if groupName == "" {
fail("group_name", "Enter the Discourse group name.")
return
}
group, err := h.apiClient.GetGroup(r.Context(), groupName)
if err != nil {
h.logger.Error("discourse operator: group validation failed", slog.Any("error", err))
fail("group_name", "Could not reach the forum to validate the group. Try again.")
return
}
if group == nil {
fail("group_name", "No group with that name exists on the forum.")
return
}
if group.Automatic {
fail("group_name", "That is an automatic Discourse group; its membership cannot be managed externally. Choose a manually-created group.")
return
}
if _, err := dcmod.New(h.db).CreateGroupMapping(r.Context(), dcmod.CreateGroupMappingParams{
ResourceKey: resourceKey, GroupName: group.Name, DiscourseGroupID: group.ID,
}); err != nil {
if fe, ok := web.FieldErrorsFromDB(err, groupMappingConstraints); ok {
data := h.buildPageData(r)
data.FieldErrors = fe
data.FormValues = map[string]string{"resource_key": resourceKey, "group_name": groupName}
h.renderBody(w, http.StatusUnprocessableEntity, data)
return
}
h.logger.Error("discourse operator: create mapping failed", slog.Any("error", err))
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.fireToast(w, "Group mapping created")
h.renderBody(w, http.StatusOK, h.buildPageData(r))
}
// DeleteMapping handles DELETE /partials/operator/discourse/mappings/{mappingID}.
// Unmapping stops management; forum-side membership is left as-is (spec:
// "Unmapping a group").
func (h *DiscourseOperatorHandler) DeleteMapping(w http.ResponseWriter, r *http.Request) {
rows, err := dcmod.New(h.db).DeleteGroupMapping(r.Context(), r.PathValue("mappingID"))
if err != nil {
h.logger.Error("discourse operator: delete mapping failed", slog.Any("error", err))
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if rows == 0 {
http.Error(w, "Not found", http.StatusNotFound)
return
}
h.fireToast(w, "Group mapping deleted")
h.renderBody(w, http.StatusOK, h.buildPageData(r))
}
func (h *DiscourseOperatorHandler) fireToast(w http.ResponseWriter, message string) {
if payload, err := json.Marshal(map[string]string{"showSuccessToast": message}); err == nil {
w.Header().Set("HX-Trigger", string(payload))
}
}