feat: admin create user + import content (posts/quips/beacons) endpoints
This commit is contained in:
parent
8d419ba057
commit
6621e323e6
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue