diff --git a/api/http/test.http b/api/http/test.http index c32cf85..9a8f0f9 100644 --- a/api/http/test.http +++ b/api/http/test.http @@ -19,7 +19,43 @@ GET http://localhost:8081/notes Authorization: Bearer {{token}} > {% +client.global.set("uuid", response.body[0].uuid); client.test("Request executed successfully", function() { client.assert(response.status === 200, "Response status is not 200"); }); %} + +### Create note +POST http://localhost:8081/notes +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "title": "test", + "tags": [ + "Some", + "Tags" + ], + "chapters": [ + { + "title": "Chapter 1", + "content": "# This is some content" + } + ] +} + +> {% +client.test("Request executed successfully", function() { + client.assert(response.status === 201, "Response status is not 201"); +}); +%} + +### Get a note +GET http://localhost:8081/notes/{{uuid}} +Authorization: Bearer {{token}} + +> {% +client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); +}); +%} \ No newline at end of file diff --git a/api/resources/db/migration/V8__Notes_uuid.sql b/api/resources/db/migration/V8__Notes_uuid.sql new file mode 100644 index 0000000..301e6f4 --- /dev/null +++ b/api/resources/db/migration/V8__Notes_uuid.sql @@ -0,0 +1,37 @@ +-- no need to migrate existing data yet +drop table if exists Chapters; +drop table if exists Tags; +drop table if exists Notes; + +CREATE TABLE `Notes` +( + `uuid` binary(16) PRIMARY KEY, + `title` varchar(50) NOT NULL, + `user_id` int NOT NULL, + `updated_at` datetime +); + +ALTER TABLE `Notes` + ADD FOREIGN KEY (`user_id`) REFERENCES `Users` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT; + +CREATE TABLE `Tags` +( + `id` int PRIMARY KEY AUTO_INCREMENT, + `name` varchar(50) NOT NULL, + `note_uuid` binary(16) NOT NULL +); + +ALTER TABLE `Tags` + ADD FOREIGN KEY (`note_uuid`) REFERENCES `Notes` (`uuid`) ON DELETE CASCADE ON UPDATE RESTRICT; + +CREATE TABLE `Chapters` +( + `id` int PRIMARY KEY AUTO_INCREMENT, + `number` int NOT NULL, + `title` varchar(50) NOT NULL, + `content` text NOT NULL, + `note_uuid` binary(16) NOT NULL +); + +ALTER TABLE `Chapters` + ADD FOREIGN KEY (`note_uuid`) REFERENCES `Notes` (`uuid`) ON DELETE CASCADE ON UPDATE RESTRICT; diff --git a/api/resources/logback.xml b/api/resources/logback.xml index e573f7d..c19e387 100644 --- a/api/resources/logback.xml +++ b/api/resources/logback.xml @@ -4,7 +4,7 @@ %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - + diff --git a/api/src/entities/Note.kt b/api/src/entities/Note.kt index f3571a7..09ec45c 100644 --- a/api/src/entities/Note.kt +++ b/api/src/entities/Note.kt @@ -2,11 +2,12 @@ package be.vandewalleh.entities import me.liuwj.ktorm.entity.* import java.time.LocalDateTime +import java.util.* interface Note : Entity { companion object : Entity.Factory() - val id: Int + var uuid: UUID var title: String var user: User var updatedAt: LocalDateTime diff --git a/api/src/extensions/ApplicationCallExtensions.kt b/api/src/extensions/ApplicationCallExtensions.kt index 3a241ce..a8a3f3b 100644 --- a/api/src/extensions/ApplicationCallExtensions.kt +++ b/api/src/extensions/ApplicationCallExtensions.kt @@ -1,6 +1,8 @@ package be.vandewalleh.extensions import be.vandewalleh.kodein +import be.vandewalleh.services.FullNoteCreateDTO +import be.vandewalleh.services.FullNotePatchDTO import be.vandewalleh.services.UserService import io.ktor.application.* import io.ktor.auth.* @@ -23,12 +25,8 @@ fun ApplicationCall.userId(): Int { return userService.getUserId(email)!! } -private class Tags(val tags: List) +class NoteCreate(val title: String, val tags: List) -suspend fun ApplicationCall.receiveTags(): List { - return receive().tags -} +suspend fun ApplicationCall.receiveNoteCreate(): FullNoteCreateDTO = receive() -data class NotePatch(val tags: List?, val title: String?) - -suspend fun ApplicationCall.receiveNotePatch() = receive() \ No newline at end of file +suspend fun ApplicationCall.receiveNotePatch() : FullNotePatchDTO = receive() \ No newline at end of file diff --git a/api/src/extensions/KtormExtensions.kt b/api/src/extensions/KtormExtensions.kt new file mode 100644 index 0000000..2b691e0 --- /dev/null +++ b/api/src/extensions/KtormExtensions.kt @@ -0,0 +1,27 @@ +package be.vandewalleh.extensions + +import me.liuwj.ktorm.schema.* +import java.nio.ByteBuffer +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.Types +import java.util.* + +class UuidBinarySqlType : SqlType(Types.BINARY, typeName = "uuidBinary") { + override fun doGetResult(rs: ResultSet, index: Int): UUID? { + val value = rs.getBytes(index) ?: return null + return ByteBuffer.wrap(value).let { b -> UUID(b.long, b.long) } + } + + override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: UUID) { + val bytes = ByteBuffer.allocate(16) + .putLong(parameter.mostSignificantBits) + .putLong(parameter.leastSignificantBits) + .array() + ps.setBytes(index, bytes) + } +} + +fun BaseTable.uuidBinary(name: String): BaseTable.ColumnRegistration { + return registerColumn(name, UuidBinarySqlType()) +} diff --git a/api/src/extensions/ParametersExtensions.kt b/api/src/extensions/ParametersExtensions.kt index 5e7863b..af7dcd0 100644 --- a/api/src/extensions/ParametersExtensions.kt +++ b/api/src/extensions/ParametersExtensions.kt @@ -1,22 +1,12 @@ package be.vandewalleh.extensions -import be.vandewalleh.kodein -import be.vandewalleh.services.NotesService -import be.vandewalleh.tables.Notes import io.ktor.http.* -import org.kodein.di.generic.instance - -private val notesService by kodein.instance() +import java.util.* fun Parameters.noteTitle(): String { return this["noteTitle"]!! } -/** - * Method that returns a [Notes] ID from it's title and the currently logged in user. - * returns null if none found - */ -fun Parameters.noteId(userId: Int): Int? { - val title = noteTitle() - return notesService.getNoteIdFromUserIdAndTitle(userId, title) +fun Parameters.noteUuid(): UUID { + return UUID.fromString(this["noteUuid"]) } \ No newline at end of file diff --git a/api/src/features/CorsFeature.kt b/api/src/features/CorsFeature.kt index ddc7342..019c172 100644 --- a/api/src/features/CorsFeature.kt +++ b/api/src/features/CorsFeature.kt @@ -9,5 +9,6 @@ fun Application.corsFeature() { anyHost() header(HttpHeaders.ContentType) header(HttpHeaders.Authorization) + methods.add(HttpMethod.Delete) } } \ No newline at end of file diff --git a/api/src/routing/ChaptersController.kt b/api/src/routing/ChaptersController.kt deleted file mode 100644 index 4236948..0000000 --- a/api/src/routing/ChaptersController.kt +++ /dev/null @@ -1,13 +0,0 @@ -package be.vandewalleh.routing - -import io.ktor.auth.* -import io.ktor.routing.* -import org.kodein.di.Kodein - -fun Routing.chapters(kodein: Kodein) { - authenticate { - route("/notes/{noteTitle}/chapters/{chapterNumber}") { - - } - } -} diff --git a/api/src/routing/NotesController.kt b/api/src/routing/NotesController.kt index ac2e587..00ec6a5 100644 --- a/api/src/routing/NotesController.kt +++ b/api/src/routing/NotesController.kt @@ -1,9 +1,11 @@ package be.vandewalleh.routing +import be.vandewalleh.extensions.receiveNoteCreate import be.vandewalleh.extensions.userId import be.vandewalleh.services.NotesService import io.ktor.application.* import io.ktor.auth.* +import io.ktor.http.* import io.ktor.response.* import io.ktor.routing.* import org.kodein.di.Kodein @@ -18,5 +20,12 @@ fun Routing.notes(kodein: Kodein) { val notes = notesService.getNotes(userId) call.respond(notes) } + + post("/notes") { + val userId = call.userId() + val note = call.receiveNoteCreate() + val uuid = notesService.createNote(userId, note) + call.respond(HttpStatusCode.Created, mapOf("uuid" to uuid)) + } } } diff --git a/api/src/routing/Routes.kt b/api/src/routing/Routes.kt index da82d16..4ed1e22 100644 --- a/api/src/routing/Routes.kt +++ b/api/src/routing/Routes.kt @@ -8,6 +8,5 @@ fun Routing.registerRoutes(kodein: Kodein) { login(kodein) notes(kodein) title(kodein) - chapters(kodein) tags(kodein) } \ No newline at end of file diff --git a/api/src/routing/TitleController.kt b/api/src/routing/TitleController.kt index 292e368..8c71777 100644 --- a/api/src/routing/TitleController.kt +++ b/api/src/routing/TitleController.kt @@ -1,6 +1,9 @@ package be.vandewalleh.routing -import be.vandewalleh.extensions.* +import be.vandewalleh.extensions.noteUuid +import be.vandewalleh.extensions.receiveNotePatch +import be.vandewalleh.extensions.respondStatus +import be.vandewalleh.extensions.userId import be.vandewalleh.services.NotesService import io.ktor.application.* import io.ktor.auth.* @@ -14,49 +17,39 @@ fun Routing.title(kodein: Kodein) { val notesService by kodein.instance() authenticate { - route("/notes/{noteTitle}") { - post { - val userId = call.userId() - val title = call.parameters.noteTitle() - val tags = call.receiveTags() - val noteId = call.parameters.noteId(userId) - - if (noteId != null) { - return@post call.respondStatus(HttpStatusCode.Conflict) - } - - notesService.createNote(userId, title, tags) - call.respondStatus(HttpStatusCode.Created) - } - + route("/notes/{noteUuid}") { get { val userId = call.userId() - val noteId = call.parameters.noteId(userId) - ?: return@get call.respondStatus(HttpStatusCode.NotFound) + val noteUuid = call.parameters.noteUuid() - val response = notesService.getTagsAndChapters(noteId) + val exists = notesService.noteExists(userId, noteUuid) + if (!exists) return@get call.respondStatus(HttpStatusCode.NotFound) + + val response = notesService.getNote(noteUuid) call.respond(response) } patch { - val notePatch = call.receiveNotePatch() - if (notePatch.tags == null && notePatch.title == null) - return@patch call.respondStatus(HttpStatusCode.BadRequest) - val userId = call.userId() - val noteId = call.parameters.noteId(userId) - ?: return@patch call.respondStatus(HttpStatusCode.NotFound) + val noteUuid = call.parameters.noteUuid() - notesService.updateNote(noteId, notePatch.tags, notePatch.title) + val exists = notesService.noteExists(userId, noteUuid) + if (!exists) return@patch call.respondStatus(HttpStatusCode.NotFound) + + val notePatch = call.receiveNotePatch().copy(uuid = noteUuid) + + notesService.updateNote(notePatch) call.respondStatus(HttpStatusCode.OK) } delete { val userId = call.userId() - val noteId = call.parameters.noteId(userId) - ?: return@delete call.respondStatus(HttpStatusCode.NotFound) + val noteUuid = call.parameters.noteUuid() - notesService.deleteNote(noteId) + val exists = notesService.noteExists(userId, noteUuid) + if (!exists) return@delete call.respondStatus(HttpStatusCode.NotFound) + + notesService.deleteNote(noteUuid) call.respondStatus(HttpStatusCode.OK) } } diff --git a/api/src/services/NotesService.kt b/api/src/services/NotesService.kt index f89c829..38f6d6e 100644 --- a/api/src/services/NotesService.kt +++ b/api/src/services/NotesService.kt @@ -5,11 +5,13 @@ import be.vandewalleh.tables.Notes import be.vandewalleh.tables.Tags import me.liuwj.ktorm.database.* import me.liuwj.ktorm.dsl.* +import me.liuwj.ktorm.entity.* import org.kodein.di.Kodein import org.kodein.di.KodeinAware import org.kodein.di.generic.instance import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.util.* /** * service to handle database queries at the Notes level. @@ -18,102 +20,175 @@ class NotesService(override val kodein: Kodein) : KodeinAware { private val db by instance() /** - * returns a list of [NotesDTO] associated with the userId + * returns a list of [BasicNoteDTO] associated with the userId */ - fun getNotes(userId: Int): List = db.from(Notes) - .select(Notes.id, Notes.title, Notes.updatedAt) - .where { Notes.userId eq userId } - .orderBy(Notes.updatedAt.desc()) - .map { row -> - val tags = db.from(Tags) - .select(Tags.name) - .where { Tags.noteId eq row[Notes.id]!! } - .map { it[Tags.name]!! } + fun getNotes(userId: Int): List { + val notes = db.sequenceOf(Notes) + .filterColumns { listOf(it.uuid, it.title, it.updatedAt) } + .filter { it.userId eq userId } + .sortedByDescending { it.updatedAt } + .toList() - val updatedAt = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(row[Notes.updatedAt]!!) + if (notes.isEmpty()) return emptyList() - NotesDTO(row[Notes.title]!!, tags, updatedAt) + val tags = db.sequenceOf(Tags) + .filterColumns { listOf(it.noteUuid, it.name) } + .filter { it.noteUuid inList notes.map { it.uuid } } + .toList() + + return notes.map { note -> + val noteTags = tags.asSequence() + .filter { it.note.uuid == note.uuid } + .map { it.name } + .toList() + + BasicNoteDTO(note.uuid, note.title, noteTags, DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(note.updatedAt)) } + } - fun getNoteIdFromUserIdAndTitle(userId: Int, noteTitle: String): Int? = db.from(Notes) - .select(Notes.id) - .where { Notes.userId eq userId and (Notes.title eq noteTitle) } - .limit(0, 1) - .map { it[Notes.id]!! } - .firstOrNull() + fun noteExists(userId: Int, uuid: UUID): Boolean { + return db.from(Notes) + .select(Notes.uuid) + .where { Notes.userId eq userId } + .where { Notes.uuid eq uuid } + .limit(0, 1) + .toList().size == 1 + } - fun createNote(userId: Int, title: String, tags: List) { + fun createNote(userId: Int, note: FullNoteCreateDTO): UUID { + val uuid = UUID.randomUUID() db.useTransaction { - val noteId = db.insertAndGenerateKey(Notes) { - it.title to title + db.insert(Notes) { + it.uuid to uuid + it.title to note.title it.userId to userId it.updatedAt to LocalDateTime.now() } - tags.forEach { tagName -> - db.insert(Tags) { - it.name to tagName - it.noteId to noteId + db.batchInsert(Tags) { + note.tags.forEach { tagName -> + item { + it.noteUuid to uuid + it.name to tagName + } } } + + db.batchInsert(Chapters) { + note.chapters.forEachIndexed { index, chapter -> + item { + it.noteUuid to uuid + it.title to chapter.title + it.number to index + it.content to chapter.content + } + } + } + } + return uuid } - fun getTagsAndChapters(noteId: Int): TagsChaptersDTO { + fun getNote(noteUuid: UUID): FullNoteDTO { + val note = db.sequenceOf(Notes) + .filterColumns { listOf(it.title, it.updatedAt) } + .find { it.uuid eq noteUuid } ?: error("Note not found") + val tags = db.from(Tags) .select(Tags.name) - .where { Tags.noteId eq noteId } + .where { Tags.noteUuid eq noteUuid } .map { it[Tags.name]!! } .toList() val chapters = db.from(Chapters) .select(Chapters.title, Chapters.content) - .where { Chapters.noteId eq noteId } + .where { Chapters.noteUuid eq noteUuid } .orderBy(Chapters.number.asc()) - .map { ChaptersDTO(it[Chapters.title]!!, it[Chapters.content]!!) } + .map { ChapterDTO(it[Chapters.title]!!, it[Chapters.content]!!) } .toList() - return TagsChaptersDTO(tags, chapters) + val updatedAtFormatted = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(note.updatedAt) + return FullNoteDTO( + uuid = noteUuid, + title = note.title, + updatedAt = updatedAtFormatted, + tags = tags, + chapters = chapters + ) } - fun updateNote(noteId: Int, tags: List?, title: String?): Unit = + fun updateNote(patch: FullNotePatchDTO) { + if (patch.uuid == null) return db.useTransaction { - if (title != null) { + if (patch.title != null) { db.update(Notes) { - it.title to title + it.title to patch.title it.updatedAt to LocalDateTime.now() - where { it.id eq noteId } + where { it.uuid eq patch.uuid } } } - if (tags != null) { + if (patch.tags != null) { // delete all tags db.delete(Tags) { - it.noteId eq noteId + it.noteUuid eq patch.uuid } // put new ones - tags.forEach { tagName -> + patch.tags.forEach { tagName -> db.insert(Tags) { it.name to tagName - it.noteId to noteId + it.noteUuid to patch.uuid } } } } - fun deleteNote(noteId: Int): Unit = + TODO("get chapters") + } + + fun deleteNote(noteUuid: UUID): Unit = db.useTransaction { - db.delete(Notes) { it.id eq noteId } + db.delete(Notes) { it.uuid eq noteUuid } } fun getTags(userId: Int): List = db.from(Tags) - .leftJoin(Notes, on = Tags.noteId eq Notes.id) + .leftJoin(Notes, on = Tags.noteUuid eq Notes.uuid) .select(Tags.name) .where { Notes.userId eq userId } .map { it[Tags.name]!! } } -data class ChaptersDTO(val title: String, val content: String) -data class TagsChaptersDTO(val tags: List, val chapters: List) -data class NotesDTO(val title: String, val tags: List, val updatedAt: String) \ No newline at end of file +data class ChapterDTO( + val title: String, + val content: String +) + +data class FullNoteDTO( + val uuid: UUID, + val title: String, + val updatedAt: String, + val tags: List, + val chapters: List +) + +data class FullNoteCreateDTO( + val title: String, + val tags: List, + val chapters: List +) + +data class FullNotePatchDTO( + val uuid: UUID? = null, + val title: String? = null, + val updatedAt: String? = null, + val tags: List? = null, + val chapters: List? = null +) + +data class BasicNoteDTO( + val uuid: UUID, + val title: String, + val tags: List, + val updatedAt: String +) \ No newline at end of file diff --git a/api/src/tables/Chapters.kt b/api/src/tables/Chapters.kt index 51ffb9b..ab04c9d 100644 --- a/api/src/tables/Chapters.kt +++ b/api/src/tables/Chapters.kt @@ -1,6 +1,7 @@ package be.vandewalleh.tables import be.vandewalleh.entities.Chapter +import be.vandewalleh.extensions.uuidBinary import me.liuwj.ktorm.schema.* object Chapters : Table("Chapters") { @@ -8,6 +9,6 @@ object Chapters : Table("Chapters") { val number by int("number").bindTo { it.number } val content by text("content").bindTo { it.content } val title by varchar("title").bindTo { it.title } - val noteId by int("note_id").references(Notes) { it.note } - val note get() = noteId.referenceTable as Notes + val noteUuid by uuidBinary("note_uuid").references(Notes) { it.note } + val note get() = noteUuid.referenceTable as Notes } \ No newline at end of file diff --git a/api/src/tables/Notes.kt b/api/src/tables/Notes.kt index 000dd9f..37ca204 100644 --- a/api/src/tables/Notes.kt +++ b/api/src/tables/Notes.kt @@ -1,10 +1,11 @@ package be.vandewalleh.tables import be.vandewalleh.entities.Note +import be.vandewalleh.extensions.uuidBinary import me.liuwj.ktorm.schema.* object Notes : Table("Notes") { - val id by int("id").primaryKey().bindTo { it.id } + val uuid by uuidBinary("uuid").primaryKey().bindTo { it.uuid } val title by varchar("title").bindTo { it.title } val userId by int("user_id").references(Users) { it.user } val updatedAt by datetime("updated_at").bindTo { it.updatedAt } diff --git a/api/src/tables/Tags.kt b/api/src/tables/Tags.kt index e9e52ef..0f26bd1 100644 --- a/api/src/tables/Tags.kt +++ b/api/src/tables/Tags.kt @@ -1,11 +1,12 @@ package be.vandewalleh.tables import be.vandewalleh.entities.Tag +import be.vandewalleh.extensions.uuidBinary import me.liuwj.ktorm.schema.* object Tags : Table("Tags") { val id by int("id").primaryKey().bindTo { it.id } val name by varchar("name").bindTo { it.name } - val noteId by int("note_id").references(Notes) { it.note } - val note get() = noteId.referenceTable as Notes + val noteUuid by uuidBinary("note_uuid").references(Notes) { it.note } + val note get() = noteUuid.referenceTable as Notes } \ No newline at end of file diff --git a/frontend/pages/notes.vue b/frontend/pages/notes.vue index 586b26a..9e76a41 100644 --- a/frontend/pages/notes.vue +++ b/frontend/pages/notes.vue @@ -70,14 +70,19 @@ export default { ], }), mounted() { - this.$axios.get('/notes').then((e) => { - this.notes = e.data - this.loading = false - }) + this.loadNotes() }, methods: { + async loadNotes() { + await this.$axios.get('/notes').then((e) => { + this.notes = e.data + this.loading = false + }) + }, editItem(item) {}, - deleteItem(item) {}, + async deleteItem(item) { + await this.$axios.delete(`/notes/${item.uuid}`).then(this.loadNotes) + }, format, }, }