// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package web import ( "bytes" "database/sql" "encoding/json" "fmt" "html/template" "io/fs" "log/slog" "net/http" "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" "git.coopcloud.tech/wiki-cafe/member-console/internal/forms" 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 // Description, when the provider manifest declares one, overrides the // page header's default lead (operator-panel-navigation: "A provider // page leads with the integration's description"). Description string // Sync health (latest sweep execution, from Temporal). SweepStatus string SweepTime string Mappings []MappingViewModel ResourceKeys []ResourceKeyOption Unlinked []UnlinkedPersonViewModel Conflicts []ConflictViewModel // Form is the mapping declaration, unbound on a plain render or bound // to a refused submission of it (form-library). Form forms.FormView 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 description string 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 // Description: see OperatorPageData.Description. Description string // 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 names the page (server.PageTitle). "pageTitle": server.PageTitle, }) // 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 } if tmpl, err = web.ParseUIPartials(tmpl); err != nil { return nil, err } // An integration's operator page is an operator page: its trail is // rooted at the operator surface like every other (design D18). tmpl = tmpl.Funcs(template.FuncMap{"surfaceRoot": web.OperatorSurfaceRoot}) 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, description: cfg.Description, 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, Description: h.description} 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) data.Form = forms.Render(mappingForm, forms.Binding{ Mode: forms.ModeUnbound, Options: mappingFormOptions(data.ResourceKeys), }) 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"). It reads the body through the // mapping declaration and never through r.FormValue (form-library "The // handler parses through the declaration"): a resource key outside the // options the select offered is refused by Parse itself, never resolved to // a default (finding FA-8). func (h *DiscourseOperatorHandler) CreateMapping(w http.ResponseWriter, r *http.Request) { keys, err := dcmod.New(h.db).ListOwnedResourceKeys(r.Context()) if err != nil { h.logger.Error("discourse operator: resource keys query failed", slog.Any("error", err)) } var resourceKeys []ResourceKeyOption for _, k := range keys { resourceKeys = append(resourceKeys, ResourceKeyOption{Key: k.ResourceKey, DisplayName: k.DisplayName}) } options := mappingFormOptions(resourceKeys) values, errs := mappingForm.ParseWith(r, options) fail := func() { data := h.buildPageData(r) data.Form = forms.Render(mappingForm, forms.Binding{ Mode: forms.ModeSubmission, Values: values, Errors: errs, Options: options, }) h.renderBody(w, http.StatusUnprocessableEntity, data) } if errs.Any() { fail() return } resourceKey := values.String("resource_key") groupName := values.String("group_name") group, err := h.apiClient.GetGroup(r.Context(), groupName) if err != nil { h.logger.Error("discourse operator: group validation failed", slog.Any("error", err)) errs.Field("group_name", "Could not reach the forum to validate the group. Try again.") fail() return } if group == nil { errs.Field("group_name", "No group with that name exists on the forum.") fail() return } if group.Automatic { errs.Field("group_name", "That is an automatic Discourse group; its membership cannot be managed externally. Choose a manually-created group.") fail() 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 { for field, msg := range fe { errs.Field(field, msg) } fail() 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)) } } // ---- Page-anatomy pipeline (anatomy sweep group 9). Same shape as the // FedWiki operator page: the shared parts come from web.ParseUIPartials, // the data uses the server package's part types so badge labels and // tones stay in the one badge map (design D3). func (d OperatorPageData) Header() server.PageHeader { lead := "Delivery of forum access to entitled members: group mappings, member-to-forum account links, and sync health." if d.Description != "" { lead = d.Description } return server.PageHeader{ Title: "Discourse forum", Lead: lead, Crumbs: []server.Link{{Label: "Integrations", URL: "/operator/integrations"}}, Action: &server.Link{Label: "Settings", URL: "/operator/integrations/discourse/settings"}, } } // NotConfigured is the page's blocked state: the integration is // registered but dormant until its keys are set, and the settings page // is where an operator sees which keys those are. func (OperatorPageData) NotConfigured() server.EmptyStateParams { return server.EmptyStateParams{ Blocked: true, BlockerCopy: "Discourse is not configured. Set discourse-base-url and discourse-api-key (and optionally discourse-webhook-secret) to enable the integration; until then it is registered but dormant.", PrerequisiteURL: "/operator/integrations/discourse/settings", PrerequisiteLabel: "Open Discourse settings", } } func (OperatorPageData) SweepHeader() server.SectionHeader { return server.SectionHeader{Title: "Sync health", Help: "The sweep is the periodic background job that reconciles forum group membership with entitlements."} } func (OperatorPageData) MapGroupHeader() server.SectionHeader { return server.SectionHeader{Title: "Map a group"} } func (d OperatorPageData) MappingsHeader() server.SectionHeader { h := server.SectionHeader{Title: "Group mappings"} switch n := len(d.Mappings); { case n == 1: h.Count = "1 mapping" case n > 1: h.Count = fmt.Sprintf("%d mappings", n) } return h } func (OperatorPageData) NoMappings() server.EmptyStateParams { return server.EmptyStateParams{ Headline: "No groups are mapped yet.", Note: "Map an entitlement to a Discourse group above to start delivering forum access.", } } func (d OperatorPageData) UnlinkedHeader() server.SectionHeader { h := server.SectionHeader{Title: "Entitled but unlinked"} switch n := len(d.Unlinked); { case n == 1: h.Count = "1 person" case n > 1: h.Count = fmt.Sprintf("%d people", n) } return h } func (d OperatorPageData) ConflictsHeader() server.SectionHeader { h := server.SectionHeader{Title: "Link conflicts"} switch n := len(d.Conflicts); { case n == 1: h.Count = "1 conflict" case n > 1: h.Count = fmt.Sprintf("%d conflicts", n) } return h }