adding entities config page

This commit is contained in:
Maxime Duchêne-Savard 2026-07-29 11:04:32 -04:00
parent e2cf9c04b5
commit 46f0b091e8
9 changed files with 675 additions and 1 deletions

View File

@ -21,6 +21,7 @@
<nav aria-label="Main navigation">
<router-link v-if="auth.role === 'admin'" to="/users">User Management</router-link>
<router-link v-if="auth.role === 'admin'" to="/admin/entities">Entity Configuration</router-link>
</nav>
<div class="account">
@ -69,7 +70,8 @@
}
button,
input {
input,
select {
font: inherit;
}

View File

@ -0,0 +1,285 @@
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue'
import { auth } from '@/auth'
const fieldTypes = ['TEXT', 'NUMBER', 'BOOLEAN', 'DATE', 'EMAIL', 'PHONE'] as const
type FieldType = typeof fieldTypes[number]
interface EntityField {
id: number
name: string
type: FieldType
}
interface EntityDefinition {
id: number
name: string
fields: EntityField[]
}
const entities = ref<EntityDefinition[]>([])
const loading = ref(true)
const saving = ref(false)
const errorMessage = ref('')
const newEntityName = ref('')
const editingEntityId = ref<number>()
const editingEntityName = ref('')
const newFields = reactive<Record<number, { name: string, type: FieldType }>>({})
const editingFieldId = ref<number>()
const editingFieldName = ref('')
const editingFieldType = ref<FieldType>('TEXT')
function request (url: string, method = 'GET', values?: Record<string, string>) {
return fetch(url, {
method,
headers: {
Authorization: `Bearer ${auth.token}`,
...(values ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
},
body: values ? new URLSearchParams(values) : undefined,
})
}
async function loadEntities () {
try {
const response = await request('/api/entity-definitions')
if (!response.ok) throw new Error('Entity request failed')
entities.value = await response.json() as EntityDefinition[]
for (const entity of entities.value) ensureNewField(entity.id)
} catch {
errorMessage.value = 'Unable to load entity configuration.'
} finally {
loading.value = false
}
}
function ensureNewField (entityId: number) {
newFields[entityId] ??= { name: '', type: 'TEXT' }
}
async function addEntity () {
const name = newEntityName.value.trim()
if (!name) return
await mutate(async () => {
const response = await request('/api/entity-definitions', 'POST', { name })
if (!response.ok) throw new Error('Entity create failed')
const entity = await response.json() as EntityDefinition
entities.value.push(entity)
ensureNewField(entity.id)
newEntityName.value = ''
})
}
function startEntityEdit (entity: EntityDefinition) {
editingEntityId.value = entity.id
editingEntityName.value = entity.name
}
async function saveEntity (entity: EntityDefinition) {
const name = editingEntityName.value.trim()
if (!name) return
await mutate(async () => {
const response = await request(`/api/entity-definitions/${entity.id}`, 'PATCH', { name })
if (!response.ok) throw new Error('Entity update failed')
entity.name = name
editingEntityId.value = undefined
})
}
async function removeEntity (entity: EntityDefinition) {
if (!window.confirm(`Remove “${entity.name}” and all of its fields?`)) return
await mutate(async () => {
const response = await request(`/api/entity-definitions/${entity.id}`, 'DELETE')
if (!response.ok) throw new Error('Entity delete failed')
entities.value = entities.value.filter(item => item.id !== entity.id)
delete newFields[entity.id]
})
}
async function addField (entity: EntityDefinition) {
const draft = newFields[entity.id]
const name = draft.name.trim()
if (!name) return
await mutate(async () => {
const response = await request(`/api/entity-definitions/${entity.id}/fields`, 'POST', {
name,
type: draft.type,
})
if (!response.ok) throw new Error('Field create failed')
entity.fields.push(await response.json() as EntityField)
newFields[entity.id] = { name: '', type: 'TEXT' }
})
}
function startFieldEdit (field: EntityField) {
editingFieldId.value = field.id
editingFieldName.value = field.name
editingFieldType.value = field.type
}
async function saveField (entity: EntityDefinition, field: EntityField) {
const name = editingFieldName.value.trim()
if (!name) return
await mutate(async () => {
const response = await request(`/api/entity-definitions/${entity.id}/fields/${field.id}`, 'PATCH', {
name,
type: editingFieldType.value,
})
if (!response.ok) throw new Error('Field update failed')
Object.assign(field, await response.json() as EntityField)
editingFieldId.value = undefined
})
}
async function removeField (entity: EntityDefinition, field: EntityField) {
if (!window.confirm(`Remove field “${field.name}”?`)) return
await mutate(async () => {
const response = await request(`/api/entity-definitions/${entity.id}/fields/${field.id}`, 'DELETE')
if (!response.ok) throw new Error('Field delete failed')
entity.fields = entity.fields.filter(item => item.id !== field.id)
})
}
async function mutate (action: () => Promise<void>) {
saving.value = true
errorMessage.value = ''
try {
await action()
} catch {
errorMessage.value = 'The change could not be saved. Please try again.'
} finally {
saving.value = false
}
}
onMounted(loadEntities)
</script>
<template>
<section class="configuration-page">
<header>
<p class="eyebrow">Administration</p>
<h1>Entity Configuration</h1>
<p>Define the entities and fields your CRM will use. This configuration does not create database tables yet.</p>
</header>
<form aria-label="Add entity" class="add-entity" @submit.prevent="addEntity">
<label for="new-entity">Entity name</label>
<div class="form-row">
<input id="new-entity" v-model="newEntityName" placeholder="e.g. Company" required>
<button class="primary-button" :disabled="saving" type="submit">Add entity</button>
</div>
</form>
<p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
<p v-if="loading" class="status">Loading configuration</p>
<p v-else-if="entities.length === 0" class="status">No entities yet. Add your first entity above.</p>
<article v-for="entity in entities" :key="entity.id" class="entity-card">
<div class="entity-heading">
<form v-if="editingEntityId === entity.id" class="edit-row" @submit.prevent="saveEntity(entity)">
<label class="sr-only" :for="`edit-entity-${entity.id}`">Entity name</label>
<input :id="`edit-entity-${entity.id}`" v-model="editingEntityName" required>
<button class="text-button" type="submit">Save entity</button>
<button class="text-button muted" type="button" @click="editingEntityId = undefined">Cancel</button>
</form>
<template v-else>
<h2>{{ entity.name }}</h2>
<div class="actions">
<button class="text-button" type="button" @click="startEntityEdit(entity)">Edit entity</button>
<button class="text-button danger" type="button" @click="removeEntity(entity)">Remove entity</button>
</div>
</template>
</div>
<div class="fields">
<p v-if="entity.fields.length === 0" class="empty-fields">No fields configured.</p>
<div v-for="field in entity.fields" :key="field.id" class="field-row">
<form v-if="editingFieldId === field.id" class="edit-field" @submit.prevent="saveField(entity, field)">
<label class="sr-only" :for="`edit-field-${field.id}`">Field name</label>
<input :id="`edit-field-${field.id}`" v-model="editingFieldName" required>
<label class="sr-only" :for="`edit-type-${field.id}`">Field type</label>
<select :id="`edit-type-${field.id}`" v-model="editingFieldType">
<option v-for="type in fieldTypes" :key="type" :value="type">{{ type }}</option>
</select>
<button class="text-button" type="submit">Save field</button>
<button class="text-button muted" type="button" @click="editingFieldId = undefined">Cancel</button>
</form>
<template v-else>
<div><strong>{{ field.name }}</strong><span class="type-badge">{{ field.type }}</span></div>
<div class="actions">
<button class="text-button" type="button" @click="startFieldEdit(field)">Edit field</button>
<button class="text-button danger" type="button" @click="removeField(entity, field)">Remove field</button>
</div>
</template>
</div>
</div>
<form :aria-label="`Add field to ${entity.name}`" class="add-field" @submit.prevent="addField(entity)">
<label :for="`new-field-${entity.id}`">New field</label>
<div class="field-inputs">
<input :id="`new-field-${entity.id}`" v-model="newFields[entity.id].name" placeholder="Field name" required>
<label class="sr-only" :for="`new-type-${entity.id}`">Field type</label>
<select :id="`new-type-${entity.id}`" v-model="newFields[entity.id].type">
<option v-for="type in fieldTypes" :key="type" :value="type">{{ type }}</option>
</select>
<button class="secondary-button" :disabled="saving" type="submit">Add field</button>
</div>
</form>
</article>
</section>
</template>
<style scoped>
.configuration-page { width: 100%; max-width: 70rem; }
header { margin-bottom: 2rem; }
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
h1 { margin: 0; font-size: 2rem; letter-spacing: -0.04em; }
header > p:last-child { max-width: 42rem; color: var(--v0-on-surface-variant); line-height: 1.5; }
label { display: block; margin-bottom: 0.45rem; font-size: 0.82rem; font-weight: 700; }
input, select { height: 2.75rem; padding: 0 0.8rem; border: 1px solid var(--v0-divider); border-radius: 0.6rem; outline: none; color: var(--v0-on-surface); background: var(--v0-surface-variant); }
input:focus, select:focus { border-color: var(--v0-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--v0-primary) 18%, transparent); }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: 0.6; }
.add-entity { max-width: 35rem; margin-bottom: 2rem; }
.form-row, .field-inputs, .edit-row, .edit-field { display: flex; gap: 0.65rem; align-items: center; }
.form-row input { flex: 1; }
.primary-button, .secondary-button { height: 2.75rem; padding: 0 1rem; border-radius: 0.6rem; font-weight: 700; }
.primary-button { border: 0; color: var(--v0-on-primary); background: var(--v0-primary); }
.secondary-button { border: 1px solid var(--v0-divider); color: var(--v0-on-surface); background: var(--v0-surface-variant); }
.entity-card { margin-bottom: 1.25rem; overflow: hidden; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
.entity-heading { display: flex; min-height: 4.5rem; padding: 1rem 1.25rem; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v0-divider); }
.entity-heading h2 { margin: 0; font-size: 1.2rem; }
.actions { display: flex; gap: 0.85rem; }
.text-button { padding: 0.25rem; border: 0; color: var(--v0-primary); background: transparent; font-weight: 600; }
.text-button.danger { color: var(--v0-error); }
.text-button.muted { color: var(--v0-on-surface-variant); }
.fields { padding: 0 1.25rem; }
.field-row { display: flex; min-height: 3.75rem; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v0-divider); }
.field-row:last-child { border-bottom: 0; }
.field-row strong { margin-right: 0.75rem; }
.type-badge { padding: 0.2rem 0.55rem; border-radius: 999px; color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 13%, transparent); font-size: 0.7rem; font-weight: 700; }
.empty-fields { color: var(--v0-on-surface-variant); font-size: 0.875rem; }
.add-field { padding: 1rem 1.25rem 1.25rem; border-top: 1px solid var(--v0-divider); background: color-mix(in srgb, var(--v0-surface-variant) 35%, transparent); }
.field-inputs input { flex: 1; }
.error { color: var(--v0-error); }
.status { padding: 1.5rem; border: 1px dashed var(--v0-divider); border-radius: 0.8rem; color: var(--v0-on-surface-variant); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 700px) {
.entity-heading, .field-row { gap: 0.75rem; align-items: flex-start; flex-direction: column; }
.form-row, .field-inputs, .edit-row, .edit-field { align-items: stretch; flex-direction: column; }
.entity-heading, .field-row { padding-block: 1rem; }
input, select, .primary-button, .secondary-button { width: 100%; }
}
</style>

View File

@ -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

View File

@ -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()
})

View File

@ -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<EntityDefinition>.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) {

View File

@ -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<EntityField>,
)
enum class FieldType {
TEXT,
NUMBER,
BOOLEAN,
DATE,
EMAIL,
PHONE,
;
companion object {
fun from(value: String): FieldType? =
entries.firstOrNull { it.name.equals(value, ignoreCase = true) }
}
}

View File

@ -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<EntityDefinition> {
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<EntityField> =
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")),
)

View File

@ -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)
)
"""
}
}

View File

@ -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"))
}
}