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.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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue