diff --git a/demodata.sql b/demodata.sql index d3ad0ec..ac493ef 100644 --- a/demodata.sql +++ b/demodata.sql @@ -125,3 +125,24 @@ INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES (' INSERT INTO user_books(created_at, user_id, book_id, start_read_date, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'Recherches philosophiques'), '2025-11-22 00:00:00+00:00',0); INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Le château',(SELECT id FROM authors WHERE name = 'Franz Kafka'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'le_chateau.jpg')); INSERT INTO user_books(created_at, user_id, book_id, start_read_date, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'Le château'), '2025-10-30 00:00:00+00:00',0); + +-- collections +INSERT INTO collections(name, user_id) VALUES ('Littérature française',(SELECT id FROM users WHERE name = 'demo')); +INSERT INTO collections(name, user_id) VALUES ('Nouvelles',(SELECT id FROM users WHERE name = 'demo')); +INSERT INTO collections(name, user_id) VALUES ('Non fiction',(SELECT id FROM users WHERE name = 'demo')); + +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Nord')); +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Gargantua')); +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Duo')); +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Un barrage contre le Pacifique')); +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Rigodon')); +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Les dieux ont soif')); + +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Dojoji et autres nouvelles')); +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Le meurtre d''O-tsuya')); +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Le coup de pistolet')); +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Duo')); + +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'Recherches philosophiques')); +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'De sang-froid')); +INSERT INTO collection_books(collection_id, book_id) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'The Life of Jesus')); diff --git a/front/src/AppNavBar.vue b/front/src/AppNavBar.vue index eb6be16..9eee178 100644 --- a/front/src/AppNavBar.vue +++ b/front/src/AppNavBar.vue @@ -86,6 +86,14 @@ onMounted(() => { {{ $t('navbar.mybooks') }} + + {{ $t('navbar.mycollections') }} + {{ $t('navbar.explore') }} diff --git a/front/src/CollectionListElement.vue b/front/src/CollectionListElement.vue new file mode 100644 index 0000000..b513a14 --- /dev/null +++ b/front/src/CollectionListElement.vue @@ -0,0 +1,64 @@ + + + diff --git a/front/src/CollectionsBrowser.vue b/front/src/CollectionsBrowser.vue new file mode 100644 index 0000000..b032ac0 --- /dev/null +++ b/front/src/CollectionsBrowser.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/front/src/api.js b/front/src/api.js index e3f0dd0..2eea003 100644 --- a/front/src/api.js +++ b/front/src/api.js @@ -55,6 +55,11 @@ export async function getAppInfo(appInfo, appInfoErr) { .catch((err) => (appInfoErr.value = err)) } +export function getCollections(data, error, limit, offset) { + const queryParams = new URLSearchParams({ limit: limit, offset: offset }) + return useFetch(data, error, '/ws/collections' + '?' + queryParams.toString()) +} + export function getMyBooks(data, error, arg, limit, offset) { const queryParams = new URLSearchParams({ limit: limit, offset: offset }) return useFetch(data, error, '/ws/mybooks/' + arg + '?' + queryParams.toString()) diff --git a/front/src/locales/en.json b/front/src/locales/en.json index 837047d..c792a11 100644 --- a/front/src/locales/en.json +++ b/front/src/locales/en.json @@ -5,6 +5,7 @@ }, "navbar": { "mybooks": "My Books", + "mycollections": "My Collections", "addbook": "Add Book", "explore": "Explore", "logout": "Log out", diff --git a/front/src/locales/fr.json b/front/src/locales/fr.json index 08839d8..e3b1b2a 100644 --- a/front/src/locales/fr.json +++ b/front/src/locales/fr.json @@ -5,6 +5,7 @@ }, "navbar": { "mybooks": "Mes Livres", + "mycollections": "Mes Listes", "explore": "Explorer", "addbook": "Ajouter Un Livre", "logout": "Se déconnecter", diff --git a/front/src/router.js b/front/src/router.js index 6126207..75a7644 100644 --- a/front/src/router.js +++ b/front/src/router.js @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router' import BooksBrowser from './BooksBrowser.vue' +import CollectionsBrowser from './CollectionsBrowser.vue' import BookFormEdit from './BookFormEdit.vue' import AuthorForm from './AuthorForm.vue' import BookFormView from './BookFormView.vue' @@ -20,6 +21,7 @@ const routes = [ { path: '/books', component: BooksBrowser }, { path: '/book/:id', component: BookFormView, props: true }, { path: '/book/:id/edit', component: BookFormEdit, props: true }, + { path: '/collections', component: CollectionsBrowser }, { path: '/author/:id', component: AuthorForm, props: true }, { path: '/search/:searchterm', component: SearchBook, props: true }, { path: '/import/inventaire/:inventaireid', component: ImportInventaire, props: true }, diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index 185918d..127380a 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -6,9 +6,41 @@ import ( "git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/dto" "git.artlef.fr/bibliomane/internal/model" + "git.artlef.fr/bibliomane/internal/query" "gorm.io/gorm" ) +func CollectionQueryToDto(collectionsQueryResult []query.CollectionsQueryResult) []dto.CollectionItemGet { + var collections []dto.CollectionItemGet + for _, collectionDb := range collectionsQueryResult { + i := findIdInCollection(collections, collectionDb.ID) + if i == -1 { + collections = append(collections, dto.CollectionItemGet{ + ID: collectionDb.ID, + Name: collectionDb.Name, + }) + //current collection is the last element + i = len(collections) - 1 + } + collections[i].Books = append(collections[i].Books, dto.CollectionBookItemGet{ + ID: collectionDb.BookId, + Title: collectionDb.BookTitle, + CoverPath: collectionDb.CoverPath, + }) + } + return collections +} + +// returns the position in collections, -1 if not found +func findIdInCollection(collections []dto.CollectionItemGet, collectionId uint) int { + for i, collection := range collections { + if collection.ID == collectionId { + return i + } + } + return -1 +} + func FillBookDbFromFields(ac appcontext.AppContext, fields *dto.BookFields, book *model.Book) error { if fields.Title != nil { book.Title = *fields.Title diff --git a/internal/apitest/fetchallbooks_test.go b/internal/apitest/fetchallbooks_test.go index 4017640..8e82339 100644 --- a/internal/apitest/fetchallbooks_test.go +++ b/internal/apitest/fetchallbooks_test.go @@ -1,11 +1,7 @@ package apitest import ( - "encoding/json" - "fmt" "net/http" - "net/http/httptest" - "net/url" "testing" "git.artlef.fr/bibliomane/internal/dto" @@ -14,45 +10,12 @@ import ( ) func TestFetchAllBooks(t *testing.T) { - result := testFetchBooks(t, "15", "0") + status, result := testFetchBooks(t, "15", "0") + assert.Equal(t, http.StatusOK, status) assert.Equal(t, int64(31), result.Count) assert.Equal(t, 15, len(result.Books)) } -func testFetchBooks(t *testing.T, limit string, offset string) dto.BookItemsGet { - router := testutils.TestSetup() - - u, err := url.Parse("/ws/books") - if err != nil { - t.Error(err) - } - if limit != "" { - q := u.Query() - q.Set("limit", limit) - u.RawQuery = q.Encode() - } - if offset != "" { - q := u.Query() - q.Set("offset", offset) - u.RawQuery = q.Encode() - } - - q := u.Query() - q.Set("lang", "fr") - u.RawQuery = q.Encode() - - token := testutils.ConnectDemoUser(router) - req, _ := http.NewRequest("GET", u.String(), nil) - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - var result dto.BookItemsGet - s := w.Body.String() - err = json.Unmarshal([]byte(s), &result) - if err != nil { - t.Error(err) - } - assert.Equal(t, 200, w.Code) - return result +func testFetchBooks(t *testing.T, limit string, offset string) (int, dto.BookItemsGet) { + return testutils.TestFetchModel[dto.BookItemsGet](t, "/ws/books", limit, offset) } diff --git a/internal/apitest/fetchallcollections_test.go b/internal/apitest/fetchallcollections_test.go new file mode 100644 index 0000000..d01fa93 --- /dev/null +++ b/internal/apitest/fetchallcollections_test.go @@ -0,0 +1,21 @@ +package apitest + +import ( + "net/http" + "testing" + + "git.artlef.fr/bibliomane/internal/dto" + "git.artlef.fr/bibliomane/internal/testutils" + "github.com/stretchr/testify/assert" +) + +func TestFetchAllCollections_OK(t *testing.T) { + status, res := testFetchCollections(t, "10", "0") + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, int64(3), res.Count) + assert.Equal(t, 3, len(res.Collections)) +} + +func testFetchCollections(t *testing.T, limit string, offset string) (int, dto.CollectionItemsGet) { + return testutils.TestFetchModel[dto.CollectionItemsGet](t, "/ws/collections", limit, offset) +} diff --git a/internal/db/init.go b/internal/db/init.go index d6fc4b3..05ae685 100644 --- a/internal/db/init.go +++ b/internal/db/init.go @@ -22,6 +22,7 @@ func Initdb(databasePath string, demoDataPath string) *gorm.DB { db.AutoMigrate(&model.User{}) db.AutoMigrate(&model.UserBook{}) db.AutoMigrate(&model.StaticFile{}) + db.AutoMigrate(&model.Collection{}) var book model.Book queryResult := db.Limit(1).Find(&book) if queryResult.RowsAffected == 0 && demoDataPath != "" { diff --git a/internal/dto/out.go b/internal/dto/out.go index 6fcfb63..34cacf1 100644 --- a/internal/dto/out.go +++ b/internal/dto/out.go @@ -43,3 +43,20 @@ type BookItemGet struct { WantRead bool `json:"wantread"` CoverPath string `json:"coverPath"` } + +type CollectionItemsGet struct { + Count int64 `json:"count"` + Collections []CollectionItemGet `json:"collections"` +} + +type CollectionItemGet struct { + ID uint `json:"id"` + Name string `json:"name"` + Books []CollectionBookItemGet `json:"books"` +} + +type CollectionBookItemGet struct { + ID uint `json:"id"` + Title string `json:"title"` + CoverPath string `json:"coverPath"` +} diff --git a/internal/model/collection.go b/internal/model/collection.go new file mode 100644 index 0000000..57f819d --- /dev/null +++ b/internal/model/collection.go @@ -0,0 +1,11 @@ +package model + +import "gorm.io/gorm" + +type Collection struct { + gorm.Model + Name string + User User + UserID uint + Books []Book `gorm:"many2many:collection_books;"` +} diff --git a/internal/query/query.go b/internal/query/query.go index fca5cf9..f0a4a32 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -184,6 +184,59 @@ func selectBookItem() string { return "books.id, books.title, authors.name as author, books.short_description as description, books.inventaire_id, user_books.rating, user_books.read, DATE(user_books.start_read_date) as start_read_date, user_books.want_read, " + selectStaticFilesPath() } +type CollectionsQueryResult struct { + ID uint + Name string + BookId uint + BookTitle string + CoverPath string +} + +type collectionId struct { + ID uint +} + +func FetchAllCollections(db *gorm.DB, userId uint, limit int, offset int) ([]CollectionsQueryResult, error) { + var collections []CollectionsQueryResult + var collectionIds []collectionId + res := fetchCollections(db, userId).Limit(limit).Offset(offset).Find(&collectionIds) + if res.Error != nil { + return collections, res.Error + } + for _, collectionId := range collectionIds { + queryResults, err := queryBooksForCollection(db, collectionId.ID) + if err != nil { + return collections, res.Error + } + collections = append(collections, queryResults...) + } + return collections, res.Error +} + +func queryBooksForCollection(db *gorm.DB, collectionId uint) ([]CollectionsQueryResult, error) { + var collections []CollectionsQueryResult + query := db.Model(&model.Collection{}) + query = query.Select("collections.id, collections.name, books.id as book_id, books.title as book_title, " + selectStaticFilesPath()) + query = query.Joins("left join collection_books on (collection_books.collection_id = collections.id)") + query = query.Joins("left join books on (books.id = collection_books.book_id)") + query = joinStaticFiles(query) + query = query.Where("collections.id = ?", collectionId) + //only takes first 5 books + query = query.Limit(5) + res := query.Find(&collections) + return collections, res.Error +} + +func FetchAllCollectionsCount(db *gorm.DB, userId uint) (int64, error) { + var count int64 + res := fetchCollections(db, userId).Count(&count) + return count, res.Error +} + +func fetchCollections(db *gorm.DB, userId uint) *gorm.DB { + return db.Model(&model.Collection{}).Where("collections.user_id = ?", userId) +} + func selectStaticFilesPath() string { return "(CASE COALESCE(static_files.path, '') WHEN '' THEN '' ELSE concat('" + fileutils.GetWsLinkPrefix() + "', static_files.path) END) as CoverPath" } diff --git a/internal/routes/collectionsget.go b/internal/routes/collectionsget.go new file mode 100644 index 0000000..fb9c083 --- /dev/null +++ b/internal/routes/collectionsget.go @@ -0,0 +1,41 @@ +package routes + +import ( + "net/http" + + "git.artlef.fr/bibliomane/internal/adapter" + "git.artlef.fr/bibliomane/internal/appcontext" + "git.artlef.fr/bibliomane/internal/dto" + "git.artlef.fr/bibliomane/internal/myvalidator" + "git.artlef.fr/bibliomane/internal/query" +) + +func GetCollectionsHandler(ac appcontext.AppContext) { + user, err := ac.GetAuthenticatedUser() + if err != nil { + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + limit, err := ac.GetQueryLimit() + if err != nil { + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + offset, err := ac.GetQueryOffset() + if err != nil { + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + collectionsDb, err := query.FetchAllCollections(ac.Db, user.ID, limit, offset) + if err != nil { + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + collections := adapter.CollectionQueryToDto(collectionsDb) + count, err := query.FetchAllCollectionsCount(ac.Db, user.ID) + if err != nil { + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + ac.C.JSON(http.StatusOK, dto.CollectionItemsGet{Count: count, Collections: collections}) +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 31f1361..a2f803e 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -78,6 +78,9 @@ func Setup(config *config.Config) *gin.Engine { ws.GET("/author/:id/books", func(c *gin.Context) { routes.GetAuthorBooksHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) }) + ws.GET("/collections", func(c *gin.Context) { + routes.GetCollectionsHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) + }) ws.POST("/auth/signup", func(c *gin.Context) { routes.PostSignupHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) }) diff --git a/internal/testutils/testutils.go b/internal/testutils/testutils.go index 151902c..a34d220 100644 --- a/internal/testutils/testutils.go +++ b/internal/testutils/testutils.go @@ -6,6 +6,8 @@ import ( "log" "net/http" "net/http/httptest" + + "net/url" "strings" "testing" @@ -65,3 +67,40 @@ func TestBookPutCallWithDemoPayload(t *testing.T, payload string, bookId string, t.Errorf("%s", w.Body.String()) } } + +func TestFetchModel[T any](t *testing.T, urlpath string, limit string, offset string) (int, T) { + router := TestSetup() + + u, err := url.Parse(urlpath) + if err != nil { + t.Error(err) + } + if limit != "" { + q := u.Query() + q.Set("limit", limit) + u.RawQuery = q.Encode() + } + if offset != "" { + q := u.Query() + q.Set("offset", offset) + u.RawQuery = q.Encode() + } + + q := u.Query() + q.Set("lang", "fr") + u.RawQuery = q.Encode() + + token := ConnectDemoUser(router) + req, _ := http.NewRequest("GET", u.String(), nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var result T + s := w.Body.String() + err = json.Unmarshal([]byte(s), &result) + if err != nil { + t.Error(err) + } + return w.Code, result +}