package main import ( "net/http" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type registerDeviceRequest struct { Token string `json:"token" binding:"required"` Platform string `json:"platform"` } func messageJSON(m Message) gin.H { return gin.H{ "id": m.ID, "conversation_id": m.ConversationID, "sender_id": m.SenderID, "sender_mode": m.SenderMode, "text": m.Text, "retracted": m.Retracted, "created_at": m.CreatedAt, } } func registerBRoutes(r *gin.Engine, db *gorm.DB) { // Minimal HTML dashboard for operators (roadmap B). JSON stays on /admin/metrics. r.GET("/admin/dashboard", func(c *gin.Context) { if !requireAdmin(c) { return } c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, adminDashboardHTML) }) r.POST("/users/:id/device-tokens", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { return } if !requireSelf(c, db, userID) { return } var req registerDeviceRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } platform := req.Platform if platform == "" { platform = "android" } var existing DeviceToken err := db.Where("token = ?", req.Token).First(&existing).Error if err == nil { existing.UserID = userID existing.Platform = platform existing.UpdatedAt = time.Now() db.Save(&existing) c.JSON(http.StatusOK, gin.H{"id": existing.ID, "token": existing.Token, "platform": existing.Platform}) return } row := DeviceToken{UserID: userID, Token: req.Token, Platform: platform} if err := db.Create(&row).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"id": row.ID, "token": row.Token, "platform": row.Platform}) }) r.GET("/users/:id/device-tokens", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { return } if !requireSelf(c, db, userID) { return } var tokens []DeviceToken db.Where("user_id = ?", userID).Order("id desc").Find(&tokens) out := make([]gin.H, 0, len(tokens)) for _, t := range tokens { out = append(out, gin.H{ "id": t.ID, "platform": t.Platform, "token_tail": trimToken(t.Token), "updated_at": t.UpdatedAt, }) } c.JSON(http.StatusOK, gin.H{"device_tokens": out}) }) // Multi-device awareness: list active sessions for the authenticated user. r.GET("/users/:id/sessions", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { return } actor, ok := currentUser(c, db, true) if !ok { return } if actor.ID != userID { c.JSON(http.StatusForbidden, gin.H{"detail": "can only list your own sessions"}) return } var sessions []Session db.Where("user_id = ? AND expires_at > ?", userID, time.Now()).Order("id desc").Find(&sessions) out := make([]gin.H, 0, len(sessions)) current := bearerToken(c) for _, s := range sessions { out = append(out, gin.H{ "id": s.ID, "created_at": s.CreatedAt, "expires_at": s.ExpiresAt, "is_current": s.Token == current, }) } c.JSON(http.StatusOK, gin.H{"sessions": out}) }) // Revoke another (or current) session — multi-device control. r.DELETE("/users/:id/sessions/:sessionId", func(c *gin.Context) { userID, ok := parseUintParam(c, "id") if !ok { return } sessionID, ok := parseUintParam(c, "sessionId") if !ok { return } actor, ok := currentUser(c, db, true) if !ok { return } if actor.ID != userID { c.JSON(http.StatusForbidden, gin.H{"detail": "can only revoke your own sessions"}) return } var session Session if err := db.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "session not found"}) return } db.Delete(&session) c.JSON(http.StatusOK, gin.H{"deleted": true, "id": sessionID}) }) // Admin smoke-test push to a user (requires FCM_SERVER_KEY for real delivery). r.POST("/admin/push-test", func(c *gin.Context) { if !requireAdmin(c) { return } var req struct { UserID uint `json:"user_id" binding:"required"` Title string `json:"title"` Body string `json:"body"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } if req.Title == "" { req.Title = "와카뷰 테스트" } if req.Body == "" { req.Body = "push test" } sent, reason, err := notifyUser(db, req.UserID, req.Title, req.Body, map[string]string{"type": "test"}) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"sent": sent, "skipped_reason": reason}) }) } func trimToken(token string) string { if len(token) <= 8 { return "****" } return "…" + token[len(token)-6:] } const adminDashboardHTML = ` 와카뷰 · admin metrics

와카뷰 운영 지표

Bearer ADMIN_API_TOKEN 으로 /admin/metrics 를 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.

에스컬레이션 사유별

reasoncount

와카뷰 발송 차단 사유별

reasoncount

raw JSON

loading…
`