feat: admin create user + import content (posts/quips/beacons) endpoints

This commit is contained in:
Patrick Britton 2026-02-08 09:43:55 -06:00
parent 8d419ba057
commit 6621e323e6
2 changed files with 285 additions and 0 deletions

View file

@ -469,6 +469,10 @@ func main() {
admin.GET("/ai/moderation-log", adminHandler.GetAIModerationLog) admin.GET("/ai/moderation-log", adminHandler.GetAIModerationLog)
admin.POST("/ai/moderation-log/:id/feedback", adminHandler.SubmitAIModerationFeedback) admin.POST("/ai/moderation-log/:id/feedback", adminHandler.SubmitAIModerationFeedback)
admin.GET("/ai/training-data", adminHandler.ExportAITrainingData) 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) // Public claim request endpoint (no auth)

View file

@ -2533,3 +2533,284 @@ func (h *AdminHandler) ExportAITrainingData(c *gin.Context) {
"count": len(data), "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),
})
}