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.
264 lines
8.8 KiB
Go
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})
|
|
}
|