package handlers import ( "context" "net/http" "time" "bookhoard/internal/database" wsync "bookhoard/internal/sync" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) // DeletedAnnotationResponse is one entry of the deleted-annotation history // for a book (the book page's "recently deleted" list). Restoring returns the // row to the active set; purging removes it permanently. type DeletedAnnotationResponse struct { ID string `json:"id"` AnnotationType string `json:"annotation_type"` DisplayText string `json:"display_text"` SecondaryText string `json:"secondary_text,omitempty"` Color string `json:"color,omitempty"` DeletedAt time.Time `json:"deleted_at"` CreatedAt time.Time `json:"created_at"` } // DeletedAnnotationsForBook builds the deleted-annotation history for a user // and book. Shared by the JSON API and the book page's server-rendered modal. func DeletedAnnotationsForBook(ctx context.Context, db *database.Queries, userID, mediaItemID pgtype.UUID) []DeletedAnnotationResponse { rows, err := db.ListDeletedAnnotationsForBook(ctx, database.ListDeletedAnnotationsForBookParams{ MediaItemID: mediaItemID, UserID: userID, }) if err != nil { return []DeletedAnnotationResponse{} } response := make([]DeletedAnnotationResponse, 0, len(rows)) for _, row := range rows { entry := DeletedAnnotationResponse{ ID: uuid.UUID(row.ID.Bytes).String(), AnnotationType: row.AnnotationType, DisplayText: row.DisplayText, SecondaryText: row.SecondaryText.String, Color: row.Color.String, } if row.DeletedAt.Valid { entry.DeletedAt = row.DeletedAt.Time } if row.CreatedAt.Valid { entry.CreatedAt = row.CreatedAt.Time } response = append(response, entry) } return response } // GetDeletedAnnotations handles GET /api/media-items/:id/annotations/deleted func (mh *MediaHandler) GetDeletedAnnotations(c *echo.Context) error { userUUID, mediaUUID, err := mh.parseUserAndMediaIDs(c) if err != nil { return err } response := DeletedAnnotationsForBook(c.Request().Context(), mh.db, userUUID, mediaUUID) return c.JSON(http.StatusOK, map[string]interface{}{ "deleted_annotations": response, "total": len(response), }) } // RestoreDeletedAnnotation handles POST /api/media-items/:id/annotations/:annotationId/restore // Body/query: annotation_type=highlight|note|bookmark func (mh *MediaHandler) RestoreDeletedAnnotation(c *echo.Context) error { userUUID, mediaUUID, annotationUUID, kind, errResp := mh.parseAnnotationHistoryRequest(c, true) if errResp != nil { return errResp } if mh.annotationSvc == nil { return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "annotation service unavailable"}) } restored, err := mh.annotationSvc.RestoreAnnotationByID(c.Request().Context(), kind, userUUID, mediaUUID, annotationUUID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to restore annotation"}) } if !restored { return c.JSON(http.StatusNotFound, map[string]string{"error": "deleted annotation not found"}) } return c.JSON(http.StatusOK, map[string]interface{}{"restored": true}) } // PurgeDeletedAnnotation handles DELETE /api/media-items/:id/annotations/:annotationId // Query: annotation_type=highlight|note|bookmark. Permanent — removes the // tombstoned row from the history. func (mh *MediaHandler) PurgeDeletedAnnotation(c *echo.Context) error { userUUID, mediaUUID, annotationUUID, kind, errResp := mh.parseAnnotationHistoryRequest(c, false) if errResp != nil { return errResp } if mh.annotationSvc == nil { return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "annotation service unavailable"}) } purged, err := mh.annotationSvc.PurgeAnnotationByID(c.Request().Context(), kind, userUUID, mediaUUID, annotationUUID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to purge annotation"}) } if !purged { return c.JSON(http.StatusNotFound, map[string]string{"error": "deleted annotation not found"}) } return c.JSON(http.StatusOK, map[string]interface{}{"purged": true}) } // parseUserAndMediaIDs extracts the authenticated user and the media item // from the route. A non-nil error has already been written as the response. func (mh *MediaHandler) parseUserAndMediaIDs(c *echo.Context) (pgtype.UUID, pgtype.UUID, error) { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return pgtype.UUID{}, pgtype.UUID{}, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaIDUUID, err := uuid.Parse(mediaID) if err != nil { return pgtype.UUID{}, pgtype.UUID{}, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } return pgtype.UUID{Bytes: userUUID, Valid: true}, pgtype.UUID{Bytes: mediaIDUUID, Valid: true}, nil } // parseAnnotationHistoryRequest extracts user, media item, annotation ID, and // the annotation_type (from query param or JSON body — restore posts a body, // purge uses a query param). A non-nil error has already been written. func (mh *MediaHandler) parseAnnotationHistoryRequest(c *echo.Context, allowBody bool) (pgtype.UUID, pgtype.UUID, pgtype.UUID, string, error) { userUUID, mediaUUID, err := mh.parseUserAndMediaIDs(c) if err != nil { return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", err } annotationID := c.Param("annotationId") annotationUUID, err := uuid.Parse(annotationID) if err != nil { return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid annotation id"}) } kind := c.QueryParam("annotation_type") if kind == "" && allowBody { var body struct { AnnotationType string `json:"annotation_type"` } if c.Bind(&body) == nil { kind = body.AnnotationType } } if !wsync.ValidAnnotationKind(kind) { return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", c.JSON(http.StatusBadRequest, map[string]string{"error": "annotation_type must be highlight, note, or bookmark"}) } return userUUID, mediaUUID, pgtype.UUID{Bytes: annotationUUID, Valid: true}, kind, nil }