Initial Nuxt data explorer setup
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Logs and caches
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.npm-cache/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Env files
|
||||||
|
.env
|
||||||
|
nuxt4-app/.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
24
nuxt4-app/.gitignore
vendored
Normal file
24
nuxt4-app/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
75
nuxt4-app/README.md
Normal file
75
nuxt4-app/README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Nuxt Minimal Starter
|
||||||
|
|
||||||
|
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
|
||||||
|
Start the development server on `http://localhost:3000`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
6
nuxt4-app/app/app.vue
Normal file
6
nuxt4-app/app/app.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
<NuxtRouteAnnouncer />
|
||||||
|
<NuxtPage />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
14
nuxt4-app/nuxt.config.ts
Normal file
14
nuxt4-app/nuxt.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
ssr: false,
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
modules: ['@nuxt/image', '@nuxt/ui', '@nuxt/test-utils'],
|
||||||
|
runtimeConfig: {
|
||||||
|
supabase: {
|
||||||
|
url: process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
|
serviceRoleKey:
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
12396
nuxt4-app/package-lock.json
generated
Normal file
12396
nuxt4-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
nuxt4-app/package.json
Normal file
22
nuxt4-app/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "nuxt-app",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/image": "^1.11.0",
|
||||||
|
"@nuxt/test-utils": "^3.19.2",
|
||||||
|
"@nuxt/ui": "^4.0.0",
|
||||||
|
"@supabase/supabase-js": "^2.48.0",
|
||||||
|
"nuxt": "^4.1.2",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
326
nuxt4-app/pages/index.vue
Normal file
326
nuxt4-app/pages/index.vue
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-10">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-2xl font-semibold">Visor de datos</h1>
|
||||||
|
<p class="text-sm text-slate-300">
|
||||||
|
Selecciona una tabla e introduce filtros para consultar los datos en modo solo lectura.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-4">
|
||||||
|
<UFormGroup label="Tabla" name="table">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="filters.table"
|
||||||
|
:options="tableOptions"
|
||||||
|
value-attribute="value"
|
||||||
|
option-attribute="label"
|
||||||
|
placeholder="Selecciona una tabla"
|
||||||
|
/>
|
||||||
|
</UFormGroup>
|
||||||
|
|
||||||
|
<UFormGroup label="ID" name="id">
|
||||||
|
<UInput v-model="filters.id" placeholder="Filtrar por ID" />
|
||||||
|
</UFormGroup>
|
||||||
|
|
||||||
|
<UFormGroup label="Fecha desde" name="from">
|
||||||
|
<UInput v-model="filters.from" type="date" />
|
||||||
|
</UFormGroup>
|
||||||
|
|
||||||
|
<UFormGroup label="Fecha hasta" name="to">
|
||||||
|
<UInput v-model="filters.to" type="date" />
|
||||||
|
</UFormGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="text-xs text-slate-400">
|
||||||
|
Para filtros avanzados puedes codificar un objeto JSON en base64 e invocarlo mediante la ruta
|
||||||
|
<code class="rounded bg-slate-800 px-2 py-1">/api/data/{{ filters.table }}/[query]</code>.
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UButton color="gray" variant="soft" @click="resetFilters" :disabled="loading">
|
||||||
|
Limpiar filtros
|
||||||
|
</UButton>
|
||||||
|
<UButton @click="refresh" :loading="loading" :disabled="!filters.table">
|
||||||
|
Consultar datos
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="rounded-lg border border-red-500 bg-red-500/10 p-4 text-sm text-red-200">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeMetadata" class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold">Resumen de {{ activeMetadata.table }}</h2>
|
||||||
|
<UBadge color="primary">{{ activeMetadata.rowCount }} registros</UBadge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<dl class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-400">Clave primaria</dt>
|
||||||
|
<dd class="font-medium">{{ activeMetadata.primaryKey }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-400">Última consulta</dt>
|
||||||
|
<dd class="font-medium">{{ formatDate(activeMetadata.lastRefreshed) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-400">Tamaño aprox.</dt>
|
||||||
|
<dd class="font-medium">{{ formatSize(activeMetadata.approxSizeBytes) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-400">Rango de creación</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
{{ formatDate(activeMetadata.createdAtRange?.from) }}
|
||||||
|
—
|
||||||
|
{{ formatDate(activeMetadata.createdAtRange?.to) }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="text-xs text-slate-400">
|
||||||
|
Columnas detectadas: {{ activeMetadata.columns.join(', ') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard v-if="activeMetadata.sampleRow">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-lg font-semibold">Registro de ejemplo</h2>
|
||||||
|
</template>
|
||||||
|
<pre class="overflow-auto rounded bg-slate-900 p-4 text-sm">{{ formatSample(activeMetadata.sampleRow) }}</pre>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h2 class="text-lg font-semibold">Datos</h2>
|
||||||
|
<UBadge v-if="tableData" color="gray">
|
||||||
|
{{ tableData.length }} registros visibles
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||||
|
<ULoadingIndicator size="lg" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="tableData.length === 0" class="py-10 text-center text-sm text-slate-400">
|
||||||
|
No hay datos que coincidan con los filtros actuales.
|
||||||
|
</div>
|
||||||
|
<div v-else class="overflow-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-800 text-sm">
|
||||||
|
<thead class="bg-slate-900/60">
|
||||||
|
<tr>
|
||||||
|
<th v-for="column in visibleColumns" :key="column" class="px-4 py-2 text-left font-semibold">
|
||||||
|
{{ column }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">
|
||||||
|
<tr v-for="(row, index) in tableData" :key="index" class="hover:bg-slate-900/40">
|
||||||
|
<td v-for="column in visibleColumns" :key="column" class="px-4 py-2">
|
||||||
|
{{ formatCell(row[column]) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref<string | null>(null)
|
||||||
|
const tableData = ref<Record<string, unknown>[]>([])
|
||||||
|
const activeMetadata = ref<any | null>(null)
|
||||||
|
const availableMetadata = ref<any[]>([])
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
table: '',
|
||||||
|
id: '',
|
||||||
|
from: '',
|
||||||
|
to: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const tableOptions = computed(() =>
|
||||||
|
availableMetadata.value.map((meta) => ({
|
||||||
|
label: `${meta.table} (${meta.rowCount})`,
|
||||||
|
value: meta.table
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleColumns = computed(() => (tableData.value[0] ? Object.keys(tableData.value[0]) : []))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadAvailableMetadata()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filters.table,
|
||||||
|
async (value) => {
|
||||||
|
if (!value) {
|
||||||
|
tableData.value = []
|
||||||
|
activeMetadata.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadAvailableMetadata() {
|
||||||
|
try {
|
||||||
|
const metadata = await $fetch('/api/metadata')
|
||||||
|
availableMetadata.value = Array.isArray(metadata) ? metadata : []
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = extractErrorMessage(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!filters.table) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [metadata, data] = await Promise.all([
|
||||||
|
$fetch(`/api/metadata/${filters.table}`),
|
||||||
|
$fetch(`/api/data/${filters.table}`, {
|
||||||
|
query: buildQueryParams()
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
activeMetadata.value = metadata
|
||||||
|
const metadataIndex = availableMetadata.value.findIndex((item) => item.table === metadata.table)
|
||||||
|
if (metadataIndex >= 0) {
|
||||||
|
availableMetadata.value[metadataIndex] = metadata
|
||||||
|
}
|
||||||
|
tableData.value = data?.records ?? []
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = extractErrorMessage(error)
|
||||||
|
tableData.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryParams() {
|
||||||
|
const params: Record<string, string> = { limit: '100' }
|
||||||
|
|
||||||
|
if (filters.id) {
|
||||||
|
params.id = filters.id.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.from) {
|
||||||
|
params.created_from = filters.from
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.to) {
|
||||||
|
params.created_to = filters.to
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filters.id = ''
|
||||||
|
filters.from = ''
|
||||||
|
filters.to = ''
|
||||||
|
|
||||||
|
if (filters.table) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(error: unknown) {
|
||||||
|
if (error && typeof error === 'object' && 'statusMessage' in error) {
|
||||||
|
return String((error as any).statusMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Ocurrió un error inesperado al consultar los datos.'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number | null) {
|
||||||
|
if (!bytes) {
|
||||||
|
return 'No disponible'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return `${bytes} B`
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['KB', 'MB', 'GB']
|
||||||
|
let size = bytes / 1024
|
||||||
|
let unitIndex = 0
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCell(value: unknown) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch (error) {
|
||||||
|
return '[objeto]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSample(value: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
} catch (error) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
BIN
nuxt4-app/public/favicon.ico
Normal file
BIN
nuxt4-app/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
nuxt4-app/public/robots.txt
Normal file
2
nuxt4-app/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
22
nuxt4-app/server/api/data/[table]/[...segment].get.ts
Normal file
22
nuxt4-app/server/api/data/[table]/[...segment].get.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { parseQuerySegment } from '../../../services/query-parser'
|
||||||
|
import { fetchTableData, fetchTableRecord } from '../../../services/table-service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const table = event.context.params?.table
|
||||||
|
const segmentParam = event.context.params?.segment
|
||||||
|
|
||||||
|
if (!table || !segmentParam) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Tabla o parámetro no especificados' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = Array.isArray(segmentParam) ? segmentParam : [segmentParam]
|
||||||
|
const target = values[0]
|
||||||
|
|
||||||
|
const parsedQuery = parseQuerySegment(target)
|
||||||
|
|
||||||
|
if (parsedQuery) {
|
||||||
|
return await fetchTableData(table, { parsedQuery })
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fetchTableRecord(table, target)
|
||||||
|
})
|
||||||
26
nuxt4-app/server/api/data/[table]/index.get.ts
Normal file
26
nuxt4-app/server/api/data/[table]/index.get.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { parseQuerySegment } from '../../../services/query-parser'
|
||||||
|
import { fetchTableData } from '../../../services/table-service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const table = event.context.params?.table
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Tabla no especificada' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const limitValue = Number.parseInt((query.limit as string) ?? '', 10)
|
||||||
|
const limit = Number.isFinite(limitValue) ? Math.min(Math.max(limitValue, 1), 500) : undefined
|
||||||
|
|
||||||
|
const parsedQuery = parseQuerySegment(query.query as string | undefined)
|
||||||
|
|
||||||
|
return await fetchTableData(table, {
|
||||||
|
parsedQuery,
|
||||||
|
limit,
|
||||||
|
filters: {
|
||||||
|
id: (query.id as string) || undefined,
|
||||||
|
createdFrom: (query.created_from as string) || undefined,
|
||||||
|
createdTo: (query.created_to as string) || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
9
nuxt4-app/server/api/data/index.get.ts
Normal file
9
nuxt4-app/server/api/data/index.get.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { fetchAllData } from '../../services/table-service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const requestedLimit = Number.parseInt((query.limit as string) ?? '', 10)
|
||||||
|
const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 100) : 25
|
||||||
|
|
||||||
|
return await fetchAllData(limit)
|
||||||
|
})
|
||||||
12
nuxt4-app/server/api/metadata/[table]/[id].get.ts
Normal file
12
nuxt4-app/server/api/metadata/[table]/[id].get.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { fetchTableRecordMetadata } from '../../../services/table-service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const table = event.context.params?.table
|
||||||
|
const id = event.context.params?.id
|
||||||
|
|
||||||
|
if (!table || !id) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Tabla o id no especificados' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fetchTableRecordMetadata(table, id)
|
||||||
|
})
|
||||||
15
nuxt4-app/server/api/metadata/[table]/index.get.ts
Normal file
15
nuxt4-app/server/api/metadata/[table]/index.get.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { parseQuerySegment } from '../../../services/query-parser'
|
||||||
|
import { fetchTableMetadata } from '../../../services/table-service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const table = event.context.params?.table
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Tabla no especificada' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const parsedQuery = parseQuerySegment(query.query as string | undefined)
|
||||||
|
|
||||||
|
return await fetchTableMetadata(table, { parsedQuery })
|
||||||
|
})
|
||||||
5
nuxt4-app/server/api/metadata/index.get.ts
Normal file
5
nuxt4-app/server/api/metadata/index.get.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { fetchAllTablesMetadata } from '../../services/table-service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
return await fetchAllTablesMetadata()
|
||||||
|
})
|
||||||
6
nuxt4-app/server/data-sources/anticipos/config.ts
Normal file
6
nuxt4-app/server/data-sources/anticipos/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const anticiposConfig: TableConfig = {
|
||||||
|
table: 'anticipos',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
6
nuxt4-app/server/data-sources/asistencias/config.ts
Normal file
6
nuxt4-app/server/data-sources/asistencias/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const asistenciasConfig: TableConfig = {
|
||||||
|
table: 'asistencias',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
6
nuxt4-app/server/data-sources/carretas/config.ts
Normal file
6
nuxt4-app/server/data-sources/carretas/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const carretasConfig: TableConfig = {
|
||||||
|
table: 'carretas',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
6
nuxt4-app/server/data-sources/clientes/config.ts
Normal file
6
nuxt4-app/server/data-sources/clientes/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const clientesConfig: TableConfig = {
|
||||||
|
table: 'clientes',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
6
nuxt4-app/server/data-sources/comercios/config.ts
Normal file
6
nuxt4-app/server/data-sources/comercios/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const comerciosConfig: TableConfig = {
|
||||||
|
table: 'comercios',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
6
nuxt4-app/server/data-sources/cupones/config.ts
Normal file
6
nuxt4-app/server/data-sources/cupones/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const cuponesConfig: TableConfig = {
|
||||||
|
table: 'cupones',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
6
nuxt4-app/server/data-sources/depositos/config.ts
Normal file
6
nuxt4-app/server/data-sources/depositos/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const depositosConfig: TableConfig = {
|
||||||
|
table: 'depositos',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
32
nuxt4-app/server/data-sources/index.ts
Normal file
32
nuxt4-app/server/data-sources/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { anticiposConfig } from './anticipos/config'
|
||||||
|
import { asistenciasConfig } from './asistencias/config'
|
||||||
|
import { carretasConfig } from './carretas/config'
|
||||||
|
import { clientesConfig } from './clientes/config'
|
||||||
|
import { comerciosConfig } from './comercios/config'
|
||||||
|
import { cuponesConfig } from './cupones/config'
|
||||||
|
import { depositosConfig } from './depositos/config'
|
||||||
|
import { ingresosConfig } from './ingresos/config'
|
||||||
|
import { pagosAnticipoConfig } from './pagos_anticipo/config'
|
||||||
|
import { rechazosConfig } from './rechazos/config'
|
||||||
|
import { tareasRealizadasConfig } from './tareas_realizadas/config'
|
||||||
|
import type { TableConfig, TableName } from './types'
|
||||||
|
|
||||||
|
export const tableConfigs: Record<TableName, TableConfig> = {
|
||||||
|
anticipos: anticiposConfig,
|
||||||
|
asistencias: asistenciasConfig,
|
||||||
|
carretas: carretasConfig,
|
||||||
|
clientes: clientesConfig,
|
||||||
|
comercios: comerciosConfig,
|
||||||
|
cupones: cuponesConfig,
|
||||||
|
depositos: depositosConfig,
|
||||||
|
ingresos: ingresosConfig,
|
||||||
|
pagos_anticipo: pagosAnticipoConfig,
|
||||||
|
rechazos: rechazosConfig,
|
||||||
|
tareas_realizadas: tareasRealizadasConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tableNames = Object.keys(tableConfigs) as TableName[]
|
||||||
|
|
||||||
|
export function getTableConfig(name: string): TableConfig | undefined {
|
||||||
|
return tableConfigs[name as TableName]
|
||||||
|
}
|
||||||
6
nuxt4-app/server/data-sources/ingresos/config.ts
Normal file
6
nuxt4-app/server/data-sources/ingresos/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const ingresosConfig: TableConfig = {
|
||||||
|
table: 'ingresos',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
6
nuxt4-app/server/data-sources/pagos_anticipo/config.ts
Normal file
6
nuxt4-app/server/data-sources/pagos_anticipo/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const pagosAnticipoConfig: TableConfig = {
|
||||||
|
table: 'pagos_anticipo',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
6
nuxt4-app/server/data-sources/rechazos/config.ts
Normal file
6
nuxt4-app/server/data-sources/rechazos/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const rechazosConfig: TableConfig = {
|
||||||
|
table: 'rechazos',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { TableConfig } from '../types'
|
||||||
|
|
||||||
|
export const tareasRealizadasConfig: TableConfig = {
|
||||||
|
table: 'tareas_realizadas',
|
||||||
|
primaryKey: 'id'
|
||||||
|
}
|
||||||
36
nuxt4-app/server/data-sources/types.ts
Normal file
36
nuxt4-app/server/data-sources/types.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export type TableName =
|
||||||
|
| 'anticipos'
|
||||||
|
| 'asistencias'
|
||||||
|
| 'carretas'
|
||||||
|
| 'clientes'
|
||||||
|
| 'comercios'
|
||||||
|
| 'cupones'
|
||||||
|
| 'depositos'
|
||||||
|
| 'ingresos'
|
||||||
|
| 'pagos_anticipo'
|
||||||
|
| 'rechazos'
|
||||||
|
| 'tareas_realizadas'
|
||||||
|
|
||||||
|
export type TableTransform = {
|
||||||
|
readonly serializeRow?: (row: Record<string, unknown>) => Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableConfig {
|
||||||
|
readonly table: TableName
|
||||||
|
readonly primaryKey?: string
|
||||||
|
readonly defaultSelect?: string
|
||||||
|
readonly transforms?: TableTransform
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryFilter {
|
||||||
|
field: string
|
||||||
|
operator?: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike'
|
||||||
|
value: string | number | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedQuery {
|
||||||
|
filters: QueryFilter[]
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
orderBy?: { field: string; ascending?: boolean }
|
||||||
|
}
|
||||||
53
nuxt4-app/server/services/query-parser.ts
Normal file
53
nuxt4-app/server/services/query-parser.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ParsedQuery, QueryFilter } from '../data-sources/types'
|
||||||
|
|
||||||
|
function isValidFilter(filter: Partial<QueryFilter>): filter is QueryFilter {
|
||||||
|
return typeof filter.field === 'string' && filter.field.length > 0 && filter.value !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Url(value: string) {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const paddingNeeded = (4 - (normalized.length % 4)) % 4
|
||||||
|
const padded = normalized + '='.repeat(paddingNeeded)
|
||||||
|
return Buffer.from(padded, 'base64').toString('utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseQuerySegment(segment?: string | string[]): ParsedQuery | null {
|
||||||
|
if (!segment) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Array.isArray(segment) ? segment[0] : segment
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = decodeBase64Url(value)
|
||||||
|
const parsed = JSON.parse(decoded)
|
||||||
|
|
||||||
|
const result: ParsedQuery = {
|
||||||
|
filters: []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(parsed.filters)) {
|
||||||
|
result.filters = parsed.filters.filter(isValidFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.limit && Number.isInteger(parsed.limit) && parsed.limit > 0) {
|
||||||
|
result.limit = Math.min(parsed.limit, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.offset && Number.isInteger(parsed.offset) && parsed.offset >= 0) {
|
||||||
|
result.offset = parsed.offset
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.orderBy && typeof parsed.orderBy.field === 'string') {
|
||||||
|
result.orderBy = {
|
||||||
|
field: parsed.orderBy.field,
|
||||||
|
ascending: parsed.orderBy.ascending !== false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse query segment', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
34
nuxt4-app/server/services/query-runner.ts
Normal file
34
nuxt4-app/server/services/query-runner.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ParsedQuery } from '../data-sources/types'
|
||||||
|
|
||||||
|
type PostgrestOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike'
|
||||||
|
|
||||||
|
export function applyParsedQuery(builder: any, parsed: ParsedQuery | null) {
|
||||||
|
if (!parsed) {
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filter of parsed.filters) {
|
||||||
|
const operator: PostgrestOperator = (filter.operator ?? 'eq') as PostgrestOperator
|
||||||
|
|
||||||
|
if (typeof builder[operator] === 'function') {
|
||||||
|
const value = operator === 'like' || operator === 'ilike' ? String(filter.value) : filter.value
|
||||||
|
builder = builder[operator](filter.field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.orderBy && typeof builder.order === 'function') {
|
||||||
|
builder = builder.order(parsed.orderBy.field, {
|
||||||
|
ascending: parsed.orderBy.ascending !== false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.limit && parsed.offset !== undefined && typeof builder.range === 'function') {
|
||||||
|
const from = parsed.offset
|
||||||
|
const to = parsed.offset + parsed.limit - 1
|
||||||
|
builder = builder.range(from, to)
|
||||||
|
} else if (parsed.limit && typeof builder.limit === 'function') {
|
||||||
|
builder = builder.limit(parsed.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder
|
||||||
|
}
|
||||||
256
nuxt4-app/server/services/table-service.ts
Normal file
256
nuxt4-app/server/services/table-service.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { getTableConfig, tableNames } from '../data-sources'
|
||||||
|
import type { ParsedQuery, TableConfig } from '../data-sources/types'
|
||||||
|
import { getSupabaseClient } from '../utils/supabase'
|
||||||
|
import { applyParsedQuery } from './query-runner'
|
||||||
|
|
||||||
|
type GenericObject = Record<string, unknown>
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is GenericObject {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataOptions {
|
||||||
|
parsedQuery?: ParsedQuery | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataFilters {
|
||||||
|
id?: string
|
||||||
|
createdFrom?: string
|
||||||
|
createdTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataOptions {
|
||||||
|
parsedQuery?: ParsedQuery | null
|
||||||
|
filters?: DataFilters
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeRow(row: unknown, config: TableConfig): GenericObject | null {
|
||||||
|
if (!isRecord(row) || ('error' in row && (row as any).error === true)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const transform = config.transforms?.serializeRow
|
||||||
|
return transform ? transform(row) : row
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllTablesMetadata() {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
tableNames.map((name) => fetchTableMetadata(name))
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
.filter((result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof fetchTableMetadata>>> =>
|
||||||
|
result.status === 'fulfilled'
|
||||||
|
)
|
||||||
|
.map((result) => result.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTableMetadata(tableName: string, options?: MetadataOptions) {
|
||||||
|
const config = getTableConfig(tableName)
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = getSupabaseClient()
|
||||||
|
|
||||||
|
const baseSelect = config.defaultSelect ?? '*'
|
||||||
|
|
||||||
|
const countPromise = supabase
|
||||||
|
.from(config.table)
|
||||||
|
.select(baseSelect, { head: true, count: 'exact' })
|
||||||
|
const samplePromise = applyParsedQuery(
|
||||||
|
supabase.from(config.table).select(baseSelect),
|
||||||
|
options?.parsedQuery ?? null
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const earliestPromise = supabase
|
||||||
|
.from(config.table)
|
||||||
|
.select('created_at')
|
||||||
|
.order('created_at', { ascending: true })
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const latestPromise = supabase
|
||||||
|
.from(config.table)
|
||||||
|
.select('created_at')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const [{ count, error: countError }, { data: sampleData, error: sampleError }, earliest, latest] =
|
||||||
|
await Promise.all([countPromise, samplePromise, earliestPromise, latestPromise])
|
||||||
|
|
||||||
|
if (countError) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: countError.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sampleError) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: sampleError.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleSet = Array.isArray(sampleData) ? sampleData : []
|
||||||
|
const sampleRow = serializeRow(sampleSet[0] ?? null, config)
|
||||||
|
const columnNames = sampleRow ? Object.keys(sampleRow) : []
|
||||||
|
const approxRowSize = sampleRow ? JSON.stringify(sampleRow).length : 0
|
||||||
|
const approxSizeBytes = count && approxRowSize ? approxRowSize * count : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
table: config.table,
|
||||||
|
primaryKey: config.primaryKey ?? 'id',
|
||||||
|
rowCount: count ?? 0,
|
||||||
|
approxSizeBytes,
|
||||||
|
columns: columnNames,
|
||||||
|
createdAtRange: {
|
||||||
|
from: earliest.data?.[0]?.created_at ?? null,
|
||||||
|
to: latest.data?.[0]?.created_at ?? null
|
||||||
|
},
|
||||||
|
sampleRow,
|
||||||
|
lastRefreshed: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTableRecordMetadata(tableName: string, id: string) {
|
||||||
|
const config = getTableConfig(tableName)
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = getSupabaseClient()
|
||||||
|
const primaryKey = config.primaryKey ?? 'id'
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from(config.table)
|
||||||
|
.select(config.defaultSelect ?? '*')
|
||||||
|
.eq(primaryKey, id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: error.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || (typeof data === 'object' && data !== null && 'error' in data)) {
|
||||||
|
if (data && typeof data === 'object' && 'message' in data) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: String((data as any).message) })
|
||||||
|
}
|
||||||
|
throw createError({ statusCode: 404, statusMessage: `Registro ${id} no encontrado` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
table: config.table,
|
||||||
|
id,
|
||||||
|
metadata: serializeRow(data, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTableRecord(tableName: string, id: string) {
|
||||||
|
const config = getTableConfig(tableName)
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = getSupabaseClient()
|
||||||
|
const primaryKey = config.primaryKey ?? 'id'
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from(config.table)
|
||||||
|
.select(config.defaultSelect ?? '*')
|
||||||
|
.eq(primaryKey, id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: error.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || (typeof data === 'object' && data !== null && 'error' in data)) {
|
||||||
|
if (data && typeof data === 'object' && 'message' in data) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: String((data as any).message) })
|
||||||
|
}
|
||||||
|
throw createError({ statusCode: 404, statusMessage: `Registro ${id} no encontrado` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializeRow(data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllData(limitPerTable = 100) {
|
||||||
|
const supabase = getSupabaseClient()
|
||||||
|
|
||||||
|
const tablePromises = tableNames.map(async (name) => {
|
||||||
|
const config = getTableConfig(name)!
|
||||||
|
const { data, error, count } = await supabase
|
||||||
|
.from(config.table)
|
||||||
|
.select(config.defaultSelect ?? '*', { count: 'exact' })
|
||||||
|
.limit(limitPerTable)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: error.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Array.isArray(data) ? data : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
table: config.table,
|
||||||
|
count: count ?? 0,
|
||||||
|
limit: limitPerTable,
|
||||||
|
records: rows
|
||||||
|
.map((row) => serializeRow(row, config))
|
||||||
|
.filter((row): row is GenericObject => row !== null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Promise.all(tablePromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTableData(tableName: string, options?: DataOptions) {
|
||||||
|
const config = getTableConfig(tableName)
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = getSupabaseClient()
|
||||||
|
const primaryKey = config.primaryKey ?? 'id'
|
||||||
|
const limit = options?.limit ?? 100
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from(config.table)
|
||||||
|
.select(config.defaultSelect ?? '*', { count: 'exact' })
|
||||||
|
|
||||||
|
query = applyParsedQuery(query as never, options?.parsedQuery ?? null) as never
|
||||||
|
|
||||||
|
if (options?.filters?.id) {
|
||||||
|
query = query.eq(primaryKey, options.filters.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.filters?.createdFrom) {
|
||||||
|
query = query.gte('created_at', options.filters.createdFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.filters?.createdTo) {
|
||||||
|
query = query.lte('created_at', options.filters.createdTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options?.parsedQuery?.limit) {
|
||||||
|
query = query.limit(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: error.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Array.isArray(data) ? data : []
|
||||||
|
const records = rows
|
||||||
|
.map((row) => serializeRow(row, config))
|
||||||
|
.filter((row): row is GenericObject => row !== null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
table: config.table,
|
||||||
|
count: count ?? records.length,
|
||||||
|
limit,
|
||||||
|
records
|
||||||
|
}
|
||||||
|
}
|
||||||
28
nuxt4-app/server/utils/supabase.ts
Normal file
28
nuxt4-app/server/utils/supabase.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
let cachedClient: SupabaseClient | null = null
|
||||||
|
|
||||||
|
export function getSupabaseClient(): SupabaseClient {
|
||||||
|
if (cachedClient) {
|
||||||
|
return cachedClient
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
supabase: { url, serviceRoleKey }
|
||||||
|
} = useRuntimeConfig()
|
||||||
|
|
||||||
|
if (!url || !serviceRoleKey) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage:
|
||||||
|
'Supabase credentials are missing. Please set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedClient = createClient(url, serviceRoleKey, {
|
||||||
|
auth: { persistSession: false },
|
||||||
|
db: { schema: 'public' }
|
||||||
|
})
|
||||||
|
|
||||||
|
return cachedClient
|
||||||
|
}
|
||||||
18
nuxt4-app/tsconfig.json
Normal file
18
nuxt4-app/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
package.json
Normal file
9
package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "analitica-nucleo",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"workspaces": ["nuxt4-app"],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev --prefix nuxt4-app"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user