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 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 @@
<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}`"
:to="`/users/${route.params.userId}/entities/${encodeURIComponent(child.entity.name)}/records/${childRecord.id}`"
>
View
</router-link>

View File

@ -103,7 +103,7 @@
<router-link
:aria-label="`View ${linked.entity.name} record ${record.id}`"
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
</router-link>

View File

@ -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 },
},

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') {
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()

View File

@ -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<EntityDefinition> {
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 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<String, String>,
): 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<String, String>,
): 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) }}) " +

View File

@ -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)
}

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
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(),
)

View File

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