From 6621e323e6ede226eefdcda9adabdcb147163e6d Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Sun, 8 Feb 2026 09:43:55 -0600 Subject: [PATCH] feat: admin create user + import content (posts/quips/beacons) endpoints --- go-backend/cmd/api/main.go | 4 + go-backend/internal/handlers/admin_handler.go | 281 ++++++++++++++++++ 2 files changed, 285 insertions(+) diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index a2d7e4c..1d6aa0e 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -469,6 +469,10 @@ func main() { admin.GET("/ai/moderation-log", adminHandler.GetAIModerationLog) admin.POST("/ai/moderation-log/:id/feedback", adminHandler.SubmitAIModerationFeedback) admin.GET("/ai/training-data", adminHandler.ExportAITrainingData) + + // Admin Content Creation & Import + admin.POST("/users/create", adminHandler.AdminCreateUser) + admin.POST("/content/import", adminHandler.AdminImportContent) } // Public claim request endpoint (no auth) diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index c308d6c..530a322 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -2533,3 +2533,284 @@ func (h *AdminHandler) ExportAITrainingData(c *gin.Context) { "count": len(data), }) } + +// ────────────────────────────────────────────── +// Admin Create User +// ────────────────────────────────────────────── + +func (h *AdminHandler) AdminCreateUser(c *gin.Context) { + ctx := c.Request.Context() + adminID, _ := c.Get("user_id") + + var req struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + Handle string `json:"handle" binding:"required"` + DisplayName string `json:"display_name" binding:"required"` + Bio string `json:"bio"` + Role string `json:"role"` + Verified bool `json:"verified"` + Official bool `json:"official"` + SkipEmail bool `json:"skip_email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + req.Handle = strings.ToLower(strings.TrimSpace(req.Handle)) + + // Check for existing email + var existsCount int + h.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.users WHERE email = $1", req.Email).Scan(&existsCount) + if existsCount > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"}) + return + } + + // Check for existing handle + h.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.profiles WHERE handle = $1", req.Handle).Scan(&existsCount) + if existsCount > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "Handle already taken"}) + return + } + + hashedBytes, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + userID := uuid.New() + now := time.Now() + + tx, err := h.pool.Begin(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction"}) + return + } + defer tx.Rollback(ctx) + + // Create user (already active + verified, admin-created) + _, err = tx.Exec(ctx, ` + INSERT INTO public.users (id, email, encrypted_password, status, mfa_enabled, created_at, updated_at) + VALUES ($1, $2, $3, 'active', false, $4, $4) + `, userID, req.Email, string(hashedBytes), now) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) + return + } + + role := "user" + if req.Role != "" { + role = req.Role + } + + // Create profile + _, err = tx.Exec(ctx, ` + INSERT INTO public.profiles (id, handle, display_name, bio, is_verified, is_official, role, has_completed_onboarding) + VALUES ($1, $2, $3, $4, $5, $6, $7, true) + `, userID, req.Handle, req.DisplayName, req.Bio, req.Verified, req.Official, role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create profile: " + err.Error()}) + return + } + + // Initialize trust state + _, err = tx.Exec(ctx, ` + INSERT INTO public.trust_state (user_id, harmony_score, tier) + VALUES ($1, 50, 'new') + `, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to init trust state: " + err.Error()}) + return + } + + if err := tx.Commit(ctx); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit: " + err.Error()}) + return + } + + // Audit log + h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_create_user', 'user', $2, $3)`, + adminID, userID.String(), fmt.Sprintf(`{"email":"%s","handle":"%s"}`, req.Email, req.Handle)) + + log.Info().Str("admin", adminID.(string)).Str("new_user", userID.String()).Str("handle", req.Handle).Msg("Admin created user") + + c.JSON(http.StatusCreated, gin.H{ + "message": "User created successfully", + "user_id": userID.String(), + "email": req.Email, + "handle": req.Handle, + }) +} + +// ────────────────────────────────────────────── +// Admin Import Content (Posts / Quips / Beacons) +// ────────────────────────────────────────────── + +func (h *AdminHandler) AdminImportContent(c *gin.Context) { + ctx := c.Request.Context() + adminID, _ := c.Get("user_id") + + var req struct { + AuthorID string `json:"author_id" binding:"required"` + ContentType string `json:"content_type" binding:"required"` // post, quip, beacon + Items []struct { + Body string `json:"body"` + MediaURL string `json:"media_url"` + ThumbnailURL string `json:"thumbnail_url"` + DurationMS int `json:"duration_ms"` + Tags []string `json:"tags"` + CategoryID string `json:"category_id"` + IsNSFW bool `json:"is_nsfw"` + NSFWReason string `json:"nsfw_reason"` + Visibility string `json:"visibility"` + BeaconType string `json:"beacon_type"` + Lat float64 `json:"lat"` + Long float64 `json:"long"` + } `json:"items" binding:"required,min=1"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + authorUUID, err := uuid.Parse(req.AuthorID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid author_id"}) + return + } + + // Verify author exists + var authorExists int + h.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.profiles WHERE id = $1", authorUUID).Scan(&authorExists) + if authorExists == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Author not found"}) + return + } + + validTypes := map[string]bool{"post": true, "quip": true, "beacon": true} + if !validTypes[req.ContentType] { + c.JSON(http.StatusBadRequest, gin.H{"error": "content_type must be post, quip, or beacon"}) + return + } + + var created []string + var errors []string + + for i, item := range req.Items { + postID := uuid.New() + visibility := item.Visibility + if visibility == "" { + visibility = "public" + } + + var imageURL, videoURL, thumbnailURL *string + var categoryID *uuid.UUID + var beaconType *string + var lat, long *float64 + isBeacon := false + durationMS := item.DurationMS + + // Determine media type from URL + mediaURL := strings.TrimSpace(item.MediaURL) + if mediaURL != "" { + lower := strings.ToLower(mediaURL) + if strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".mov") || strings.HasSuffix(lower, ".webm") || req.ContentType == "quip" { + videoURL = &mediaURL + } else { + imageURL = &mediaURL + } + } + + if item.ThumbnailURL != "" { + thumbnailURL = &item.ThumbnailURL + } + + if item.CategoryID != "" { + if catUUID, err := uuid.Parse(item.CategoryID); err == nil { + categoryID = &catUUID + } + } + + if req.ContentType == "beacon" { + isBeacon = true + if item.BeaconType != "" { + beaconType = &item.BeaconType + } else { + bt := "general" + beaconType = &bt + } + if item.Lat != 0 || item.Long != 0 { + lat = &item.Lat + long = &item.Long + } + } + + tx, err := h.pool.Begin(ctx) + if err != nil { + errors = append(errors, fmt.Sprintf("item %d: tx start failed", i)) + continue + } + + _, err = tx.Exec(ctx, ` + INSERT INTO public.posts ( + id, author_id, category_id, body, status, tone_label, cis_score, + image_url, video_url, thumbnail_url, duration_ms, body_format, tags, + is_beacon, beacon_type, location, confidence_score, + is_active_beacon, allow_chain, visibility, + is_nsfw, nsfw_reason + ) VALUES ( + $1, $2, $3, $4, 'active', 'neutral', 0.8, + $5, $6, $7, $8, 'plain', $9, + $10, $11, + CASE WHEN ($12::double precision) IS NOT NULL AND ($13::double precision) IS NOT NULL + THEN ST_SetSRID(ST_MakePoint(($13::double precision), ($12::double precision)), 4326)::geography + ELSE NULL END, + 0.5, $10, true, $14, + $15, $16 + ) RETURNING id + `, postID, authorUUID, categoryID, item.Body, + imageURL, videoURL, thumbnailURL, durationMS, item.Tags, + isBeacon, beaconType, lat, long, visibility, + item.IsNSFW, item.NSFWReason, + ) + if err != nil { + tx.Rollback(ctx) + errors = append(errors, fmt.Sprintf("item %d: %s", i, err.Error())) + continue + } + + // Initialize metrics + _, err = tx.Exec(ctx, "INSERT INTO public.post_metrics (post_id) VALUES ($1)", postID) + if err != nil { + tx.Rollback(ctx) + errors = append(errors, fmt.Sprintf("item %d: metrics init failed", i)) + continue + } + + if err := tx.Commit(ctx); err != nil { + errors = append(errors, fmt.Sprintf("item %d: commit failed", i)) + continue + } + + created = append(created, postID.String()) + } + + // Audit log + h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'admin_import_content', 'post', $2, $3)`, + adminID, req.AuthorID, fmt.Sprintf(`{"type":"%s","count":%d,"errors":%d}`, req.ContentType, len(created), len(errors))) + + log.Info().Str("admin", adminID.(string)).Str("type", req.ContentType).Int("created", len(created)).Int("errors", len(errors)).Msg("Admin import content") + + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("Imported %d/%d items", len(created), len(req.Items)), + "created": created, + "errors": errors, + "total": len(req.Items), + "success": len(created), + "failures": len(errors), + }) +}