use entity name in routes + unique entity names

This commit is contained in:
Maxime Duchêne-Savard 2026-07-31 09:57:21 -04:00
parent c6b1127e30
commit c7566bf4a0
9 changed files with 60 additions and 31 deletions

View File

@ -39,7 +39,8 @@
const addingChildKey = ref('') const addingChildKey = ref('')
const userFilePath = computed(() => `/users/${route.params.userId}`) const userFilePath = computed(() => `/users/${route.params.userId}`)
const recordUrl = computed( 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) { function displayValue (value: unknown) {
@ -146,7 +147,7 @@
errorMessage.value = '' errorMessage.value = ''
try { try {
const response = await fetch( 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', method: 'POST',
headers: { headers: {
@ -305,7 +306,7 @@
<router-link <router-link
:aria-label="`View ${child.entity.name} record ${childRecord.id}`" :aria-label="`View ${child.entity.name} record ${childRecord.id}`"
class="back-link" class="back-link"
:to="`/users/${route.params.userId}/entities/${child.entity.id}/records/${childRecord.id}`" :to="`/users/${route.params.userId}/entities/${encodeURIComponent(child.entity.name)}/records/${childRecord.id}`"
> >
View View
</router-link> </router-link>

View File

@ -103,7 +103,7 @@
<router-link <router-link
:aria-label="`View ${linked.entity.name} record ${record.id}`" :aria-label="`View ${linked.entity.name} record ${record.id}`"
class="view-link" class="view-link"
:to="`/users/${file.user.id}/entities/${linked.entity.id}/records/${record.id}`" :to="`/users/${file.user.id}/entities/${encodeURIComponent(linked.entity.name)}/records/${record.id}`"
> >
View View
</router-link> </router-link>

View File

@ -31,7 +31,7 @@ const router = createRouter({
meta: { requiresAdmin: true }, meta: { requiresAdmin: true },
}, },
{ {
path: '/users/:userId/entities/:entityId/records/:recordId', path: '/users/:userId/entities/:entityName/records/:recordId',
component: EntityRecordViewer, component: EntityRecordViewer,
meta: { requiresAdmin: true }, meta: { requiresAdmin: true },
}, },

View File

@ -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') { if (route.request().method() === 'PATCH') {
const values = new URLSearchParams(route.request().postData() ?? '') const values = new URLSearchParams(route.request().postData() ?? '')
projectTitle = values.get('title') ?? projectTitle projectTitle = values.get('title') ?? projectTitle
} }
await route.fulfill({ json: projectResponse() }) 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 values = new URLSearchParams(route.request().postData() ?? '')
const created = { id: 55, description: values.get('description'), project: 42 } const created = { id: 55, description: values.get('description'), project: 42 }
tasks.push(created) 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 expect(page.getByRole('row').filter({ hasText: 'Website refresh' })).toContainText('7')
await page.getByRole('link', { name: 'View Project record 42' }).click() 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.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()

View File

@ -4,6 +4,21 @@ import dev.mduchene.bolts.persistence.Database
import java.sql.ResultSet import java.sql.ResultSet
class EntityDefinitionRepository(private val database: Database) { 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<EntityDefinition> { fun findAll(): List<EntityDefinition> {
val fields = database.queryList( val fields = database.queryList(
""" """

View File

@ -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 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 val values = findValues(entity, recordId) ?: return null
if (!isAccessibleToUser(entity, values, userId, definitions, mutableSetOf())) return null if (!isAccessibleToUser(entity, values, userId, definitions, mutableSetOf())) return null
return EntityRecord(entity, values, findChildren(entity.id, recordId)) return EntityRecord(entity, values, findChildren(entity.id, recordId))
@ -61,11 +61,11 @@ class EntityRecordRepository(
fun updateRecordLinkedToUser( fun updateRecordLinkedToUser(
userId: Long, userId: Long,
entityId: Long, entityName: String,
recordId: Long, recordId: Long,
submittedValues: Map<String, String>, submittedValues: Map<String, String>,
): EntityRecord? { ): 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 } val fields = current.entity.fields.filter { it.type != FieldType.USER && it.identifier in submittedValues }
if (fields.isNotEmpty()) { if (fields.isNotEmpty()) {
val assignments = fields.joinToString { "${quote(it.identifier)} = ?" } val assignments = fields.joinToString { "${quote(it.identifier)} = ?" }
@ -77,21 +77,23 @@ class EntityRecordRepository(
setLong(fields.size + 1, recordId) setLong(fields.size + 1, recordId)
} }
} }
return findRecordLinkedToUser(userId, entityId, recordId) return findRecordLinkedToUser(userId, entityName, recordId)
} }
fun createChild( fun createChild(
userId: Long, userId: Long,
parentEntityId: Long, parentEntityName: String,
parentRecordId: Long, parentRecordId: Long,
childEntityId: Long, childEntityName: String,
relationshipFieldId: Long, relationshipFieldId: Long,
submittedValues: Map<String, String>, submittedValues: Map<String, String>,
): EntityRecord? { ): EntityRecord? {
if (findRecordLinkedToUser(userId, parentEntityId, parentRecordId) == null) return null val definitions = entities.findAll()
val child = entities.findAll().firstOrNull { it.id == childEntityId } ?: return null 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 { 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 } ?: return null
val fields = child.fields val fields = child.fields
val sql = "INSERT INTO ${quote(child.identifier)} (${fields.joinToString { quote(it.identifier) }}) " + val sql = "INSERT INTO ${quote(child.identifier)} (${fields.joinToString { quote(it.identifier) }}) " +

View File

@ -33,6 +33,10 @@ class EntityDefinitionController(
if (!ctx.requireAdmin(loginService)) return@post if (!ctx.requireAdmin(loginService)) return@post
val name = ctx.requiredFormParam("name") ?: return@post val name = ctx.requiredFormParam("name") ?: return@post
val identifier = ctx.requiredFormParam("identifier") ?: 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)) ctx.status(HttpStatus.CREATED).json(entities.create(name, identifier))
} }
routes.patch("/api/entity-definitions/{entityId}") { ctx -> routes.patch("/api/entity-definitions/{entityId}") { ctx ->
@ -41,6 +45,10 @@ class EntityDefinitionController(
val name = ctx.requiredFormParam("name") val name = ctx.requiredFormParam("name")
val identifier = ctx.requiredFormParam("identifier") val identifier = ctx.requiredFormParam("identifier")
if (id == null || name == null || identifier == null) return@patch 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) val entity = entities.update(id, name, identifier)
if (entity == null) ctx.notFound() else ctx.json(entity) if (entity == null) ctx.notFound() else ctx.json(entity)
} }

View File

@ -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 if (!ctx.requireAdmin(loginService)) return@get
val userId = ctx.pathParam("userId").toLongOrNull() val userId = ctx.pathParam("userId").toLongOrNull()
val entityId = ctx.pathParam("entityId").toLongOrNull() val entityName = ctx.pathParam("entityName")
val recordId = ctx.pathParam("recordId").toLongOrNull() 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() ctx.notFound()
return@get 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) 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 if (!ctx.requireAdmin(loginService)) return@patch
val userId = ctx.pathParam("userId").toLongOrNull() val userId = ctx.pathParam("userId").toLongOrNull()
val entityId = ctx.pathParam("entityId").toLongOrNull() val entityName = ctx.pathParam("entityName")
val recordId = ctx.pathParam("recordId").toLongOrNull() 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() ctx.notFound()
return@patch return@patch
} }
try { 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) if (record == null) ctx.notFound() else ctx.json(record)
} catch (exception: IllegalArgumentException) { } catch (exception: IllegalArgumentException) {
ctx.badRequest(exception.message ?: "Invalid field value") ctx.badRequest(exception.message ?: "Invalid field value")
@ -69,16 +69,16 @@ class UserController(
} }
} }
routes.post( routes.post(
"/api/users/{userId}/entities/{entityId}/records/{recordId}/children/{childEntityId}/{fieldId}", "/api/users/{userId}/entities/{entityName}/records/{recordId}/children/{childEntityName}/{fieldId}",
) { ctx -> ) { ctx ->
if (!ctx.requireAdmin(loginService)) return@post if (!ctx.requireAdmin(loginService)) return@post
val userId = ctx.pathParam("userId").toLongOrNull() val userId = ctx.pathParam("userId").toLongOrNull()
val entityId = ctx.pathParam("entityId").toLongOrNull() val entityName = ctx.pathParam("entityName")
val recordId = ctx.pathParam("recordId").toLongOrNull() val recordId = ctx.pathParam("recordId").toLongOrNull()
val childEntityId = ctx.pathParam("childEntityId").toLongOrNull() val childEntityName = ctx.pathParam("childEntityName")
val fieldId = ctx.pathParam("fieldId").toLongOrNull() val fieldId = ctx.pathParam("fieldId").toLongOrNull()
if ( if (
userId == null || entityId == null || recordId == null || childEntityId == null || fieldId == null || userId == null || recordId == null || fieldId == null ||
users.findById(userId) == null users.findById(userId) == null
) { ) {
ctx.notFound() ctx.notFound()
@ -87,9 +87,9 @@ class UserController(
try { try {
val child = records.createChild( val child = records.createChild(
userId, userId,
entityId, entityName,
recordId, recordId,
childEntityId, childEntityName,
fieldId, fieldId,
ctx.singleFormParams(), ctx.singleFormParams(),
) )

View File

@ -0,0 +1,2 @@
CREATE UNIQUE INDEX IF NOT EXISTS entity_definitions_name_case_insensitive_unique
ON entity_definitions (LOWER(name));