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 @@
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+ Loading configuration…
+ No entities yet. Add your first entity above.
+
+
+
+
+
+
+ {{ entity.name }}
+
+
+
+
+
+
+
+
+
+
No fields configured.
+
+
+
+
+
+ {{ field.name }}{{ field.type }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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"))
+ }
+}