diff --git a/frontend/src/pages/entity-record-viewer.vue b/frontend/src/pages/entity-record-viewer.vue index 14ea5f1..82e3e25 100644 --- a/frontend/src/pages/entity-record-viewer.vue +++ b/frontend/src/pages/entity-record-viewer.vue @@ -39,7 +39,8 @@ const addingChildKey = ref('') const userFilePath = computed(() => `/users/${route.params.userId}`) const recordUrl = computed( - () => `/api/users/${route.params.userId}/entities/${route.params.entityId}/records/${route.params.recordId}`, + () => `/api/users/${route.params.userId}/entities/${encodeURIComponent(String(route.params.entityName))}` + + `/records/${route.params.recordId}`, ) function displayValue (value: unknown) { @@ -146,7 +147,7 @@ errorMessage.value = '' try { const response = await fetch( - `${recordUrl.value}/children/${child.entity.id}/${child.relationshipField.id}`, + `${recordUrl.value}/children/${encodeURIComponent(child.entity.name)}/${child.relationshipField.id}`, { method: 'POST', headers: { @@ -305,7 +306,7 @@ View diff --git a/frontend/src/pages/user-file.vue b/frontend/src/pages/user-file.vue index d47519e..7b95c55 100644 --- a/frontend/src/pages/user-file.vue +++ b/frontend/src/pages/user-file.vue @@ -103,7 +103,7 @@ View diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 06a6949..02e10fc 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -31,7 +31,7 @@ const router = createRouter({ meta: { requiresAdmin: true }, }, { - path: '/users/:userId/entities/:entityId/records/:recordId', + path: '/users/:userId/entities/:entityName/records/:recordId', component: EntityRecordViewer, meta: { requiresAdmin: true }, }, diff --git a/frontend/tests/user-file.spec.ts b/frontend/tests/user-file.spec.ts index 9b70c20..23969c9 100644 --- a/frontend/tests/user-file.spec.ts +++ b/frontend/tests/user-file.spec.ts @@ -72,14 +72,14 @@ test('admin opens a user file and views a linked entity record', async ({ page } }], }, })) - await page.route('**/api/users/7/entities/3/records/42', async route => { + await page.route('**/api/users/7/entities/Project/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 => { + await page.route('**/api/users/7/entities/Project/records/42/children/Task/21', async route => { const values = new URLSearchParams(route.request().postData() ?? '') const created = { id: 55, description: values.get('description'), project: 42 } tasks.push(created) @@ -101,6 +101,7 @@ test('admin opens a user file and views a linked entity record', async ({ page } await expect(page.getByRole('row').filter({ hasText: 'Website refresh' })).toContainText('7') await page.getByRole('link', { name: 'View Project record 42' }).click() + await expect(page).toHaveURL('/users/7/entities/Project/records/42') await expect(page.getByRole('heading', { name: 'Project #42' })).toBeVisible() await expect(page.getByText('Website refresh')).toBeVisible() await expect(page.getByText('Owner')).toBeVisible() diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt index f0d7164..2d8acf3 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityDefinitionRepository.kt @@ -4,6 +4,21 @@ import dev.mduchene.bolts.persistence.Database import java.sql.ResultSet class EntityDefinitionRepository(private val database: Database) { + fun nameExists(name: String, excludingId: Long? = null): Boolean = + database.queryOne( + """ + SELECT EXISTS ( + SELECT 1 FROM entity_definitions + WHERE lower(name) = lower(?) AND (? IS NULL OR id <> ?) + ) AS found + """.trimIndent(), + bind = { + setString(1, name) + setObject(2, excludingId) + setObject(3, excludingId) + }, + ) { getBoolean("found") } == true + fun findAll(): List { val fields = database.queryList( """ diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt index 67abafb..c5e6b44 100644 --- a/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt @@ -51,9 +51,9 @@ class EntityRecordRepository( } } - fun findRecordLinkedToUser(userId: Long, entityId: Long, recordId: Long): EntityRecord? { + fun findRecordLinkedToUser(userId: Long, entityName: String, recordId: Long): EntityRecord? { val definitions = entities.findAll() - val entity = definitions.firstOrNull { it.id == entityId } ?: return null + val entity = definitions.firstOrNull { it.name == entityName } ?: 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)) @@ -61,11 +61,11 @@ class EntityRecordRepository( fun updateRecordLinkedToUser( userId: Long, - entityId: Long, + entityName: String, recordId: Long, submittedValues: Map, ): EntityRecord? { - val current = findRecordLinkedToUser(userId, entityId, recordId) ?: return null + val current = findRecordLinkedToUser(userId, entityName, 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)} = ?" } @@ -77,21 +77,23 @@ class EntityRecordRepository( setLong(fields.size + 1, recordId) } } - return findRecordLinkedToUser(userId, entityId, recordId) + return findRecordLinkedToUser(userId, entityName, recordId) } fun createChild( userId: Long, - parentEntityId: Long, + parentEntityName: String, parentRecordId: Long, - childEntityId: Long, + childEntityName: String, relationshipFieldId: Long, submittedValues: Map, ): EntityRecord? { - if (findRecordLinkedToUser(userId, parentEntityId, parentRecordId) == null) return null - val child = entities.findAll().firstOrNull { it.id == childEntityId } ?: return null + val definitions = entities.findAll() + val parent = definitions.firstOrNull { it.name == parentEntityName } ?: return null + if (findRecordLinkedToUser(userId, parentEntityName, parentRecordId) == null) return null + val child = definitions.firstOrNull { it.name == childEntityName } ?: return null val parentField = child.fields.firstOrNull { - it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == parentEntityId + it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == parent.id } ?: return null val fields = child.fields val sql = "INSERT INTO ${quote(child.identifier)} (${fields.joinToString { quote(it.identifier) }}) " + diff --git a/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt b/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt index fe54e21..54dc366 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt @@ -33,6 +33,10 @@ class EntityDefinitionController( if (!ctx.requireAdmin(loginService)) return@post val name = ctx.requiredFormParam("name") ?: return@post val identifier = ctx.requiredFormParam("identifier") ?: return@post + if (entities.nameExists(name)) { + ctx.badRequest("Entity names must be unique") + return@post + } ctx.status(HttpStatus.CREATED).json(entities.create(name, identifier)) } routes.patch("/api/entity-definitions/{entityId}") { ctx -> @@ -41,6 +45,10 @@ class EntityDefinitionController( val name = ctx.requiredFormParam("name") val identifier = ctx.requiredFormParam("identifier") if (id == null || name == null || identifier == null) return@patch + if (entities.nameExists(name, id)) { + ctx.badRequest("Entity names must be unique") + return@patch + } val entity = entities.update(id, name, identifier) if (entity == null) ctx.notFound() else ctx.json(entity) } diff --git a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt index c11f42a..6f8ce93 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt @@ -38,29 +38,29 @@ class UserController( ), ) } - routes.get("/api/users/{userId}/entities/{entityId}/records/{recordId}") { ctx -> + routes.get("/api/users/{userId}/entities/{entityName}/records/{recordId}") { ctx -> if (!ctx.requireAdmin(loginService)) return@get val userId = ctx.pathParam("userId").toLongOrNull() - val entityId = ctx.pathParam("entityId").toLongOrNull() + val entityName = ctx.pathParam("entityName") val recordId = ctx.pathParam("recordId").toLongOrNull() - if (userId == null || entityId == null || recordId == null || users.findById(userId) == null) { + if (userId == null || recordId == null || users.findById(userId) == null) { ctx.notFound() return@get } - val record = records.findRecordLinkedToUser(userId, entityId, recordId) + val record = records.findRecordLinkedToUser(userId, entityName, recordId) if (record == null) ctx.notFound() else ctx.json(record) } - routes.patch("/api/users/{userId}/entities/{entityId}/records/{recordId}") { ctx -> + routes.patch("/api/users/{userId}/entities/{entityName}/records/{recordId}") { ctx -> if (!ctx.requireAdmin(loginService)) return@patch val userId = ctx.pathParam("userId").toLongOrNull() - val entityId = ctx.pathParam("entityId").toLongOrNull() + val entityName = ctx.pathParam("entityName") val recordId = ctx.pathParam("recordId").toLongOrNull() - if (userId == null || entityId == null || recordId == null || users.findById(userId) == null) { + if (userId == null || recordId == null || users.findById(userId) == null) { ctx.notFound() return@patch } try { - val record = records.updateRecordLinkedToUser(userId, entityId, recordId, ctx.singleFormParams()) + val record = records.updateRecordLinkedToUser(userId, entityName, recordId, ctx.singleFormParams()) if (record == null) ctx.notFound() else ctx.json(record) } catch (exception: IllegalArgumentException) { ctx.badRequest(exception.message ?: "Invalid field value") @@ -69,16 +69,16 @@ class UserController( } } routes.post( - "/api/users/{userId}/entities/{entityId}/records/{recordId}/children/{childEntityId}/{fieldId}", + "/api/users/{userId}/entities/{entityName}/records/{recordId}/children/{childEntityName}/{fieldId}", ) { ctx -> if (!ctx.requireAdmin(loginService)) return@post val userId = ctx.pathParam("userId").toLongOrNull() - val entityId = ctx.pathParam("entityId").toLongOrNull() + val entityName = ctx.pathParam("entityName") val recordId = ctx.pathParam("recordId").toLongOrNull() - val childEntityId = ctx.pathParam("childEntityId").toLongOrNull() + val childEntityName = ctx.pathParam("childEntityName") val fieldId = ctx.pathParam("fieldId").toLongOrNull() if ( - userId == null || entityId == null || recordId == null || childEntityId == null || fieldId == null || + userId == null || recordId == null || fieldId == null || users.findById(userId) == null ) { ctx.notFound() @@ -87,9 +87,9 @@ class UserController( try { val child = records.createChild( userId, - entityId, + entityName, recordId, - childEntityId, + childEntityName, fieldId, ctx.singleFormParams(), ) diff --git a/src/main/resources/db/migrations/20260730200141_enforce_unique_entity_names.sql b/src/main/resources/db/migrations/20260730200141_enforce_unique_entity_names.sql new file mode 100644 index 0000000..395899f --- /dev/null +++ b/src/main/resources/db/migrations/20260730200141_enforce_unique_entity_names.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX IF NOT EXISTS entity_definitions_name_case_insensitive_unique +ON entity_definitions (LOWER(name));