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));