Files
member-console/internal/server/workspace_partials.go
T
cgalo5758 ad7a219adf Enforce schema and boot invariants
Enforce 10j's verified gaps (schema-hardening change):

- Migration 00010: partial unique indexes for one default pool and one
  primary assignment per workspace, plus CHECKs pinning
  pool/provider/subscription vocabularies and provider lifecycle
  timestamps.
- Workspace creation shares a transactional provisioning function;
  extension validates its target pool; last-tier deletion of a defaulted
  ladder is guarded; signup completes plan-less on a broken ladder.
- Boot asserts integration slug parity and validates declared config
  enums; Stripe invoice amounts are range-checked; domain cancellation
  runs a final evidence probe; rule authoring is additive-only.
2026-08-22 18:02:46 -05:00

264 lines
8.8 KiB
Go

package server
import (
"database/sql"
"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/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/provisioning"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// WorkspacePartialsHandler handles HTMX partial requests for workspace management.
type WorkspacePartialsHandler struct {
OrgQ organization.Querier
EntitlementsQ entitlements.Querier
// Database opens the transaction workspace creation runs in, so the
// workspace insert and its primary pool assignment commit or roll back
// together (schema-hardening design D2).
Database *sql.DB
AuthConfig *auth.Config
Logger *slog.Logger
Templates *SafeTemplates
}
// WorkspacePartialsConfig holds configuration for the workspace partials handler.
type WorkspacePartialsConfig struct {
OrgQ organization.Querier
EntitlementsQ entitlements.Querier
Database *sql.DB
AuthConfig *auth.Config
Logger *slog.Logger
}
// NewWorkspacePartialsHandler creates a new WorkspacePartialsHandler.
func NewWorkspacePartialsHandler(cfg WorkspacePartialsConfig) (*WorkspacePartialsHandler, error) {
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
return nil, err
}
tmpl, err := template.New("workspace-partials").Funcs(template.FuncMap{
"routeURL": web.RouteURL,
}).ParseFS(templateSubFS, "workspace_*.html")
if err != nil {
return nil, err
}
return &WorkspacePartialsHandler{
OrgQ: cfg.OrgQ,
EntitlementsQ: cfg.EntitlementsQ,
Database: cfg.Database,
AuthConfig: cfg.AuthConfig,
Logger: cfg.Logger,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
}, nil
}
// RegisterRoutes registers workspace management routes.
func (h *WorkspacePartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/workspaces", h.GetWorkspaces)
mux.HandleFunc("GET /partials/workspaces/create-form", h.GetCreateForm)
mux.HandleFunc("POST /partials/workspaces", h.CreateWorkspace)
mux.HandleFunc("POST /partials/workspaces/{workspaceID}/switch", h.SwitchWorkspace)
}
// WorkspaceViewModel represents a workspace for template rendering.
type WorkspaceViewModel struct {
WorkspaceID string
Name string
Slug string
Status string
// StatusBadgeClass is the Bootstrap badge class for Status — mapped, not
// hardcoded green (audit #50 interim guard); full status semantics await
// the workspace-identity model.
StatusBadgeClass string
CreatedAt string
IsActive bool
// CanSwitch gates the Switch button: only active workspaces may become
// the session workspace (matching SwitchWorkspace's server-side refusal).
CanSwitch bool
}
// workspaceStatusBadgeClass maps a workspace status to its badge class.
func workspaceStatusBadgeClass(status string) string {
switch status {
case "active":
return "bg-success"
case "suspended":
return "text-bg-warning"
default:
return "bg-secondary"
}
}
// WorkspaceListData holds data for the workspace list partial.
type WorkspaceListData struct {
Workspaces []WorkspaceViewModel
Success string
Error string
}
// WorkspaceCreateData holds data for the workspace create form partial.
type WorkspaceCreateData struct {
Error string
}
// GetWorkspaces handles GET /partials/workspaces
func (h *WorkspacePartialsHandler) GetWorkspaces(w http.ResponseWriter, r *http.Request) {
h.renderWorkspaceList(w, r, "", "")
}
// GetCreateForm handles GET /partials/workspaces/create-form
func (h *WorkspacePartialsHandler) GetCreateForm(w http.ResponseWriter, r *http.Request) {
h.Templates.Render(w, "workspace_create.html", WorkspaceCreateData{})
}
// CreateWorkspace handles POST /partials/workspaces
func (h *WorkspacePartialsHandler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := r.ParseForm(); err != nil {
h.renderCreateFormError(w, "Invalid request")
return
}
name := strings.TrimSpace(r.FormValue("name"))
slug := strings.TrimSpace(strings.ToLower(r.FormValue("slug")))
if name == "" || slug == "" {
h.renderCreateFormError(w, "Name and slug are required")
return
}
// Create the workspace and its primary pool assignment together, in one
// transaction, through the same shared provisioning function signup
// uses -- either both happen or neither does (schema-hardening design
// D2). Previously this handler created the workspace, then tried the
// pool assignment as a best-effort follow-up step and reported success
// even when that step failed, leaving a workspace with no pool.
tx, err := h.Database.BeginTx(r.Context(), nil)
if err != nil {
h.Logger.Error("failed to begin workspace create transaction", slog.Any("error", err))
h.renderCreateFormError(w, "We could not create the workspace. Please try again.")
return
}
defer tx.Rollback()
_, err = provisioning.CreateWorkspaceWithPrimaryAssignment(r.Context(), tx, session.OrgID, name, slug)
if err != nil {
// Translate constraint violations into friendly text (never raw
// driver text). This member partial pre-dates the FieldErrors
// machinery, so the translated message renders through the form's
// existing banner shape rather than the 422 field-level path.
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
"workspaces_org_id_slug_key": {Field: "slug", Message: "A workspace with this slug already exists in this organization."},
}); ok {
msg := fe.Get("slug")
if msg == "" {
msg = fe.Get("")
}
h.renderCreateFormError(w, msg)
return
}
h.Logger.Error("failed to create workspace", slog.Any("error", err))
h.renderCreateFormError(w, "We could not create the workspace. Please try again.")
return
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit workspace create transaction", slog.Any("error", err))
h.renderCreateFormError(w, "We could not create the workspace. Please try again.")
return
}
h.renderWorkspaceList(w, r, "Workspace created successfully.", "")
}
// SwitchWorkspace handles POST /partials/workspaces/{workspaceID}/switch
func (h *WorkspacePartialsHandler) SwitchWorkspace(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
workspaceID := r.PathValue("workspaceID")
// Verify workspace belongs to user's org
ws, err := h.OrgQ.GetWorkspaceByID(r.Context(), workspaceID)
if err != nil {
h.Logger.Error("failed to get workspace", slog.Any("error", err))
h.renderWorkspaceList(w, r, "", "Workspace not found")
return
}
if ws.OrgID != session.OrgID {
h.renderWorkspaceList(w, r, "", "Workspace does not belong to your organization")
return
}
// Interim guard (audit #50): only active workspaces can become the
// session workspace; full status semantics await the workspace-identity
// model.
if ws.Status != "active" {
h.renderWorkspaceList(w, r, "", "That workspace isn't active, so you can't switch to it.")
return
}
// Update session workspace
h.AuthConfig.SessionManager.Put(r.Context(), "workspace_id", workspaceID)
// Trigger full page refresh so all workspace-scoped views update
w.Header().Set("HX-Refresh", "true")
}
func (h *WorkspacePartialsHandler) renderWorkspaceList(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
session := h.AuthConfig.GetUserSession(r.Context())
data := WorkspaceListData{
Success: success,
Error: errMsg,
}
if session != nil {
workspaces, err := h.OrgQ.GetWorkspacesByOrgID(r.Context(), session.OrgID)
if err != nil {
h.Logger.Error("failed to list workspaces", slog.Any("error", err))
if data.Error == "" {
data.Error = "Failed to load workspaces"
}
} else {
data.Workspaces = make([]WorkspaceViewModel, len(workspaces))
for i, ws := range workspaces {
data.Workspaces[i] = WorkspaceViewModel{
WorkspaceID: ws.WorkspaceID,
Name: ws.Name,
Slug: ws.Slug,
Status: ws.Status,
StatusBadgeClass: workspaceStatusBadgeClass(ws.Status),
CreatedAt: ws.CreatedAt.Format("Jan 2, 2006"),
IsActive: ws.WorkspaceID == session.WorkspaceID,
CanSwitch: ws.WorkspaceID != session.WorkspaceID && ws.Status == "active",
}
}
}
}
h.Templates.Render(w, "workspace_list.html", data)
}
func (h *WorkspacePartialsHandler) renderCreateFormError(w http.ResponseWriter, message string) {
h.Templates.Render(w, "workspace_create.html", WorkspaceCreateData{Error: message})
}