adding entities config page
This commit is contained in:
parent
e2cf9c04b5
commit
46f0b091e8
@ -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;
|
||||
}
|
||||
|
||||
|
||||
285
frontend/src/pages/entity-configuration.vue
Normal file
285
frontend/src/pages/entity-configuration.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
123
frontend/tests/entity-configuration.spec.ts
Normal file
123
frontend/tests/entity-configuration.spec.ts
Normal 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()
|
||||
})
|
||||
@ -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) {
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -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")),
|
||||
)
|
||||
@ -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)
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
18
src/test/kotlin/dev/mduchene/bolts/entity/FieldTypeTest.kt
Normal file
18
src/test/kotlin/dev/mduchene/bolts/entity/FieldTypeTest.kt
Normal 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"))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user