/* Copyright 2025 Dnote Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package controllers import ( "errors" "fmt" "net/http" "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/context" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/presenters" "github.com/gorilla/mux" "gorm.io/gorm" pkgErrors "github.com/pkg/errors" ) // NewBooks creates a new Books controller. // It panics if the necessary templates are not parsed. func NewBooks(app *app.App) *Books { return &Books{ app: app, } } // Books is a user controller. type Books struct { app *app.App } func (b *Books) getBooks(r *http.Request) ([]database.Book, error) { user := context.User(r.Context()) if user == nil { return []database.Book{}, app.ErrLoginRequired } conn := b.app.DB.Where("user_id = ? AND NOT deleted", user.ID).Order("label ASC") query := r.URL.Query() name := query.Get("name") if name != "" { part := fmt.Sprintf("%%%s%%", name) conn = conn.Where("LOWER(label) LIKE ?", part) } var books []database.Book if err := conn.Find(&books).Error; err != nil { return []database.Book{}, nil } return books, nil } // V3Index gets books func (b *Books) V3Index(w http.ResponseWriter, r *http.Request) { result, err := b.getBooks(r) if err != nil { handleJSONError(w, err, "getting books") return } respondJSON(w, http.StatusOK, presenters.PresentBooks(result)) } // V3Show gets a book func (b *Books) V3Show(w http.ResponseWriter, r *http.Request) { user := context.User(r.Context()) if user == nil { handleJSONError(w, app.ErrLoginRequired, "login required") return } vars := mux.Vars(r) bookUUID := vars["bookUUID"] if !helpers.ValidateUUID(bookUUID) { handleJSONError(w, app.ErrInvalidUUID, "login required") return } var book database.Book err := b.app.DB.Where("uuid = ? AND user_id = ?", bookUUID, user.ID).First(&book).Error if errors.Is(err, gorm.ErrRecordNotFound) { w.WriteHeader(http.StatusNotFound) return } if err != nil { handleJSONError(w, err, "finding the book") return } respondJSON(w, http.StatusOK, presenters.PresentBook(book)) } type createBookPayload struct { Name string `schema:"name" json:"name"` } func validateCreateBookPayload(p createBookPayload) error { if p.Name == "" { return app.ErrBookNameRequired } return nil } func (b *Books) create(r *http.Request) (database.Book, error) { user := context.User(r.Context()) if user == nil { return database.Book{}, app.ErrLoginRequired } var params createBookPayload if err := parseRequestData(r, ¶ms); err != nil { return database.Book{}, pkgErrors.Wrap(err, "parsing request payload") } if err := validateCreateBookPayload(params); err != nil { return database.Book{}, pkgErrors.Wrap(err, "validating payload") } var bookCount int64 err := b.app.DB.Model(database.Book{}). Where("user_id = ? AND label = ?", user.ID, params.Name). Count(&bookCount).Error if err != nil { return database.Book{}, pkgErrors.Wrap(err, "checking duplicate") } if bookCount > 0 { return database.Book{}, app.ErrDuplicateBook } book, err := b.app.CreateBook(*user, params.Name) if err != nil { return database.Book{}, pkgErrors.Wrap(err, "inserting a book") } return book, nil } // CreateBookResp is the response from create book api type CreateBookResp struct { Book presenters.Book `json:"book"` } // V3Create creates a book func (b *Books) V3Create(w http.ResponseWriter, r *http.Request) { result, err := b.create(r) if err != nil { handleJSONError(w, err, "creating a book") return } resp := CreateBookResp{ Book: presenters.PresentBook(result), } respondJSON(w, http.StatusCreated, resp) } type updateBookPayload struct { Name *string `schema:"name" json:"name"` } // UpdateBookResp is the response from create book api type UpdateBookResp struct { Book presenters.Book `json:"book"` } func (b *Books) update(r *http.Request) (database.Book, error) { user := context.User(r.Context()) if user == nil { return database.Book{}, app.ErrLoginRequired } vars := mux.Vars(r) uuid := vars["bookUUID"] if !helpers.ValidateUUID(uuid) { return database.Book{}, app.ErrInvalidUUID } tx := b.app.DB.Begin() var book database.Book if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil { tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "finding book") } var params updateBookPayload if err := parseRequestData(r, ¶ms); err != nil { tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "decoding payload") } book, err := b.app.UpdateBook(tx, *user, book, params.Name) if err != nil { tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "updating a book") } tx.Commit() return book, nil } // V3Update updates a book func (b *Books) V3Update(w http.ResponseWriter, r *http.Request) { book, err := b.update(r) if err != nil { handleJSONError(w, err, "updating a book") return } resp := UpdateBookResp{ Book: presenters.PresentBook(book), } respondJSON(w, http.StatusOK, resp) } func (b *Books) del(r *http.Request) (database.Book, error) { user := context.User(r.Context()) if user == nil { return database.Book{}, app.ErrLoginRequired } vars := mux.Vars(r) uuid := vars["bookUUID"] if !helpers.ValidateUUID(uuid) { return database.Book{}, app.ErrInvalidUUID } tx := b.app.DB.Begin() var book database.Book if err := tx.Where("user_id = ? AND uuid = ?", user.ID, uuid).First(&book).Error; err != nil { tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "finding a book") } var notes []database.Note if err := tx.Where("book_uuid = ? AND NOT deleted", uuid).Order("usn ASC").Find(¬es).Error; err != nil { tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "finding notes for the book") } for _, note := range notes { if _, err := b.app.DeleteNote(tx, *user, note); err != nil { tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "deleting a note in the book") } } book, err := b.app.DeleteBook(tx, *user, book) if err != nil { tx.Rollback() return database.Book{}, pkgErrors.Wrap(err, "deleting the book") } tx.Commit() return book, nil } // deleteBookResp is the response from create book api type deleteBookResp struct { Status int `json:"status"` Book presenters.Book `json:"book"` } // Delete updates a book func (b *Books) V3Delete(w http.ResponseWriter, r *http.Request) { book, err := b.del(r) if err != nil { handleJSONError(w, err, "creating a books") return } resp := deleteBookResp{ Status: http.StatusOK, Book: presenters.PresentBook(book), } respondJSON(w, http.StatusOK, resp) } // IndexOptions is a handler for OPTIONS endpoint for notes func (b *Books) IndexOptions(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Methods", "GET, POST") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Version") }