Files
member-console/internal/server/operator_partials.go

407 lines
12 KiB
Go

package server
import (
"context"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strconv"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
)
// OperatorRole is the OIDC role required to access operator pages
const OperatorRole = "operator-member"
// OperatorPartialsHandler handles HTMX partial requests for operator pages
type OperatorPartialsHandler struct {
DB db.Querier
Logger *slog.Logger
AuthConfig *auth.Config
Templates *template.Template
}
// OperatorPartialsConfig holds configuration for the operator partials handler
type OperatorPartialsConfig struct {
DB db.Querier
Logger *slog.Logger
AuthConfig *auth.Config
}
// NewOperatorPartialsHandler creates a new OperatorPartialsHandler
func NewOperatorPartialsHandler(cfg OperatorPartialsConfig) (*OperatorPartialsHandler, error) {
// Parse partial templates
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
return nil, err
}
tmpl, err := template.ParseFS(templateSubFS, "operator_*.html")
if err != nil {
return nil, err
}
return &OperatorPartialsHandler{
DB: cfg.DB,
Logger: cfg.Logger,
AuthConfig: cfg.AuthConfig,
Templates: tmpl,
}, nil
}
// RegisterRoutes registers all operator HTMX partial routes
func (h *OperatorPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/operator/users", h.requireOperatorRole(h.GetUsers))
mux.HandleFunc("GET /partials/operator/sites", h.requireOperatorRole(h.GetSites))
mux.HandleFunc("GET /partials/operator/payments", h.requireOperatorRole(h.GetPayments))
mux.HandleFunc("GET /partials/operator/user/{id}/edit", h.requireOperatorRole(h.GetUserEditForm))
mux.HandleFunc("PUT /partials/operator/user/{id}", h.requireOperatorRole(h.UpdateUser))
}
// requireOperatorRole is middleware that checks for the operator role
func (h *OperatorPartialsHandler) requireOperatorRole(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !h.AuthConfig.HasRole(r, OperatorRole) {
h.Logger.Warn("operator partials access denied",
slog.String("path", r.URL.Path),
slog.String("reason", "missing operator-member role"))
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next(w, r)
}
}
// UserViewModel represents a user for operator template rendering
type UserViewModel struct {
ID int64
Username string
Email string
IsMember bool
SitesQuota int64
SitesCount int64
CreatedAt string
}
// UsersData holds data for the users list partial
type UsersData struct {
Users []UserViewModel
Error string
}
// GetUsers handles GET /partials/operator/users
func (h *OperatorPartialsHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.DB.ListUsers(r.Context())
if err != nil {
h.Logger.Error("failed to list users", slog.Any("error", err))
h.renderError(w, "operator_users.html", "Failed to retrieve users")
return
}
// Build view models with site counts
userVMs := make([]UserViewModel, len(users))
for i, user := range users {
siteCount, err := h.DB.GetSiteCountByUserID(r.Context(), user.ID)
if err != nil {
h.Logger.Error("failed to get site count", slog.Any("error", err), slog.Int64("userID", user.ID))
siteCount = 0
}
userVMs[i] = UserViewModel{
ID: user.ID,
Username: user.Username,
Email: user.Email,
IsMember: user.IsMember != 0,
SitesQuota: user.SitesQuota,
SitesCount: siteCount,
CreatedAt: user.CreatedAt.Format("Jan 2, 2006"),
}
}
data := UsersData{Users: userVMs}
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "operator_users.html", data); err != nil {
h.Logger.Error("template error", slog.Any("error", err))
}
}
// OperatorSiteViewModel represents a site for operator template rendering
type OperatorSiteViewModel struct {
ID int64
Domain string
OwnerUsername string
OwnerID int64
IsCustomDomain bool
CreatedAt string
}
// SitesData holds data for the sites list partial
type OperatorSitesData struct {
Sites []OperatorSiteViewModel
Error string
}
// GetSites handles GET /partials/operator/sites
func (h *OperatorPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request) {
sites, err := h.DB.GetAllSites(r.Context())
if err != nil {
h.Logger.Error("failed to get all sites", slog.Any("error", err))
h.renderError(w, "operator_sites.html", "Failed to retrieve sites")
return
}
// Get all users for owner lookup
users, err := h.DB.ListUsers(r.Context())
if err != nil {
h.Logger.Error("failed to list users for owner lookup", slog.Any("error", err))
}
userMap := make(map[int64]string)
for _, u := range users {
userMap[u.ID] = u.Username
}
siteVMs := make([]OperatorSiteViewModel, len(sites))
for i, site := range sites {
ownerUsername := userMap[site.UserID]
if ownerUsername == "" {
ownerUsername = "(unknown)"
}
siteVMs[i] = OperatorSiteViewModel{
ID: site.ID,
Domain: site.Domain,
OwnerUsername: ownerUsername,
OwnerID: site.UserID,
IsCustomDomain: site.IsCustomDomain != 0,
CreatedAt: site.CreatedAt.Format("Jan 2, 2006"),
}
}
data := OperatorSitesData{Sites: siteVMs}
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "operator_sites.html", data); err != nil {
h.Logger.Error("template error", slog.Any("error", err))
}
}
// PaymentViewModel represents a payment for operator template rendering
type PaymentViewModel struct {
ID int64
UserID int64
Username string
Amount string // Formatted with currency
Currency string
Status string
PaidAt string
CreatedAt string
}
// PaymentsData holds data for the payments list partial
type PaymentsData struct {
Payments []PaymentViewModel
Error string
}
// GetPayments handles GET /partials/operator/payments
func (h *OperatorPartialsHandler) GetPayments(w http.ResponseWriter, r *http.Request) {
payments, err := h.DB.ListPayments(r.Context())
if err != nil {
h.Logger.Error("failed to list payments", slog.Any("error", err))
h.renderError(w, "operator_payments.html", "Failed to retrieve payments")
return
}
// Get all users for username lookup
users, err := h.DB.ListUsers(r.Context())
if err != nil {
h.Logger.Error("failed to list users for payment lookup", slog.Any("error", err))
}
userMap := make(map[int64]string)
for _, u := range users {
userMap[u.ID] = u.Username
}
paymentVMs := make([]PaymentViewModel, len(payments))
for i, payment := range payments {
username := userMap[payment.UserID]
if username == "" {
username = "(unknown)"
}
paidAt := "-"
if payment.PaidAt.Valid {
paidAt = payment.PaidAt.Time.Format("Jan 2, 2006")
}
// Format amount (assuming minor units, e.g., cents)
amountStr := formatAmount(payment.Amount, payment.Currency)
paymentVMs[i] = PaymentViewModel{
ID: payment.ID,
UserID: payment.UserID,
Username: username,
Amount: amountStr,
Currency: payment.Currency,
Status: payment.Status,
PaidAt: paidAt,
CreatedAt: payment.CreatedAt.Format("Jan 2, 2006"),
}
}
data := PaymentsData{Payments: paymentVMs}
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "operator_payments.html", data); err != nil {
h.Logger.Error("template error", slog.Any("error", err))
}
}
// UserEditData holds data for the user edit form partial
type UserEditData struct {
User UserViewModel
Error string
}
// GetUserEditForm handles GET /partials/operator/user/{id}/edit
func (h *OperatorPartialsHandler) GetUserEditForm(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
h.Logger.Error("invalid user ID", slog.String("id", idStr))
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
user, err := h.DB.GetUserByID(r.Context(), id)
if err != nil {
h.Logger.Error("failed to get user", slog.Any("error", err), slog.Int64("id", id))
h.renderEditError(w, id, "User not found")
return
}
siteCount, _ := h.DB.GetSiteCountByUserID(r.Context(), id)
data := UserEditData{
User: UserViewModel{
ID: user.ID,
Username: user.Username,
Email: user.Email,
IsMember: user.IsMember != 0,
SitesQuota: user.SitesQuota,
SitesCount: siteCount,
CreatedAt: user.CreatedAt.Format("Jan 2, 2006"),
},
}
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "operator_user_edit.html", data); err != nil {
h.Logger.Error("template error", slog.Any("error", err))
}
}
// UpdateUser handles PUT /partials/operator/user/{id}
func (h *OperatorPartialsHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
h.Logger.Error("invalid user ID", slog.String("id", idStr))
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
h.renderEditError(w, id, "Invalid form data")
return
}
// Parse is_member checkbox (present = true, absent = false)
isMember := r.FormValue("is_member") == "on" || r.FormValue("is_member") == "true" || r.FormValue("is_member") == "1"
sitesQuotaStr := r.FormValue("sites_quota")
sitesQuota, err := strconv.ParseInt(sitesQuotaStr, 10, 64)
if err != nil || sitesQuota < 0 {
h.renderEditError(w, id, "Invalid sites quota value")
return
}
var isMemberInt int64 = 0
if isMember {
isMemberInt = 1
}
_, err = h.DB.UpdateUserMembership(r.Context(), db.UpdateUserMembershipParams{
ID: id,
IsMember: isMemberInt,
SitesQuota: sitesQuota,
})
if err != nil {
h.Logger.Error("failed to update user membership", slog.Any("error", err), slog.Int64("id", id))
h.renderEditError(w, id, "Failed to update user")
return
}
h.Logger.Info("user membership updated",
slog.Int64("id", id),
slog.Bool("is_member", isMember),
slog.Int64("sites_quota", sitesQuota))
// Return success and trigger refresh
w.Header().Set("Content-Type", "text/html")
w.Header().Set("HX-Trigger", `{"refreshUsers": true, "closeModal": true}`)
w.Write([]byte(`<div class="alert alert-success">User updated successfully</div>`))
}
// Helper functions
func (h *OperatorPartialsHandler) renderError(w http.ResponseWriter, template string, message string) {
w.Header().Set("Content-Type", "text/html")
data := struct{ Error string }{Error: message}
h.Templates.ExecuteTemplate(w, template, data)
}
func (h *OperatorPartialsHandler) renderEditError(w http.ResponseWriter, userID int64, message string) {
// Re-fetch user data and render with error
user, err := h.DB.GetUserByID(context.TODO(), userID)
if err != nil {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<div class="alert alert-danger">` + message + `</div>`))
return
}
siteCount, _ := h.DB.GetSiteCountByUserID(context.TODO(), userID)
data := UserEditData{
User: UserViewModel{
ID: user.ID,
Username: user.Username,
Email: user.Email,
IsMember: user.IsMember != 0,
SitesQuota: user.SitesQuota,
SitesCount: siteCount,
},
Error: message,
}
w.Header().Set("Content-Type", "text/html")
h.Templates.ExecuteTemplate(w, "operator_user_edit.html", data)
}
func formatAmount(amount int64, currency string) string {
// Simple formatting - assumes minor units (cents)
major := amount / 100
minor := amount % 100
return currency + " " + strconv.FormatInt(major, 10) + "." + padLeft(strconv.FormatInt(minor, 10), 2, '0')
}
func padLeft(s string, length int, pad rune) string {
for len(s) < length {
s = string(pad) + s
}
return s
}