allow editing individual records

This commit is contained in:
Maxime Duchêne-Savard 2026-07-30 15:59:35 -04:00
parent 00d3f2b13b
commit c6b1127e30
4 changed files with 546 additions and 42 deletions

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { auth } from '@/auth' import { auth } from '@/auth'
@ -7,19 +7,40 @@
id: number id: number
name: string name: string
identifier: string identifier: string
type: string type: 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'DATE' | 'EMAIL' | 'PHONE' | 'RELATIONSHIP' | 'USER'
targetEntityId?: number
relationshipType?: 'ONE_TO_ONE' | 'ONE_TO_MANY'
}
interface Entity {
id: number
name: string
fields: Field[]
}
interface ChildRecords {
entity: Entity
relationshipField: Field
records: Record<string, unknown>[]
} }
interface EntityRecord { interface EntityRecord {
entity: { id: number, name: string, fields: Field[] } entity: Entity
values: Record<string, unknown> values: Record<string, unknown>
children: ChildRecords[]
} }
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const record = ref<EntityRecord>() const record = ref<EntityRecord>()
const loading = ref(true) const loading = ref(true)
const saving = ref(false)
const editing = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const editValues = reactive<Record<string, string | boolean>>({})
const childValues = reactive<Record<string, Record<string, string | boolean>>>({})
const addingChildKey = ref('')
const userFilePath = computed(() => `/users/${route.params.userId}`) const userFilePath = computed(() => `/users/${route.params.userId}`)
const recordUrl = computed(
() => `/api/users/${route.params.userId}/entities/${route.params.entityId}/records/${route.params.recordId}`,
)
function displayValue (value: unknown) { function displayValue (value: unknown) {
if ([null, undefined, ''].includes(value as null | undefined | string)) return '—' if ([null, undefined, ''].includes(value as null | undefined | string)) return '—'
@ -27,12 +48,60 @@
return String(value) return String(value)
} }
onMounted(async () => { function inputType (field: Field) {
try { if (field.type === 'NUMBER' || field.type === 'RELATIONSHIP') return 'number'
const response = await fetch( if (field.type === 'DATE') return 'date'
`/api/users/${route.params.userId}/entities/${route.params.entityId}/records/${route.params.recordId}`, if (field.type === 'EMAIL') return 'email'
{ headers: { Authorization: `Bearer ${auth.token}` } }, if (field.type === 'PHONE') return 'tel'
return 'text'
}
function childKey (child: ChildRecords) {
return `${child.entity.id}-${child.relationshipField.id}`
}
function editableChildFields (child: ChildRecords) {
return child.entity.fields.filter(field =>
field.id !== child.relationshipField.id && field.type !== 'USER',
) )
}
function resetEditValues () {
if (!record.value) return
for (const field of record.value.entity.fields) {
editValues[field.identifier] = field.type === 'BOOLEAN'
? record.value.values[field.identifier] === true
: String(record.value.values[field.identifier] ?? '')
}
}
function startEditing () {
resetEditValues()
editing.value = true
errorMessage.value = ''
}
function startAddingChild (child: ChildRecords) {
const key = childKey(child)
childValues[key] = {}
for (const field of editableChildFields(child)) {
childValues[key][field.identifier] = field.type === 'BOOLEAN' ? false : ''
}
addingChildKey.value = key
errorMessage.value = ''
}
function formBody (fields: Field[], values: Record<string, string | boolean>) {
const body = new URLSearchParams()
for (const field of fields) body.set(field.identifier, String(values[field.identifier] ?? ''))
return body
}
async function loadRecord () {
try {
const response = await fetch(recordUrl.value, {
headers: { Authorization: `Bearer ${auth.token}` },
})
if (response.status === 404) { if (response.status === 404) {
await router.replace(userFilePath.value) await router.replace(userFilePath.value)
return return
@ -44,22 +113,112 @@
} finally { } finally {
loading.value = false loading.value = false
} }
}
async function saveRecord () {
if (!record.value) return
saving.value = true
errorMessage.value = ''
const fields = record.value.entity.fields.filter(field => field.type !== 'USER')
try {
const response = await fetch(recordUrl.value, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${auth.token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formBody(fields, editValues),
}) })
if (!response.ok) throw new Error('Record update failed')
record.value = await response.json() as EntityRecord
editing.value = false
} catch {
errorMessage.value = 'The record could not be saved. Check the field values and try again.'
} finally {
saving.value = false
}
}
async function addChild (child: ChildRecords) {
const fields = editableChildFields(child)
const key = childKey(child)
saving.value = true
errorMessage.value = ''
try {
const response = await fetch(
`${recordUrl.value}/children/${child.entity.id}/${child.relationshipField.id}`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${auth.token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formBody(fields, childValues[key]),
},
)
if (!response.ok) throw new Error('Child create failed')
const created = await response.json() as EntityRecord
child.records.push(created.values)
addingChildKey.value = ''
} catch {
errorMessage.value = `The ${child.entity.name} could not be added. Check the field values and try again.`
} finally {
saving.value = false
}
}
onMounted(loadRecord)
</script> </script>
<template> <template>
<section class="viewer-page"> <section class="viewer-page">
<router-link class="back-link" :to="userFilePath"> Back to user file</router-link> <router-link class="back-link" :to="userFilePath"> Back to user file</router-link>
<p v-if="loading">Loading record</p> <p v-if="loading">Loading record</p>
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p> <p v-else-if="errorMessage && !record" role="alert">{{ errorMessage }}</p>
<template v-else-if="record"> <template v-else-if="record">
<header> <header>
<div>
<p class="eyebrow">Record viewer</p> <p class="eyebrow">Record viewer</p>
<h1>{{ record.entity.name }} #{{ record.values.id }}</h1> <h1>{{ record.entity.name }} #{{ record.values.id }}</h1>
</div>
<button v-if="!editing" type="button" @click="startEditing">Edit record</button>
</header> </header>
<dl class="record-card"> <p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
<form v-if="editing" aria-label="Edit record" class="record-card edit-form" @submit.prevent="saveRecord">
<div class="readonly-field"><span>ID</span><span>{{ record.values.id }}</span></div>
<label v-for="field in record.entity.fields" :key="field.id">
<span>{{ field.name }}</span>
<span v-if="field.type === 'USER'" class="readonly-value">
{{ displayValue(record.values[field.identifier]) }}
</span>
<input
v-else-if="field.type === 'BOOLEAN'"
v-model="editValues[field.identifier]"
type="checkbox"
>
<input
v-else
v-model="editValues[field.identifier]"
:inputmode="field.type === 'NUMBER' || field.type === 'RELATIONSHIP' ? 'numeric' : undefined"
:type="inputType(field)"
>
</label>
<div class="form-actions">
<button :disabled="saving" type="submit">{{ saving ? 'Saving…' : 'Save changes' }}</button>
<button :disabled="saving" type="button" @click="editing = false">Cancel</button>
</div>
</form>
<dl v-else class="record-card">
<div><dt>ID</dt><dd>{{ displayValue(record.values.id) }}</dd></div> <div><dt>ID</dt><dd>{{ displayValue(record.values.id) }}</dd></div>
<div v-for="field in record.entity.fields" :key="field.id"> <div v-for="field in record.entity.fields" :key="field.id">
@ -67,20 +226,135 @@
<dd>{{ displayValue(record.values[field.identifier]) }}</dd> <dd>{{ displayValue(record.values[field.identifier]) }}</dd>
</div> </div>
</dl> </dl>
<section class="children-section">
<div class="section-heading">
<div>
<p class="eyebrow">Related records</p>
<h2>Children</h2>
</div>
</div>
<p v-if="record.children.length === 0" class="empty-state">No child entity types are configured.</p>
<article v-for="child in record.children" :key="childKey(child)" class="child-card">
<div class="child-heading">
<div>
<h3>{{ child.entity.name }}</h3>
<p>Linked by {{ child.relationshipField.name }}</p>
</div>
<button
v-if="child.relationshipField.relationshipType !== 'ONE_TO_ONE' || child.records.length === 0"
type="button"
@click="startAddingChild(child)"
>
Add {{ child.entity.name }}
</button>
</div>
<form
v-if="addingChildKey === childKey(child)"
:aria-label="`Add ${child.entity.name}`"
class="child-form"
@submit.prevent="addChild(child)"
>
<label v-for="field in editableChildFields(child)" :key="field.id">
<span>{{ field.name }}</span>
<input
v-if="field.type === 'BOOLEAN'"
v-model="childValues[childKey(child)][field.identifier]"
type="checkbox"
>
<input
v-else
v-model="childValues[childKey(child)][field.identifier]"
:type="inputType(field)"
>
</label>
<div class="form-actions">
<button :disabled="saving" type="submit">{{ saving ? 'Adding…' : `Add ${child.entity.name}` }}</button>
<button :disabled="saving" type="button" @click="addingChildKey = ''">Cancel</button>
</div>
</form>
<p v-if="child.records.length === 0" class="empty-state">No children yet.</p>
<div v-else class="table-scroll">
<table>
<thead>
<tr>
<th>ID</th>
<th v-for="field in child.entity.fields" :key="field.id">{{ field.name }}</th>
<th><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="childRecord in child.records" :key="String(childRecord.id)">
<td>{{ displayValue(childRecord.id) }}</td>
<td v-for="field in child.entity.fields" :key="field.id">
{{ displayValue(childRecord[field.identifier]) }}
</td>
<td>
<router-link
:aria-label="`View ${child.entity.name} record ${childRecord.id}`"
class="back-link"
:to="`/users/${route.params.userId}/entities/${child.entity.id}/records/${childRecord.id}`"
>
View
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</section>
</template> </template>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.viewer-page { width: 100%; max-width: 60rem; } .viewer-page { width: 100%; max-width: 72rem; }
.back-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; } .back-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
.back-link:hover { text-decoration: underline; } .back-link:hover { text-decoration: underline; }
header, .child-heading, .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
header { margin: 1.5rem 0 2rem; } header { margin: 1.5rem 0 2rem; }
button { padding: 0.65rem 0.9rem; border: 1px solid var(--v0-divider); border-radius: 0.6rem; color: inherit; background: var(--v0-surface); font: inherit; font-weight: 700; cursor: pointer; }
button[type="submit"] { color: var(--v0-on-primary); border-color: var(--v0-primary); background: var(--v0-primary); }
button:disabled { opacity: 0.55; cursor: wait; }
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } .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; } h1, h2, h3 { margin: 0; letter-spacing: -0.04em; }
.record-card { overflow: hidden; margin: 0; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); } h1 { font-size: 2rem; }
.record-card > div { display: grid; grid-template-columns: minmax(10rem, 1fr) 2fr; gap: 1rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--v0-divider); } .record-card, .child-card { overflow: hidden; margin: 0; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
.record-card > div, .edit-form > label, .readonly-field { display: grid; grid-template-columns: minmax(10rem, 1fr) 2fr; gap: 1rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--v0-divider); }
.record-card > div:last-child { border-bottom: 0; } .record-card > div:last-child { border-bottom: 0; }
dt { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; } dt, label > span:first-child, .readonly-field > span:first-child { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
dd { margin: 0; overflow-wrap: anywhere; } dd { margin: 0; overflow-wrap: anywhere; }
input { width: 100%; padding: 0.65rem 0.75rem; border: 1px solid var(--v0-divider); border-radius: 0.5rem; color: inherit; background: var(--v0-background); font: inherit; }
input[type="checkbox"] { width: 1.1rem; }
.readonly-value { color: inherit; font-size: inherit; font-weight: 400; text-transform: none; }
.form-actions { display: flex !important; justify-content: flex-end; grid-template-columns: none !important; }
.error { padding: 0.9rem 1rem; border-radius: 0.6rem; color: #9f1d1d; background: #fee2e2; }
.children-section { margin-top: 2.5rem; }
.section-heading { margin-bottom: 1rem; }
.child-card { margin-bottom: 1rem; }
.child-heading { padding: 1rem 1.25rem; border-bottom: 1px solid var(--v0-divider); }
.child-heading p { margin: 0.3rem 0 0; color: var(--v0-on-surface-variant); font-size: 0.85rem; }
.child-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: 1rem; padding: 1.25rem; border-bottom: 1px solid var(--v0-divider); }
.child-form label { display: grid; gap: 0.45rem; }
.child-form .form-actions { align-items: end; }
.empty-state { margin: 0; padding: 1.25rem; color: var(--v0-on-surface-variant); }
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; white-space: nowrap; }
th, td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--v0-divider); }
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
</style> </style>

