From 46f0b091e8009acc89cefe1553f1aee39f910548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20Duch=C3=AAne-Savard?= Date: Wed, 29 Jul 2026 11:04:32 -0400 Subject: [PATCH] adding entities config page --- frontend/src/App.vue | 4 +- frontend/src/pages/entity-configuration.vue | 285 ++++++++++++++++++ frontend/src/router/index.ts | 14 + frontend/tests/entity-configuration.spec.ts | 123 ++++++++ src/main/kotlin/Main.kt | 93 ++++++ .../mduchene/bolts/entity/EntityDefinition.kt | 28 ++ .../entity/EntityDefinitionRepository.kt | 90 ++++++ .../mduchene/bolts/persistence/Database.kt | 21 ++ .../mduchene/bolts/entity/FieldTypeTest.kt | 18 ++ 9 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/entity-configuration.vue create mode 100644 frontend/tests/entity-configuration.spec.ts create mode 100644 src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt create mode 100644 src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt create mode 100644 src/test/kotlin/dev/mduchene/bolts/entity/FieldTypeTest.kt diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 27d4984..9691baf 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -21,6 +21,7 @@
@@ -69,7 +70,8 @@ } button, - input { + input, + select { font: inherit; } diff --git a/frontend/src/pages/entity-configuration.vue b/frontend/src/pages/entity-configuration.vue new file mode 100644 index 0000000..f06e66c --- /dev/null +++ b/frontend/src/pages/entity-configuration.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index f0bd102..17de85d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -6,6 +6,8 @@ // Composables import { createRouter, createWebHistory } from 'vue-router' +import { auth } from '@/auth' +import EntityConfiguration from '@/pages/entity-configuration.vue' import Index from '@/pages/index.vue' import Users from '@/pages/users.vue' @@ -19,8 +21,20 @@ const router = createRouter({ { path: '/users', component: Users, + meta: { requiresAdmin: true }, + }, + { + path: '/admin/entities', + component: EntityConfiguration, + meta: { requiresAdmin: true }, }, ], }) +router.beforeEach(to => { + if (to.meta.requiresAdmin && (!auth.token || auth.role !== 'admin')) { + return '/' + } +}) + export default router diff --git a/frontend/tests/entity-configuration.spec.ts b/frontend/tests/entity-configuration.spec.ts new file mode 100644 index 0000000..425a61d --- /dev/null +++ b/frontend/tests/entity-configuration.spec.ts @@ -0,0 +1,123 @@ +import { expect, type Page, type Route, test } from '@playwright/test' + +interface EntityField { + id: number + name: string + type: string +} + +interface EntityDefinition { + id: number + name: string + fields: EntityField[] +} + +async function useAdminSession (page: Page) { + await page.addInitScript(() => { + sessionStorage.setItem('bolts-session', JSON.stringify({ + username: 'admin', + role: 'admin', + token: 'admin-token', + })) + }) +} + +function formValues (route: Route) { + return Object.fromEntries(new URLSearchParams(route.request().postData() ?? '')) +} + +test('only admins can access entity configuration', async ({ page }) => { + await page.addInitScript(() => { + sessionStorage.setItem('bolts-session', JSON.stringify({ + username: 'member', + role: 'user', + token: 'user-token', + })) + }) + + await page.goto('/admin/entities') + + await expect(page).toHaveURL('/') + await expect(page.getByRole('heading', { name: 'Entity Configuration' })).not.toBeVisible() +}) + +test('admin can add, edit, and remove an entity and its fields', async ({ page }) => { + const entities: EntityDefinition[] = [] + let nextEntityId = 1 + let nextFieldId = 1 + + await useAdminSession(page) + await page.route('**/api/entity-definitions**', async route => { + const request = route.request() + const url = new URL(request.url()) + const segments = url.pathname.split('/').filter(Boolean) + const entityId = Number(segments[2]) + const fieldId = Number(segments[4]) + const entity = entities.find(item => item.id === entityId) + + if (request.method() === 'GET') { + await route.fulfill({ json: entities }) + } else if (request.method() === 'POST' && segments.at(-1) === 'entity-definitions') { + const created = { id: nextEntityId++, name: formValues(route).name, fields: [] } + entities.push(created) + await route.fulfill({ status: 201, json: created }) + } else if (request.method() === 'PATCH' && segments.length === 3 && entity) { + entity.name = formValues(route).name + await route.fulfill({ json: entity }) + } else if (request.method() === 'DELETE' && segments.length === 3 && entity) { + entities.splice(entities.indexOf(entity), 1) + await route.fulfill({ status: 204 }) + } else if (request.method() === 'POST' && segments.at(-1) === 'fields' && entity) { + const values = formValues(route) + const created = { id: nextFieldId++, name: values.name, type: values.type } + entity.fields.push(created) + await route.fulfill({ status: 201, json: created }) + } else if (request.method() === 'PATCH' && entity) { + const field = entity.fields.find(item => item.id === fieldId)! + Object.assign(field, formValues(route)) + await route.fulfill({ json: field }) + } else if (request.method() === 'DELETE' && entity) { + const index = entity.fields.findIndex(item => item.id === fieldId) + entity.fields.splice(index, 1) + await route.fulfill({ status: 204 }) + } else { + await route.fulfill({ status: 404 }) + } + }) + + await page.goto('/admin/entities') + await expect(page.getByRole('heading', { name: 'Entity Configuration' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Entity Configuration' })).toBeVisible() + + await page.getByLabel('Entity name', { exact: true }).fill('Company') + await page.getByRole('button', { name: 'Add entity' }).click() + await expect(page.getByRole('heading', { name: 'Company' })).toBeVisible() + + await page.getByRole('button', { name: 'Edit entity' }).click() + const entityEditForm = page.getByRole('button', { name: 'Save entity' }).locator('..') + await entityEditForm.getByLabel('Entity name', { exact: true }).fill('Organization') + await entityEditForm.getByRole('button', { name: 'Save entity' }).click() + await expect(page.getByRole('heading', { name: 'Organization' })).toBeVisible() + + await page.getByLabel('New field').fill('Website') + await page.getByLabel('Field type').selectOption('EMAIL') + await page.getByRole('button', { name: 'Add field' }).click() + await expect(page.getByText('Website')).toBeVisible() + await expect(page.locator('.type-badge')).toHaveText('EMAIL') + + await page.getByRole('button', { name: 'Edit field' }).click() + const fieldEditForm = page.getByRole('button', { name: 'Save field' }).locator('..') + await fieldEditForm.getByLabel('Field name').fill('Annual revenue') + await fieldEditForm.getByLabel('Field type').selectOption('NUMBER') + await fieldEditForm.getByRole('button', { name: 'Save field' }).click() + await expect(page.getByText('Annual revenue')).toBeVisible() + await expect(page.locator('.type-badge')).toHaveText('NUMBER') + + page.on('dialog', dialog => dialog.accept()) + await page.getByRole('button', { name: 'Remove field' }).click() + await expect(page.getByText('Annual revenue')).not.toBeVisible() + + await page.getByRole('button', { name: 'Remove entity' }).click() + await expect(page.getByRole('heading', { name: 'Organization' })).not.toBeVisible() + await expect(page.getByText('No entities yet.')).toBeVisible() +}) diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 5b050d1..a0e5bd4 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -1,5 +1,9 @@ import dev.mduchene.bolts.persistence.Database import dev.mduchene.bolts.persistence.DatabaseConfig +import dev.mduchene.bolts.entity.EntityDefinition +import dev.mduchene.bolts.entity.EntityDefinitionRepository +import dev.mduchene.bolts.entity.EntityField +import dev.mduchene.bolts.entity.FieldType import dev.mduchene.bolts.user.LoginService import dev.mduchene.bolts.user.SessionRepository import dev.mduchene.bolts.user.UserRepository @@ -11,6 +15,7 @@ fun main() { database.initialize() val users = UserRepository(database) val loginService = LoginService(users, SessionRepository(database)) + val entities = EntityDefinitionRepository(database) loginService.initializeAdmin() Javalin.create { config -> @@ -47,10 +52,98 @@ fun main() { } ctx.contentType("application/json").result(result) } + get("/api/entity-definitions") { ctx -> + if (!ctx.requireAdmin(loginService)) return@get + ctx.contentType("application/json").result(entities.findAll().toJson()) + } + post("/api/entity-definitions") { ctx -> + if (!ctx.requireAdmin(loginService)) return@post + val name = ctx.requiredFormParam("name") ?: return@post + ctx.status(HttpStatus.CREATED).contentType("application/json") + .result(entities.create(name).toJson()) + } + patch("/api/entity-definitions/{entityId}") { ctx -> + if (!ctx.requireAdmin(loginService)) return@patch + val id = ctx.pathParam("entityId").toLongOrNull() + val name = ctx.requiredFormParam("name") + if (id == null || name == null) return@patch + val entity = entities.update(id, name) + if (entity == null) ctx.notFound() else ctx.contentType("application/json").result(entity.toJson()) + } + delete("/api/entity-definitions/{entityId}") { ctx -> + if (!ctx.requireAdmin(loginService)) return@delete + val id = ctx.pathParam("entityId").toLongOrNull() + if (id == null || !entities.delete(id)) ctx.notFound() else ctx.status(HttpStatus.NO_CONTENT) + } + post("/api/entity-definitions/{entityId}/fields") { ctx -> + if (!ctx.requireAdmin(loginService)) return@post + val entityId = ctx.pathParam("entityId").toLongOrNull() + val name = ctx.requiredFormParam("name") + val type = ctx.formParam("type")?.let(FieldType::from) + if (entityId == null || name == null || type == null) { + if (type == null) ctx.badRequest("A valid field type is required") + return@post + } + val field = entities.createField(entityId, name, type) + if (field == null) ctx.notFound() else { + ctx.status(HttpStatus.CREATED).contentType("application/json").result(field.toJson()) + } + } + patch("/api/entity-definitions/{entityId}/fields/{fieldId}") { ctx -> + if (!ctx.requireAdmin(loginService)) return@patch + val entityId = ctx.pathParam("entityId").toLongOrNull() + val fieldId = ctx.pathParam("fieldId").toLongOrNull() + val name = ctx.requiredFormParam("name") + val type = ctx.formParam("type")?.let(FieldType::from) + if (entityId == null || fieldId == null || name == null || type == null) { + if (type == null) ctx.badRequest("A valid field type is required") + return@patch + } + val field = entities.updateField(entityId, fieldId, name, type) + if (field == null) ctx.notFound() else ctx.contentType("application/json").result(field.toJson()) + } + delete("/api/entity-definitions/{entityId}/fields/{fieldId}") { ctx -> + if (!ctx.requireAdmin(loginService)) return@delete + val entityId = ctx.pathParam("entityId").toLongOrNull() + val fieldId = ctx.pathParam("fieldId").toLongOrNull() + if (entityId == null || fieldId == null || !entities.deleteField(entityId, fieldId)) { + ctx.notFound() + } else { + ctx.status(HttpStatus.NO_CONTENT) + } + } } }.start(System.getenv("SERVER_PORT")?.toIntOrNull() ?: 7070) } +private fun io.javalin.http.Context.requireAdmin(loginService: LoginService): Boolean { + val token = header("Authorization")?.removePrefix("Bearer ") + if (loginService.userForToken(token)?.role == "admin") return true + status(HttpStatus.FORBIDDEN).contentType("application/json").result("""{"message":"Admin access required"}""") + return false +} + +private fun io.javalin.http.Context.requiredFormParam(name: String): String? { + val value = formParam(name)?.trim()?.takeIf(String::isNotEmpty) + if (value == null) badRequest("$name is required") + return value +} + +private fun io.javalin.http.Context.badRequest(message: String) { + status(HttpStatus.BAD_REQUEST).contentType("application/json") + .result("""{"message":"${message.toJsonString()}"}""") +} + +private fun io.javalin.http.Context.notFound() { + status(HttpStatus.NOT_FOUND).contentType("application/json").result("""{"message":"Not found"}""") +} + +private fun List.toJson() = joinToString(prefix = "[", postfix = "]") { it.toJson() } +private fun EntityDefinition.toJson() = + """{"id":$id,"name":"${name.toJsonString()}","fields":${fields.joinToString(prefix = "[", postfix = "]") { it.toJson() }}}""" +private fun EntityField.toJson() = + """{"id":$id,"name":"${name.toJsonString()}","type":"$type"}""" + private fun String.toJsonString() = buildString { for (character in this@toJsonString) { when (character) { diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt new file mode 100644 index 0000000..277b1c6 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinition.kt @@ -0,0 +1,28 @@ +package dev.mduchene.bolts.entity + +data class EntityField( + val id: Long, + val name: String, + val type: FieldType, +) + +data class EntityDefinition( + val id: Long, + val name: String, + val fields: List, +) + +enum class FieldType { + TEXT, + NUMBER, + BOOLEAN, + DATE, + EMAIL, + PHONE, + ; + + companion object { + fun from(value: String): FieldType? = + entries.firstOrNull { it.name.equals(value, ignoreCase = true) } + } +} diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt new file mode 100644 index 0000000..d4fd814 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt @@ -0,0 +1,90 @@ +package dev.mduchene.bolts.entity + +import dev.mduchene.bolts.persistence.Database +import java.sql.ResultSet + +class EntityDefinitionRepository(private val database: Database) { + fun findAll(): List { + val fields = database.queryList( + "SELECT id, entity_id, name, field_type FROM entity_fields ORDER BY id", + ) { + FieldRow( + entityId = getLong("entity_id"), + field = toEntityField(), + ) + }.groupBy(FieldRow::entityId) + + return database.queryList("SELECT id, name FROM entity_definitions ORDER BY id") { + val id = getLong("id") + EntityDefinition(id, getString("name"), fields[id].orEmpty().map(FieldRow::field)) + } + } + + fun create(name: String): EntityDefinition { + val sql = "INSERT INTO entity_definitions (name) VALUES (?) RETURNING id, name" + return checkNotNull(database.queryOne(sql, bind = { setString(1, name) }) { + EntityDefinition(getLong("id"), getString("name"), emptyList()) + }) + } + + fun update(id: Long, name: String): EntityDefinition? { + val sql = "UPDATE entity_definitions SET name = ? WHERE id = ? RETURNING id, name" + return database.queryOne(sql, bind = { + setString(1, name) + setLong(2, id) + }) { + val entityId = getLong("id") + EntityDefinition(entityId, getString("name"), fieldsFor(entityId)) + } + } + + fun delete(id: Long): Boolean = + database.executeUpdate("DELETE FROM entity_definitions WHERE id = ?") { setLong(1, id) } > 0 + + fun createField(entityId: Long, name: String, type: FieldType): EntityField? { + val sql = """ + INSERT INTO entity_fields (entity_id, name, field_type) + SELECT id, ?, ? FROM entity_definitions WHERE id = ? + RETURNING id, name, field_type + """.trimIndent() + return database.queryOne(sql, bind = { + setString(1, name) + setString(2, type.name) + setLong(3, entityId) + }) { toEntityField() } + } + + fun updateField(entityId: Long, fieldId: Long, name: String, type: FieldType): EntityField? { + val sql = """ + UPDATE entity_fields SET name = ?, field_type = ? + WHERE id = ? AND entity_id = ? + RETURNING id, name, field_type + """.trimIndent() + return database.queryOne(sql, bind = { + setString(1, name) + setString(2, type.name) + setLong(3, fieldId) + setLong(4, entityId) + }) { toEntityField() } + } + + fun deleteField(entityId: Long, fieldId: Long): Boolean = + database.executeUpdate("DELETE FROM entity_fields WHERE id = ? AND entity_id = ?") { + setLong(1, fieldId) + setLong(2, entityId) + } > 0 + + private fun fieldsFor(entityId: Long): List = + database.queryList( + "SELECT id, name, field_type FROM entity_fields WHERE entity_id = ? ORDER BY id", + bind = { setLong(1, entityId) }, + ) { toEntityField() } + + private data class FieldRow(val entityId: Long, val field: EntityField) +} + +private fun ResultSet.toEntityField() = EntityField( + id = getLong("id"), + name = getString("name"), + type = FieldType.valueOf(getString("field_type")), +) diff --git a/src/main/kotlin/dev/mduchene/bolts/persistence/Database.kt b/src/main/kotlin/dev/mduchene/bolts/persistence/Database.kt index 604df87..17f7911 100644 --- a/src/main/kotlin/dev/mduchene/bolts/persistence/Database.kt +++ b/src/main/kotlin/dev/mduchene/bolts/persistence/Database.kt @@ -41,6 +41,8 @@ class Database(private val config: DatabaseConfig) { statement.execute(CREATE_USERS_TABLE) statement.execute(ADD_USER_ROLE) statement.execute(CREATE_SESSIONS_TABLE) + statement.execute(CREATE_ENTITY_DEFINITIONS_TABLE) + statement.execute(CREATE_ENTITY_FIELDS_TABLE) } } } @@ -82,5 +84,24 @@ class Database(private val config: DatabaseConfig) { created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ + + const val CREATE_ENTITY_DEFINITIONS_TABLE = """ + CREATE TABLE IF NOT EXISTS entity_definitions ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + + const val CREATE_ENTITY_FIELDS_TABLE = """ + CREATE TABLE IF NOT EXISTS entity_fields ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + entity_id BIGINT NOT NULL REFERENCES entity_definitions(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + field_type VARCHAR(30) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (entity_id, name) + ) + """ } } diff --git a/src/test/kotlin/dev/mduchene/bolts/entity/FieldTypeTest.kt b/src/test/kotlin/dev/mduchene/bolts/entity/FieldTypeTest.kt new file mode 100644 index 0000000..b74a84d --- /dev/null +++ b/src/test/kotlin/dev/mduchene/bolts/entity/FieldTypeTest.kt @@ -0,0 +1,18 @@ +package dev.mduchene.bolts.entity + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class FieldTypeTest { + @Test + fun `parses supported field types without case sensitivity`() { + assertEquals(FieldType.TEXT, FieldType.from("text")) + assertEquals(FieldType.NUMBER, FieldType.from("NUMBER")) + } + + @Test + fun `rejects unsupported field types`() { + assertNull(FieldType.from("attachment")) + } +}