package handlers import ( "errors" "net/http" "strings" "time" "github.com/celestia-trace/backend/models" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type SessionHandler struct { DB *gorm.DB } func sessionPreloads(db *gorm.DB) *gorm.DB { return db.Preload("Events", func(events *gorm.DB) *gorm.DB { return events.Order("relative_time_ms asc") }).Preload("Assets") } // GetSessions supports cursor-like incremental pulls with updatedAfter and can // include deletion tombstones for multi-device reconciliation. func (h *SessionHandler) GetSessions(c *gin.Context) { userID := c.GetString("userID") query := sessionPreloads(h.DB).Where("user_id = ?", userID) if c.Query("includeDeleted") != "true" { query = query.Where("deleted_at IS NULL") } if search := c.Query("search"); search != "" { query = query.Where("title ILIKE ?", "%"+search+"%") } if value := c.Query("updatedAfter"); value != "" { updatedAfter, err := time.Parse(time.RFC3339Nano, value) if err != nil { respondError(c, http.StatusBadRequest, 400, "invalid updatedAfter timestamp") return } query = query.Where("updated_at > ?", updatedAfter) } var sessions []models.CelestiaSession if err := query.Order("updated_at asc, id asc").Find(&sessions).Error; err != nil { respondError(c, http.StatusInternalServerError, 500, "failed to query field records") return } respondSuccess(c, sessions) } func (h *SessionHandler) GetSessionDetail(c *gin.Context) { userID := c.GetString("userID") var session models.CelestiaSession if err := sessionPreloads(h.DB). Where("id = ? AND user_id = ? AND deleted_at IS NULL", c.Param("id"), userID). First(&session).Error; err != nil { respondError(c, http.StatusNotFound, 404, "record not found") return } respondSuccess(c, session) } // CreateSession is an idempotent upsert keyed by (user_id, client_id). func (h *SessionHandler) CreateSession(c *gin.Context) { userID := c.GetString("userID") var req models.CreateSessionRequest if err := c.ShouldBindJSON(&req); err != nil { respondError(c, http.StatusBadRequest, 400, "invalid input data") return } var session models.CelestiaSession var conflictRevision int64 var isConflict bool err := h.DB.Transaction(func(tx *gorm.DB) error { err := tx.Where("user_id = ? AND client_id = ?", userID, req.ClientID).First(&session).Error if errors.Is(err, gorm.ErrRecordNotFound) { clientID := req.ClientID session = models.CelestiaSession{ UserID: userID, ClientID: &clientID, Revision: 1, } } else if err != nil { return err } else { // Optimistic locking: reject if client baseRevision doesn't match if req.BaseRevision != nil && *req.BaseRevision != session.Revision { conflictRevision = session.Revision isConflict = true return nil // exit transaction without error; handled below } session.Revision++ } session.Title = req.Title session.StartTime = req.StartTime session.EndTime = req.EndTime session.DurationMs = req.DurationMs session.LocalAudioPath = "" session.IsSynced = true session.DeletedAt = nil session.PhotoCount, session.NoteCount = countEvents(req.Events) if session.ID == "" { if err := tx.Create(&session).Error; err != nil { return err } } else if err := tx.Save(&session).Error; err != nil { return err } for _, input := range req.Events { var event models.TimelineEvent err := tx.Where("session_id = ? AND client_id = ?", session.ID, input.ClientID).First(&event).Error if errors.Is(err, gorm.ErrRecordNotFound) { clientID := input.ClientID event = models.TimelineEvent{SessionID: session.ID, ClientID: &clientID} } else if err != nil { return err } event.RelativeTimeMs = input.RelativeTimeMs event.EventType = input.EventType event.TextContent = input.TextContent event.LocalFilePath = "" event.VoiceStartOffsetMs = input.VoiceStartOffsetMs event.VoiceEndOffsetMs = input.VoiceEndOffsetMs event.LocationName = input.LocationName event.LocationAddress = input.LocationAddress event.Latitude = input.Latitude event.Longitude = input.Longitude if event.ID == "" { if err := tx.Create(&event).Error; err != nil { return err } } else if err := tx.Save(&event).Error; err != nil { return err } } // Delete events that were removed on the client if len(req.DeletedEventClientIds) > 0 { if err := tx.Where("session_id = ? AND client_id IN ?", session.ID, req.DeletedEventClientIds). Delete(&models.TimelineEvent{}).Error; err != nil { return err } // Recalculate counts from the remaining DB events var remaining []models.TimelineEvent if err := tx.Where("session_id = ?", session.ID).Find(&remaining).Error; err != nil { return err } session.PhotoCount, session.NoteCount = countPersistedEvents(remaining) if err := tx.Save(&session).Error; err != nil { return err } } return nil }) if isConflict { c.JSON(http.StatusConflict, gin.H{ "code": 409, "message": "version conflict", "data": gin.H{"serverRevision": conflictRevision}, }) return } if err != nil { respondError(c, http.StatusInternalServerError, 500, "failed to save record") return } if err := sessionPreloads(h.DB).First(&session, "id = ?", session.ID).Error; err != nil { respondError(c, http.StatusInternalServerError, 500, "failed to reload record") return } respondSuccess(c, session) } func countEvents(events []models.CreateEventSubSchema) (photoCount, noteCount int) { for _, event := range events { switch event.EventType { case "PHOTO": photoCount++ case "NOTE": noteCount++ case "MARKER": // Exclude continuation markers (e.g. "续录时间:...") from note count if !strings.HasPrefix(event.TextContent, "续录时间:") { noteCount++ } case "CONTINUATION": // Dedicated continuation type, not counted as a note } } return } // countPersistedEvents recalculates counts from persisted TimelineEvent rows // (used after event deletion to keep counts accurate). func countPersistedEvents(events []models.TimelineEvent) (photoCount, noteCount int) { for _, event := range events { switch event.EventType { case "PHOTO": photoCount++ case "NOTE": noteCount++ case "MARKER": if !strings.HasPrefix(event.TextContent, "续录时间:") { noteCount++ } case "CONTINUATION": // Not counted } } return } func (h *SessionHandler) AddEventToSession(c *gin.Context) { userID := c.GetString("userID") sessionID := c.Param("id") var session models.CelestiaSession if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil { respondError(c, http.StatusNotFound, 404, "record not found") return } var req models.CreateEventRequest if err := c.ShouldBindJSON(&req); err != nil { respondError(c, http.StatusBadRequest, 400, "invalid event payload") return } clientID := req.ClientID event := models.TimelineEvent{ SessionID: session.ID, ClientID: &clientID, RelativeTimeMs: req.RelativeTimeMs, EventType: req.EventType, TextContent: req.TextContent, VoiceStartOffsetMs: req.VoiceStartOffsetMs, VoiceEndOffsetMs: req.VoiceEndOffsetMs, LocationName: req.LocationName, LocationAddress: req.LocationAddress, Latitude: req.Latitude, Longitude: req.Longitude, } if err := h.DB.Where("session_id = ? AND client_id = ?", session.ID, req.ClientID). Assign(event).FirstOrCreate(&event).Error; err != nil { respondError(c, http.StatusInternalServerError, 500, "failed to add event") return } h.DB.Model(&session).Updates(map[string]interface{}{"revision": gorm.Expr("revision + 1")}) respondSuccess(c, event) } // DeleteSession creates a tombstone rather than hard deleting cloud data. func (h *SessionHandler) DeleteSession(c *gin.Context) { userID := c.GetString("userID") sessionID := c.Param("id") now := time.Now().UTC() result := h.DB.Model(&models.CelestiaSession{}). Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID). Updates(map[string]interface{}{"deleted_at": &now, "revision": gorm.Expr("revision + 1")}) if result.Error != nil { respondError(c, http.StatusInternalServerError, 500, "failed to delete record") return } if result.RowsAffected == 0 { respondError(c, http.StatusNotFound, 404, "record not found") return } respondSuccess(c, gin.H{"deletedId": sessionID, "deletedAt": now}) }