From c6b1127e30f1e08a4b9cfcb8c1fe92d42bf2bf46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20Duch=C3=AAne-Savard?= Date: Thu, 30 Jul 2026 15:59:35 -0400 Subject: [PATCH] allow editing individual records --- frontend/src/pages/entity-record-viewer.vue | 310 +++++++++++++++++- frontend/tests/user-file.spec.ts | 84 ++++- .../bolts/entity/EntityRecordRepository.kt | 140 +++++++- .../dev/mduchene/bolts/web/UserController.kt | 54 +++ 4 files changed, 546 insertions(+), 42 deletions(-) diff --git a/frontend/src/pages/entity-record-viewer.vue b/frontend/src/pages/entity-record-viewer.vue index b10697b..14ea5f1 100644 --- a/frontend/src/pages/entity-record-viewer.vue +++ b/frontend/src/pages/entity-record-viewer.vue @@ -1,5 +1,5 @@ diff --git a/frontend/tests/user-file.spec.ts b/frontend/tests/user-file.spec.ts index bd17d9f..9b70c20 100644 --- a/frontend/tests/user-file.spec.ts +++ b/frontend/tests/user-file.spec.ts @@ -11,6 +11,46 @@ async function useAdminSession (page: Page) { } test('admin opens a user file and views a linked entity record', async ({ page }) => { + let projectTitle = 'Website refresh' + const tasks: Record[] = [] + 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 page.route('**/api/users', route => route.fulfill({ 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({ - json: { - entity: { - id: 3, - name: 'Project', - identifier: 'project', - fields: [ - { id: 11, name: 'Title', identifier: 'title', type: 'TEXT' }, - { id: 12, name: 'Owner', identifier: 'owner', type: 'USER' }, - ], + 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: { + entity: projectResponse().children.at(0)!.entity, + values: created, + children: [], }, - values: { id: 42, title: 'Website refresh', owner: 7 }, - }, - })) + }) + }) await page.goto('/users') 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.getByText('Website refresh')).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') }) diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt index 059a30c..67abafb 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt @@ -1,7 +1,9 @@ package dev.mduchene.bolts.entity import dev.mduchene.bolts.persistence.Database +import java.sql.PreparedStatement import java.sql.ResultSet +import java.time.LocalDate import java.time.temporal.TemporalAccessor data class LinkedEntityRecords( @@ -12,6 +14,13 @@ data class LinkedEntityRecords( data class EntityRecord( val entity: EntityDefinition, val values: Map, + val children: List = emptyList(), +) + +data class ChildEntityRecords( + val entity: EntityDefinition, + val relationshipField: EntityField, + val records: List>, ) class EntityRecordRepository( @@ -43,21 +52,130 @@ class EntityRecordRepository( } fun findRecordLinkedToUser(userId: Long, entityId: Long, recordId: Long): EntityRecord? { - val entity = entities.findAll().firstOrNull { it.id == entityId } ?: return null - val userFields = entity.fields.filter { it.type == FieldType.USER } - if (userFields.isEmpty()) return null + val definitions = entities.findAll() + val entity = definitions.firstOrNull { it.id == entityId } ?: 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) - val predicate = userFields.joinToString(" OR ") { "${quote(it.identifier)} = ?" } - val sql = "SELECT ${columns.joinToString { quote(it) }} FROM ${quote(entity.identifier)} " + - "WHERE \"id\" = ? AND ($predicate)" - val values = database.queryOne(sql, bind = { - setLong(1, recordId) - userFields.indices.forEach { setLong(it + 2, userId) } - }) { toRecord(columns) } ?: return null + fun updateRecordLinkedToUser( + userId: Long, + entityId: Long, + recordId: Long, + submittedValues: Map, + ): EntityRecord? { + val current = findRecordLinkedToUser(userId, entityId, recordId) ?: 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, + ): 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 = + 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) } + private fun findValues(entity: EntityDefinition, recordId: Long): Map? { + 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, + userId: Long, + definitions: List, + visited: MutableSet>, + ): 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): List> = buildList { while (next()) add(toRecord(columns)) } diff --git a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt index 5b8513e..c11f42a 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt @@ -4,6 +4,7 @@ import dev.mduchene.bolts.entity.EntityRecordRepository import dev.mduchene.bolts.user.LoginService import dev.mduchene.bolts.user.UserRepository import io.javalin.router.JavalinDefaultRoutingApi +import java.sql.SQLException class UserController( private val users: UserRepository, @@ -49,5 +50,58 @@ class UserController( val record = records.findRecordLinkedToUser(userId, entityId, recordId) 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 = + formParamMap().mapValues { (_, values) -> values.firstOrNull().orEmpty() } }