add entity viewer
This commit is contained in:
parent
285fc4aeec
commit
00d3f2b13b
86
frontend/src/pages/entity-record-viewer.vue
Normal file
86
frontend/src/pages/entity-record-viewer.vue
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
|
||||||
|
interface Field {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
identifier: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
interface EntityRecord {
|
||||||
|
entity: { id: number, name: string, fields: Field[] }
|
||||||
|
values: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const record = ref<EntityRecord>()
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const userFilePath = computed(() => `/users/${route.params.userId}`)
|
||||||
|
|
||||||
|
function displayValue (value: unknown) {
|
||||||
|
if ([null, undefined, ''].includes(value as null | undefined | string)) return '—'
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/users/${route.params.userId}/entities/${route.params.entityId}/records/${route.params.recordId}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${auth.token}` } },
|
||||||
|
)
|
||||||
|
if (response.status === 404) {
|
||||||
|
await router.replace(userFilePath.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error('Record request failed')
|
||||||
|
record.value = await response.json() as EntityRecord
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'Unable to load this record.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="viewer-page">
|
||||||
|
<router-link class="back-link" :to="userFilePath">← Back to user file</router-link>
|
||||||
|
<p v-if="loading">Loading record…</p>
|
||||||
|
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="record">
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">Record viewer</p>
|
||||||
|
<h1>{{ record.entity.name }} #{{ record.values.id }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<dl class="record-card">
|
||||||
|
<div><dt>ID</dt><dd>{{ displayValue(record.values.id) }}</dd></div>
|
||||||
|
|
||||||
|
<div v-for="field in record.entity.fields" :key="field.id">
|
||||||
|
<dt>{{ field.name }}</dt>
|
||||||
|
<dd>{{ displayValue(record.values[field.identifier]) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.viewer-page { width: 100%; max-width: 60rem; }
|
||||||
|
.back-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
header { margin: 1.5rem 0 2rem; }
|
||||||
|
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||||
|
h1 { margin: 0; font-size: 2rem; letter-spacing: -0.04em; }
|
||||||
|
.record-card { overflow: hidden; margin: 0; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
|
||||||
|
.record-card > div { display: grid; grid-template-columns: minmax(10rem, 1fr) 2fr; gap: 1rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--v0-divider); }
|
||||||
|
.record-card > div:last-child { border-bottom: 0; }
|
||||||
|
dt { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
|
||||||
|
dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
</style>
|
||||||
142
frontend/src/pages/user-file.vue
Normal file
142
frontend/src/pages/user-file.vue
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
|
||||||
|
interface Field {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
identifier: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
interface LinkedEntity {
|
||||||
|
entity: { id: number, name: string, identifier: string, fields: Field[] }
|
||||||
|
records: Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
interface UserFile {
|
||||||
|
user: { id: number, username: string, role: string }
|
||||||
|
linkedEntities: LinkedEntity[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const file = ref<UserFile>()
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
function displayValue (value: unknown) {
|
||||||
|
if ([null, undefined, ''].includes(value as null | undefined | string)) return '—'
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users/${route.params.userId}/file`, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||||||
|
})
|
||||||
|
if (response.status === 404) {
|
||||||
|
await router.replace('/users')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error('User file request failed')
|
||||||
|
file.value = await response.json() as UserFile
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'Unable to load this user file.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="user-file-page">
|
||||||
|
<router-link class="back-link" to="/users">← User Management</router-link>
|
||||||
|
<p v-if="loading">Loading user file…</p>
|
||||||
|
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="file">
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">User file</p>
|
||||||
|
|
||||||
|
<div class="title-row">
|
||||||
|
<h1>{{ file.user.username }}</h1>
|
||||||
|
<span class="role-badge">{{ file.user.role }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>All entity records linked to this user.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="file.linkedEntities.length === 0" class="empty-state">
|
||||||
|
No entities with user references are configured.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<article v-for="linked in file.linkedEntities" :key="linked.entity.id" class="entity-card">
|
||||||
|
<div class="entity-heading">
|
||||||
|
<div>
|
||||||
|
<h2>{{ linked.entity.name }}</h2>
|
||||||
|
<p>{{ linked.records.length }} linked {{ linked.records.length === 1 ? 'record' : 'records' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="linked.records.length === 0" class="no-records">No records linked to this user.</p>
|
||||||
|
|
||||||
|
<div v-else class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th v-for="field in linked.entity.fields" :key="field.id">{{ field.name }}</th>
|
||||||
|
<th><span class="sr-only">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="record in linked.records" :key="String(record.id)">
|
||||||
|
<td>{{ displayValue(record.id) }}</td>
|
||||||
|
|
||||||
|
<td v-for="field in linked.entity.fields" :key="field.id">
|
||||||
|
{{ displayValue(record[field.identifier]) }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<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}`"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-file-page { width: 100%; max-width: 78rem; }
|
||||||
|
.back-link, .view-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
|
||||||
|
.back-link:hover, .view-link:hover { text-decoration: underline; }
|
||||||
|
header { margin: 1.5rem 0 2rem; }
|
||||||
|
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||||
|
.title-row { display: flex; align-items: center; gap: 0.8rem; }
|
||||||
|
h1, h2 { margin: 0; letter-spacing: -0.03em; }
|
||||||
|
h1 { font-size: 2rem; }
|
||||||
|
h2 { font-size: 1.25rem; }
|
||||||
|
header > p:last-child, .entity-heading p { color: var(--v0-on-surface-variant); }
|
||||||
|
.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; }
|
||||||
|
.entity-card, .empty-state { margin-bottom: 1.25rem; overflow: hidden; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
|
||||||
|
.entity-heading { padding: 1.25rem; border-bottom: 1px solid var(--v0-divider); }
|
||||||
|
.entity-heading p { margin: 0.35rem 0 0; font-size: 0.9rem; }
|
||||||
|
.empty-state, .no-records { padding: 1.5rem; }
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; text-align: left; white-space: nowrap; }
|
||||||
|
th, td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--v0-divider); }
|
||||||
|
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
|
||||||
|
tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
|
||||||
|
</style>
|
||||||
@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="user in users" :key="user.id">
|
<tr v-for="user in users" :key="user.id">
|
||||||
<td>{{ user.username }}</td>
|
<td><router-link class="user-link" :to="`/users/${user.id}`">{{ user.username }}</router-link></td>
|
||||||
<td><span class="role-badge">{{ user.role }}</span></td>
|
<td><span class="role-badge">{{ user.role }}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -75,4 +75,6 @@
|
|||||||
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
|
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
|
||||||
tbody tr:last-child td { border-bottom: 0; }
|
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; }
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -8,7 +8,9 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import EntityConfiguration from '@/pages/entity-configuration.vue'
|
import EntityConfiguration from '@/pages/entity-configuration.vue'
|
||||||
|
import EntityRecordViewer from '@/pages/entity-record-viewer.vue'
|
||||||
import Index from '@/pages/index.vue'
|
import Index from '@/pages/index.vue'
|
||||||
|
import UserFile from '@/pages/user-file.vue'
|
||||||
import Users from '@/pages/users.vue'
|
import Users from '@/pages/users.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@ -23,6 +25,16 @@ const router = createRouter({
|
|||||||
component: Users,
|
component: Users,
|
||||||
meta: { requiresAdmin: true },
|
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',
|
path: '/admin/entities',
|
||||||
component: EntityConfiguration,
|
component: EntityConfiguration,
|
||||||
|
|||||||
61
frontend/tests/user-file.spec.ts
Normal file
61
frontend/tests/user-file.spec.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import dev.mduchene.bolts.entity.EntityDefinitionRepository
|
import dev.mduchene.bolts.entity.EntityDefinitionRepository
|
||||||
import dev.mduchene.bolts.entity.EntityMigrationService
|
import dev.mduchene.bolts.entity.EntityMigrationService
|
||||||
|
import dev.mduchene.bolts.entity.EntityRecordRepository
|
||||||
import dev.mduchene.bolts.persistence.Database
|
import dev.mduchene.bolts.persistence.Database
|
||||||
import dev.mduchene.bolts.persistence.DatabaseConfig
|
import dev.mduchene.bolts.persistence.DatabaseConfig
|
||||||
import dev.mduchene.bolts.user.LoginService
|
import dev.mduchene.bolts.user.LoginService
|
||||||
@ -24,7 +25,7 @@ fun main() {
|
|||||||
val controllers = listOf(
|
val controllers = listOf(
|
||||||
HomeController(),
|
HomeController(),
|
||||||
AuthController(loginService),
|
AuthController(loginService),
|
||||||
UserController(users, loginService),
|
UserController(users, loginService, EntityRecordRepository(database, entities)),
|
||||||
EntityDefinitionController(entities, loginService, EntityMigrationService(database, entities)),
|
EntityDefinitionController(entities, loginService, EntityMigrationService(database, entities)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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<Map<String, Any?>>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EntityRecord(
|
||||||
|
val entity: EntityDefinition,
|
||||||
|
val values: Map<String, Any?>,
|
||||||
|
)
|
||||||
|
|
||||||
|
class EntityRecordRepository(
|
||||||
|
private val database: Database,
|
||||||
|
private val entities: EntityDefinitionRepository,
|
||||||
|
) {
|
||||||
|
fun findLinkedToUser(userId: Long): List<LinkedEntityRecords> =
|
||||||
|
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<String>): List<Map<String, Any?>> =
|
||||||
|
buildList { while (next()) add(toRecord(columns)) }
|
||||||
|
|
||||||
|
private fun ResultSet.toRecord(columns: List<String>): Map<String, Any?> =
|
||||||
|
linkedMapOf<String, Any?>().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("\"", "\"\"")}\""
|
||||||
|
}
|
||||||
@ -44,6 +44,13 @@ class UserRepository(private val database: Database) {
|
|||||||
return database.queryList(sql, map = ResultSet::toUser)
|
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) {
|
fun updateRole(id: Long, role: String) {
|
||||||
val sql = "UPDATE users SET role = ? WHERE id = ?"
|
val sql = "UPDATE users SET role = ? WHERE id = ?"
|
||||||
database.executeUpdate(sql) {
|
database.executeUpdate(sql) {
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package dev.mduchene.bolts.web
|
package dev.mduchene.bolts.web
|
||||||
|
|
||||||
|
import dev.mduchene.bolts.entity.LinkedEntityRecords
|
||||||
|
|
||||||
data class ErrorResponse(
|
data class ErrorResponse(
|
||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
@ -15,3 +17,8 @@ data class UserResponse(
|
|||||||
val username: String,
|
val username: String,
|
||||||
val role: String,
|
val role: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class UserFileResponse(
|
||||||
|
val user: UserResponse,
|
||||||
|
val linkedEntities: List<LinkedEntityRecords>,
|
||||||
|
)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package dev.mduchene.bolts.web
|
package dev.mduchene.bolts.web
|
||||||
|
|
||||||
|
import dev.mduchene.bolts.entity.EntityRecordRepository
|
||||||
import dev.mduchene.bolts.user.LoginService
|
import dev.mduchene.bolts.user.LoginService
|
||||||
import dev.mduchene.bolts.user.UserRepository
|
import dev.mduchene.bolts.user.UserRepository
|
||||||
import io.javalin.router.JavalinDefaultRoutingApi
|
import io.javalin.router.JavalinDefaultRoutingApi
|
||||||
@ -7,6 +8,7 @@ import io.javalin.router.JavalinDefaultRoutingApi
|
|||||||
class UserController(
|
class UserController(
|
||||||
private val users: UserRepository,
|
private val users: UserRepository,
|
||||||
private val loginService: LoginService,
|
private val loginService: LoginService,
|
||||||
|
private val records: EntityRecordRepository,
|
||||||
) : Controller {
|
) : Controller {
|
||||||
override fun register(routes: JavalinDefaultRoutingApi) {
|
override fun register(routes: JavalinDefaultRoutingApi) {
|
||||||
routes.get("/api/users") { ctx ->
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user