From 00d3f2b13bc58b620c93c32ef06ab149b8d65ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20Duch=C3=AAne-Savard?= Date: Thu, 30 Jul 2026 15:37:00 -0400 Subject: [PATCH] add entity viewer --- frontend/src/pages/entity-record-viewer.vue | 86 +++++++++++ frontend/src/pages/user-file.vue | 142 ++++++++++++++++++ frontend/src/pages/users.vue | 4 +- frontend/src/router/index.ts | 12 ++ frontend/tests/user-file.spec.ts | 61 ++++++++ src/main/kotlin/Main.kt | 3 +- .../bolts/entity/EntityRecordRepository.kt | 78 ++++++++++ .../dev/mduchene/bolts/user/UserRepository.kt | 7 + .../dev/mduchene/bolts/web/Responses.kt | 7 + .../dev/mduchene/bolts/web/UserController.kt | 29 ++++ 10 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/entity-record-viewer.vue create mode 100644 frontend/src/pages/user-file.vue create mode 100644 frontend/tests/user-file.spec.ts create mode 100644 src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt diff --git a/frontend/src/pages/entity-record-viewer.vue b/frontend/src/pages/entity-record-viewer.vue new file mode 100644 index 0000000..b10697b --- /dev/null +++ b/frontend/src/pages/entity-record-viewer.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/src/pages/user-file.vue b/frontend/src/pages/user-file.vue new file mode 100644 index 0000000..d47519e --- /dev/null +++ b/frontend/src/pages/user-file.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/frontend/src/pages/users.vue b/frontend/src/pages/users.vue index cf9a262..8f3f54e 100644 --- a/frontend/src/pages/users.vue +++ b/frontend/src/pages/users.vue @@ -53,7 +53,7 @@ - {{ user.username }} + {{ user.username }} {{ user.role }} @@ -75,4 +75,6 @@ th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; } tbody tr:last-child td { border-bottom: 0; } .role-badge { padding: 0.25rem 0.6rem; border-radius: 999px; color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 14%, transparent); font-size: 0.8rem; font-weight: 700; } + .user-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; } + .user-link:hover { text-decoration: underline; } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 17de85d..06a6949 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -8,7 +8,9 @@ import { createRouter, createWebHistory } from 'vue-router' import { auth } from '@/auth' import EntityConfiguration from '@/pages/entity-configuration.vue' +import EntityRecordViewer from '@/pages/entity-record-viewer.vue' import Index from '@/pages/index.vue' +import UserFile from '@/pages/user-file.vue' import Users from '@/pages/users.vue' const router = createRouter({ @@ -23,6 +25,16 @@ const router = createRouter({ component: Users, meta: { requiresAdmin: true }, }, + { + path: '/users/:userId', + component: UserFile, + meta: { requiresAdmin: true }, + }, + { + path: '/users/:userId/entities/:entityId/records/:recordId', + component: EntityRecordViewer, + meta: { requiresAdmin: true }, + }, { path: '/admin/entities', component: EntityConfiguration, diff --git a/frontend/tests/user-file.spec.ts b/frontend/tests/user-file.spec.ts new file mode 100644 index 0000000..bd17d9f --- /dev/null +++ b/frontend/tests/user-file.spec.ts @@ -0,0 +1,61 @@ +import { expect, type Page, test } from '@playwright/test' + +async function useAdminSession (page: Page) { + await page.addInitScript(() => { + sessionStorage.setItem('bolts-session', JSON.stringify({ + username: 'admin', + role: 'admin', + token: 'admin-token', + })) + }) +} + +test('admin opens a user file and views a linked entity record', async ({ page }) => { + await useAdminSession(page) + await page.route('**/api/users', route => route.fulfill({ + json: [{ id: 7, username: 'alex', role: 'user' }], + })) + await page.route('**/api/users/7/file', route => route.fulfill({ + json: { + user: { id: 7, username: 'alex', role: 'user' }, + linkedEntities: [{ + entity: { + id: 3, + name: 'Project', + identifier: 'project', + fields: [ + { id: 11, name: 'Title', identifier: 'title', type: 'TEXT' }, + { id: 12, name: 'Owner', identifier: 'owner', type: 'USER' }, + ], + }, + records: [{ id: 42, title: 'Website refresh', owner: 7 }], + }], + }, + })) + 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' }, + ], + }, + values: { id: 42, title: 'Website refresh', owner: 7 }, + }, + })) + + await page.goto('/users') + await page.getByRole('link', { name: 'alex' }).click() + + await expect(page.getByRole('heading', { name: 'alex' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Project' })).toBeVisible() + await expect(page.getByRole('row').filter({ hasText: 'Website refresh' })).toContainText('7') + + await page.getByRole('link', { name: 'View Project record 42' }).click() + 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/Main.kt b/src/main/kotlin/Main.kt index ad6103a..fa88167 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -1,5 +1,6 @@ import dev.mduchene.bolts.entity.EntityDefinitionRepository import dev.mduchene.bolts.entity.EntityMigrationService +import dev.mduchene.bolts.entity.EntityRecordRepository import dev.mduchene.bolts.persistence.Database import dev.mduchene.bolts.persistence.DatabaseConfig import dev.mduchene.bolts.user.LoginService @@ -24,7 +25,7 @@ fun main() { val controllers = listOf( HomeController(), AuthController(loginService), - UserController(users, loginService), + UserController(users, loginService, EntityRecordRepository(database, entities)), EntityDefinitionController(entities, loginService, EntityMigrationService(database, entities)), ) diff --git a/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt b/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt new file mode 100644 index 0000000..059a30c --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/entity/EntityRecordRepository.kt @@ -0,0 +1,78 @@ +package dev.mduchene.bolts.entity + +import dev.mduchene.bolts.persistence.Database +import java.sql.ResultSet +import java.time.temporal.TemporalAccessor + +data class LinkedEntityRecords( + val entity: EntityDefinition, + val records: List>, +) + +data class EntityRecord( + val entity: EntityDefinition, + val values: Map, +) + +class EntityRecordRepository( + private val database: Database, + private val entities: EntityDefinitionRepository, +) { + fun findLinkedToUser(userId: Long): List = + database.getConnection().use { connection -> + val tables = connection.metaData.getTables(null, null, "%", arrayOf("TABLE")).use { result -> + buildSet { while (result.next()) add(result.getString("TABLE_NAME").lowercase()) } + } + + entities.findAll() + .filter { entity -> + entity.identifier.lowercase() in tables && entity.fields.any { it.type == FieldType.USER } + } + .map { entity -> + val userFields = entity.fields.filter { it.type == FieldType.USER } + 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 $predicate ORDER BY \"id\"" + val records = connection.prepareStatement(sql).use { statement -> + userFields.indices.forEach { statement.setLong(it + 1, userId) } + statement.executeQuery().use { result -> result.toRecords(columns) } + } + LinkedEntityRecords(entity, records) + } + } + + 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 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 + return EntityRecord(entity, values) + } + + private fun ResultSet.toRecords(columns: List): List> = + buildList { while (next()) add(toRecord(columns)) } + + private fun ResultSet.toRecord(columns: List): Map = + linkedMapOf().also { values -> + columns.forEach { column -> values[column] = normalizedValue(getObject(column)) } + } + + private fun normalizedValue(value: Any?): Any? = when (value) { + is java.sql.Date -> value.toLocalDate().toString() + is java.sql.Timestamp -> value.toInstant().toString() + is TemporalAccessor -> value.toString() + is java.util.Date -> value.toInstant().toString() + else -> value + } + + private fun quote(identifier: String) = "\"${identifier.replace("\"", "\"\"")}\"" +} diff --git a/src/main/kotlin/dev/mduchene/bolts/user/UserRepository.kt b/src/main/kotlin/dev/mduchene/bolts/user/UserRepository.kt index 627e337..98bf960 100644 --- a/src/main/kotlin/dev/mduchene/bolts/user/UserRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/user/UserRepository.kt @@ -44,6 +44,13 @@ class UserRepository(private val database: Database) { return database.queryList(sql, map = ResultSet::toUser) } + fun findById(id: Long): User? = + database.queryOne( + "SELECT id, username, password_hash, role FROM users WHERE id = ?", + bind = { setLong(1, id) }, + map = ResultSet::toUser, + ) + fun updateRole(id: Long, role: String) { val sql = "UPDATE users SET role = ? WHERE id = ?" database.executeUpdate(sql) { diff --git a/src/main/kotlin/dev/mduchene/bolts/web/Responses.kt b/src/main/kotlin/dev/mduchene/bolts/web/Responses.kt index f3216b8..e5128e4 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/Responses.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/Responses.kt @@ -1,5 +1,7 @@ package dev.mduchene.bolts.web +import dev.mduchene.bolts.entity.LinkedEntityRecords + data class ErrorResponse( val message: String, ) @@ -15,3 +17,8 @@ data class UserResponse( val username: String, val role: String, ) + +data class UserFileResponse( + val user: UserResponse, + val linkedEntities: List, +) diff --git a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt index aedd145..5b8513e 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt @@ -1,5 +1,6 @@ package dev.mduchene.bolts.web +import dev.mduchene.bolts.entity.EntityRecordRepository import dev.mduchene.bolts.user.LoginService import dev.mduchene.bolts.user.UserRepository import io.javalin.router.JavalinDefaultRoutingApi @@ -7,6 +8,7 @@ import io.javalin.router.JavalinDefaultRoutingApi class UserController( private val users: UserRepository, private val loginService: LoginService, + private val records: EntityRecordRepository, ) : Controller { override fun register(routes: JavalinDefaultRoutingApi) { routes.get("/api/users") { ctx -> @@ -20,5 +22,32 @@ class UserController( ) }) } + routes.get("/api/users/{userId}/file") { ctx -> + if (!ctx.requireAdmin(loginService)) return@get + val userId = ctx.pathParam("userId").toLongOrNull() + val user = userId?.let(users::findById) + if (user == null) { + ctx.notFound() + return@get + } + ctx.json( + UserFileResponse( + UserResponse(user.id, user.username, user.role), + records.findLinkedToUser(user.id), + ), + ) + } + routes.get("/api/users/{userId}/entities/{entityId}/records/{recordId}") { ctx -> + if (!ctx.requireAdmin(loginService)) return@get + 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@get + } + val record = records.findRecordLinkedToUser(userId, entityId, recordId) + if (record == null) ctx.notFound() else ctx.json(record) + } } }