Initial Nuxt data explorer setup

This commit is contained in:
2025-09-29 14:10:11 -06:00
commit 47f4a20bd3
35 changed files with 13509 additions and 0 deletions

23
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

22
nuxt4-app/package.json Normal file
View 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
import { fetchAllTablesMetadata } from '../../services/table-service'
export default defineEventHandler(async () => {
return await fetchAllTablesMetadata()
})

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const anticiposConfig: TableConfig = {
table: 'anticipos',
primaryKey: 'id'
}

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const asistenciasConfig: TableConfig = {
table: 'asistencias',
primaryKey: 'id'
}

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const carretasConfig: TableConfig = {
table: 'carretas',
primaryKey: 'id'
}

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const clientesConfig: TableConfig = {
table: 'clientes',
primaryKey: 'id'
}

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const comerciosConfig: TableConfig = {
table: 'comercios',
primaryKey: 'id'
}

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const cuponesConfig: TableConfig = {
table: 'cupones',
primaryKey: 'id'
}

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const depositosConfig: TableConfig = {
table: 'depositos',
primaryKey: 'id'
}

View 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]
}

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const ingresosConfig: TableConfig = {
table: 'ingresos',
primaryKey: 'id'
}

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const pagosAnticipoConfig: TableConfig = {
table: 'pagos_anticipo',
primaryKey: 'id'
}

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const rechazosConfig: TableConfig = {
table: 'rechazos',
primaryKey: 'id'
}

View File

@@ -0,0 +1,6 @@
import type { TableConfig } from '../types'
export const tareasRealizadasConfig: TableConfig = {
table: 'tareas_realizadas',
primaryKey: 'id'
}

View 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 }
}

View 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
}
}

View 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
}

View 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
}
}

View 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
View 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
View 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"
}
}