View File

@ -11,6 +11,46 @@ async function useAdminSession (page: Page) {
} }
test('admin opens a user file and views a linked entity record', async ({ page }) => { test('admin opens a user file and views a linked entity record', async ({ page }) => {
let projectTitle = 'Website refresh'
const tasks: Record<string, unknown>[] = []
const projectResponse = () => ({
entity: {
id: 3,
name: 'Project',
identifier: 'project',
fields: [
{ id: 11, name: 'Title', identifier: 'title', type: 'TEXT' },
{ id: 12, name: 'Owner', identifier: 'owner', type: 'USER' },
],
},
values: { id: 42, title: projectTitle, owner: 7 },
children: [{
entity: {
id: 4,
name: 'Task',
identifier: 'task',
fields: [
{ id: 20, name: 'Description', identifier: 'description', type: 'TEXT' },
{
id: 21,
name: 'Project',
identifier: 'project',
type: 'RELATIONSHIP',
targetEntityId: 3,
},
],
},
relationshipField: {
id: 21,
name: 'Project',
identifier: 'project',
type: 'RELATIONSHIP',
targetEntityId: 3,
},
records: tasks,
}],
})
await useAdminSession(page) await useAdminSession(page)
await page.route('**/api/users', route => route.fulfill({ await page.route('**/api/users', route => route.fulfill({
json: [{ id: 7, username: 'alex', role: 'user' }], json: [{ id: 7, username: 'alex', role: 'user' }],
@ -32,20 +72,26 @@ test('admin opens a user file and views a linked entity record', async ({ page }
}], }],
}, },
})) }))
await page.route('**/api/users/7/entities/3/records/42', route => route.fulfill({ await page.route('**/api/users/7/entities/3/records/42', async route => {
if (route.request().method() === 'PATCH') {
const values = new URLSearchParams(route.request().postData() ?? '')
projectTitle = values.get('title') ?? projectTitle
}
await route.fulfill({ json: projectResponse() })
})
await page.route('**/api/users/7/entities/3/records/42/children/4/21', async route => {
const values = new URLSearchParams(route.request().postData() ?? '')
const created = { id: 55, description: values.get('description'), project: 42 }
tasks.push(created)
await route.fulfill({
status: 201,
json: { json: {
entity: { entity: projectResponse().children.at(0)!.entity,
id: 3, values: created,
name: 'Project', children: [],
identifier: 'project',
fields: [
{ id: 11, name: 'Title', identifier: 'title', type: 'TEXT' },
{ id: 12, name: 'Owner', identifier: 'owner', type: 'USER' },
],
}, },
values: { id: 42, title: 'Website refresh', owner: 7 }, })
}, })
}))
await page.goto('/users') await page.goto('/users')
await page.getByRole('link', { name: 'alex' }).click() await page.getByRole('link', { name: 'alex' }).click()
@ -58,4 +104,16 @@ test('admin opens a user file and views a linked entity record', async ({ page }
await expect(page.getByRole('heading', { name: 'Project #42' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Project #42' })).toBeVisible()
await expect(page.getByText('Website refresh')).toBeVisible() await expect(page.getByText('Website refresh')).toBeVisible()
await expect(page.getByText('Owner')).toBeVisible() await expect(page.getByText('Owner')).toBeVisible()
await page.getByRole('button', { name: 'Edit record' }).click()
const editForm = page.getByRole('form', { name: 'Edit record' })
await editForm.getByLabel('Title').fill('Mobile application')
await editForm.getByRole('button', { name: 'Save changes' }).click()
await expect(page.getByText('Mobile application')).toBeVisible()
await page.getByRole('button', { name: 'Add Task' }).click()
const childForm = page.getByRole('form', { name: 'Add Task' })
await childForm.getByLabel('Description').fill('Prepare launch')
await childForm.getByRole('button', { name: 'Add Task' }).click()
await expect(page.getByRole('row').filter({ hasText: 'Prepare launch' })).toContainText('42')
}) })

View File

@ -1,7 +1,9 @@
package dev.mduchene.bolts.entity package dev.mduchene.bolts.entity
import dev.mduchene.bolts.persistence.Database import dev.mduchene.bolts.persistence.Database
import java.sql.PreparedStatement
import java.sql.ResultSet import java.sql.ResultSet
import java.time.LocalDate
import java.time.temporal.TemporalAccessor import java.time.temporal.TemporalAccessor
data class LinkedEntityRecords( data class LinkedEntityRecords(
@ -12,6 +14,13 @@ data class LinkedEntityRecords(
data class EntityRecord( data class EntityRecord(
val entity: EntityDefinition, val entity: EntityDefinition,
val values: Map<String, Any?>, val values: Map<String, Any?>,
val children: List<ChildEntityRecords> = emptyList(),
)
data class ChildEntityRecords(
val entity: EntityDefinition,
val relationshipField: EntityField,
val records: List<Map<String, Any?>>,
) )
class EntityRecordRepository( class EntityRecordRepository(
@ -43,21 +52,130 @@ class EntityRecordRepository(
} }
fun findRecordLinkedToUser(userId: Long, entityId: Long, recordId: Long): EntityRecord? { fun findRecordLinkedToUser(userId: Long, entityId: Long, recordId: Long): EntityRecord? {
val entity = entities.findAll().firstOrNull { it.id == entityId } ?: return null val definitions = entities.findAll()
val userFields = entity.fields.filter { it.type == FieldType.USER } val entity = definitions.firstOrNull { it.id == entityId } ?: return null
if (userFields.isEmpty()) return null val values = findValues(entity, recordId) ?: return null
if (!isAccessibleToUser(entity, values, userId, definitions, mutableSetOf())) return null
return EntityRecord(entity, values, findChildren(entity.id, recordId))
}
val columns = listOf("id") + entity.fields.map(EntityField::identifier) fun updateRecordLinkedToUser(
val predicate = userFields.joinToString(" OR ") { "${quote(it.identifier)} = ?" } userId: Long,
val sql = "SELECT ${columns.joinToString { quote(it) }} FROM ${quote(entity.identifier)} " + entityId: Long,
"WHERE \"id\" = ? AND ($predicate)" recordId: Long,
val values = database.queryOne(sql, bind = { submittedValues: Map<String, String>,
setLong(1, recordId) ): EntityRecord? {
userFields.indices.forEach { setLong(it + 2, userId) } val current = findRecordLinkedToUser(userId, entityId, recordId) ?: return null
}) { toRecord(columns) } ?: return null val fields = current.entity.fields.filter { it.type != FieldType.USER && it.identifier in submittedValues }
if (fields.isNotEmpty()) {
val assignments = fields.joinToString { "${quote(it.identifier)} = ?" }
val sql = "UPDATE ${quote(current.entity.identifier)} SET $assignments WHERE \"id\" = ?"
database.executeUpdate(sql) {
fields.forEachIndexed { index, field ->
bindField(index + 1, field, submittedValues.getValue(field.identifier))
}
setLong(fields.size + 1, recordId)
}
}
return findRecordLinkedToUser(userId, entityId, recordId)
}
fun createChild(
userId: Long,
parentEntityId: Long,
parentRecordId: Long,
childEntityId: Long,
relationshipFieldId: Long,
submittedValues: Map<String, String>,
): EntityRecord? {
if (findRecordLinkedToUser(userId, parentEntityId, parentRecordId) == null) return null
val child = entities.findAll().firstOrNull { it.id == childEntityId } ?: return null
val parentField = child.fields.firstOrNull {
it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == parentEntityId
} ?: return null
val fields = child.fields
val sql = "INSERT INTO ${quote(child.identifier)} (${fields.joinToString { quote(it.identifier) }}) " +
"VALUES (${fields.joinToString { "?" }}) RETURNING \"id\""
val childId = checkNotNull(database.queryOne(sql, bind = {
fields.forEachIndexed { index, field ->
when {
field.id == parentField.id -> setLong(index + 1, parentRecordId)
field.type == FieldType.USER -> setLong(index + 1, userId)
else -> bindField(index + 1, field, submittedValues[field.identifier].orEmpty())
}
}
}) { getLong("id") })
return findChildRecord(child, childId)
}
private fun findChildren(parentEntityId: Long, parentRecordId: Long): List<ChildEntityRecords> =
entities.findAll().flatMap { child ->
child.fields
.filter { it.type == FieldType.RELATIONSHIP && it.targetEntityId == parentEntityId }
.map { relationship ->
val columns = listOf("id") + child.fields.map(EntityField::identifier)
val sql = "SELECT ${columns.joinToString { quote(it) }} FROM ${quote(child.identifier)} " +
"WHERE ${quote(relationship.identifier)} = ? ORDER BY \"id\""
val records = database.queryList(sql, bind = { setLong(1, parentRecordId) }) {
toRecord(columns)
}
ChildEntityRecords(child, relationship, records)
}
}
private fun findChildRecord(entity: EntityDefinition, recordId: Long): EntityRecord? {
val values = findValues(entity, recordId) ?: return null
return EntityRecord(entity, values) return EntityRecord(entity, values)
} }
private fun findValues(entity: EntityDefinition, recordId: Long): Map<String, Any?>? {
val columns = listOf("id") + entity.fields.map(EntityField::identifier)
return database.queryOne(
"SELECT ${columns.joinToString { quote(it) }} FROM ${quote(entity.identifier)} WHERE \"id\" = ?",
bind = { setLong(1, recordId) },
) { toRecord(columns) }
}
private fun isAccessibleToUser(
entity: EntityDefinition,
values: Map<String, Any?>,
userId: Long,
definitions: List<EntityDefinition>,
visited: MutableSet<Pair<Long, Long>>,
): Boolean {
val recordId = (values["id"] as? Number)?.toLong() ?: return false
if (!visited.add(entity.id to recordId)) return false
if (entity.fields.any { it.type == FieldType.USER && (values[it.identifier] as? Number)?.toLong() == userId }) {
return true
}
return entity.fields
.filter { it.type == FieldType.RELATIONSHIP && it.targetEntityId != null }
.any { field ->
val parentId = (values[field.identifier] as? Number)?.toLong() ?: return@any false
val parent = definitions.firstOrNull { it.id == field.targetEntityId } ?: return@any false
val parentValues = findValues(parent, parentId) ?: return@any false
isAccessibleToUser(parent, parentValues, userId, definitions, visited)
}
}
private fun PreparedStatement.bindField(index: Int, field: EntityField, rawValue: String) {
val value = rawValue.trim()
if (value.isEmpty()) {
setObject(index, null)
return
}
when (field.type) {
FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE -> setString(index, value)
FieldType.NUMBER -> setBigDecimal(index, value.toBigDecimalOrNull() ?: invalid(field))
FieldType.BOOLEAN -> setBoolean(index, value.toBooleanStrictOrNull() ?: invalid(field))
FieldType.DATE -> setObject(index, runCatching { LocalDate.parse(value) }.getOrElse { invalid(field) })
FieldType.RELATIONSHIP, FieldType.USER -> setLong(index, value.toLongOrNull() ?: invalid(field))
}
}
private fun invalid(field: EntityField): Nothing =
throw IllegalArgumentException("Invalid value for ${field.name}")
private fun ResultSet.toRecords(columns: List<String>): List<Map<String, Any?>> = private fun ResultSet.toRecords(columns: List<String>): List<Map<String, Any?>> =
buildList { while (next()) add(toRecord(columns)) } buildList { while (next()) add(toRecord(columns)) }

View File

@ -4,6 +4,7 @@ import dev.mduchene.bolts.entity.EntityRecordRepository
import dev.mduchene.bolts.user.LoginService import dev.mduchene.bolts.user.LoginService
import dev.mduchene.bolts.user.UserRepository import dev.mduchene.bolts.user.UserRepository
import io.javalin.router.JavalinDefaultRoutingApi import io.javalin.router.JavalinDefaultRoutingApi
import java.sql.SQLException
class UserController( class UserController(
private val users: UserRepository, private val users: UserRepository,
@ -49,5 +50,58 @@ class UserController(
val record = records.findRecordLinkedToUser(userId, entityId, recordId) val record = records.findRecordLinkedToUser(userId, entityId, recordId)
if (record == null) ctx.notFound() else ctx.json(record) if (record == null) ctx.notFound() else ctx.json(record)
} }
routes.patch("/api/users/{userId}/entities/{entityId}/records/{recordId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@patch
val userId = ctx.pathParam("userId").toLongOrNull()
val entityId = ctx.pathParam("entityId").toLongOrNull()
val recordId = ctx.pathParam("recordId").toLongOrNull()
if (userId == null || entityId == null || recordId == null || users.findById(userId) == null) {
ctx.notFound()
return@patch
} }
try {
val record = records.updateRecordLinkedToUser(userId, entityId, recordId, ctx.singleFormParams())
if (record == null) ctx.notFound() else ctx.json(record)
} catch (exception: IllegalArgumentException) {
ctx.badRequest(exception.message ?: "Invalid field value")
} catch (_: SQLException) {
ctx.badRequest("The record violates an entity constraint")
}
}
routes.post(
"/api/users/{userId}/entities/{entityId}/records/{recordId}/children/{childEntityId}/{fieldId}",
) { ctx ->
if (!ctx.requireAdmin(loginService)) return@post
val userId = ctx.pathParam("userId").toLongOrNull()
val entityId = ctx.pathParam("entityId").toLongOrNull()
val recordId = ctx.pathParam("recordId").toLongOrNull()
val childEntityId = ctx.pathParam("childEntityId").toLongOrNull()
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
if (
userId == null || entityId == null || recordId == null || childEntityId == null || fieldId == null ||
users.findById(userId) == null
) {
ctx.notFound()
return@post
}
try {
val child = records.createChild(
userId,
entityId,
recordId,
childEntityId,
fieldId,
ctx.singleFormParams(),
)
if (child == null) ctx.notFound() else ctx.status(201).json(child)
} catch (exception: IllegalArgumentException) {
ctx.badRequest(exception.message ?: "Invalid field value")
} catch (_: SQLException) {
ctx.badRequest("The child record violates an entity constraint")
}
}
}
private fun io.javalin.http.Context.singleFormParams(): Map<String, String> =
formParamMap().mapValues { (_, values) -> values.firstOrNull().orEmpty() }
} }