feat: initial brainstorming app — Nuxt 3 + SQLite + admin auth

Nuxt 3 app with:
- SQLite (better-sqlite3) for persistence
- Anonymous idea submission and voting
- Admin auth with session cookies
- AI analysis via Gemini API
- Nuxt UI components + Tailwind

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Martinez
2026-04-07 14:15:45 +02:00
commit e7de636cf2
25 changed files with 9114 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import db from '../../utils/db'
import { requireAdmin } from '../../utils/auth'
export default defineEventHandler(async (event) => {
requireAdmin(event)
const ideas = db.prepare('SELECT text, category FROM ideas WHERE hidden = 0').all() as any[]
if (ideas.length < 3) {
throw createError({ statusCode: 400, statusMessage: 'Need at least 3 ideas' })
}
const { apiKey } = await readBody(event)
if (!apiKey) {
throw createError({ statusCode: 400, statusMessage: 'API key required' })
}
const prompt = `Analiza las siguientes ideas de automatización propuestas por un equipo de ingeniería:
${ideas.map((i: any) => `- [${i.category}]: ${i.text}`).join('\n')}
Por favor, genera un resumen ejecutivo en formato JSON con:
1. "topFrictionPoints": Los 3 problemas más recurrentes.
2. "quickWins": Automatizaciones fáciles de implementar con alto impacto.
3. "strategicAdvice": Recomendación sobre qué priorizar.
Responde SOLO con el JSON.`
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { responseMimeType: 'application/json' },
}),
},
)
if (!response.ok) {
throw createError({ statusCode: 502, statusMessage: 'AI API failed' })
}
const data = await response.json()
return JSON.parse(data.candidates[0].content.parts[0].text)
})

View File

@@ -0,0 +1,17 @@
import { checkPassword, createSession } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const { password } = await readBody(event)
if (!password || !checkPassword(password)) {
throw createError({ statusCode: 401, statusMessage: 'Invalid password' })
}
const token = createSession()
setCookie(event, 'session', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 86400,
path: '/',
})
return { ok: true }
})

View File

@@ -0,0 +1,8 @@
import { destroySession } from '../../utils/auth'
export default defineEventHandler((event) => {
const token = getCookie(event, 'session')
if (token) destroySession(token)
deleteCookie(event, 'session', { path: '/' })
return { ok: true }
})

View File

@@ -0,0 +1,5 @@
import { isAdmin } from '../../utils/auth'
export default defineEventHandler((event) => {
return { admin: isAdmin(event) }
})

1
server/api/health.get.ts Normal file
View File

@@ -0,0 +1 @@
export default defineEventHandler(() => ({ status: 'ok' }))

View File

@@ -0,0 +1,9 @@
import db from '../../utils/db'
import { requireAdmin } from '../../utils/auth'
export default defineEventHandler((event) => {
requireAdmin(event)
const id = getRouterParam(event, 'id')
db.prepare('DELETE FROM ideas WHERE id = ?').run(id)
return { ok: true }
})

View File

@@ -0,0 +1,20 @@
import db from '../../utils/db'
import { requireAdmin } from '../../utils/auth'
export default defineEventHandler(async (event) => {
requireAdmin(event)
const id = getRouterParam(event, 'id')
const body = await readBody(event)
if (body.hidden !== undefined) {
db.prepare('UPDATE ideas SET hidden = ? WHERE id = ?').run(body.hidden ? 1 : 0, id)
}
if (body.text !== undefined) {
db.prepare('UPDATE ideas SET text = ? WHERE id = ?').run(body.text, id)
}
if (body.category !== undefined) {
db.prepare('UPDATE ideas SET category = ? WHERE id = ?').run(body.category, id)
}
return { ok: true }
})

View File

@@ -0,0 +1,10 @@
import db from '../../utils/db'
import { isAdmin } from '../../utils/auth'
export default defineEventHandler((event) => {
const admin = isAdmin(event)
const rows = admin
? db.prepare('SELECT * FROM ideas ORDER BY created_at DESC').all()
: db.prepare('SELECT * FROM ideas WHERE hidden = 0 ORDER BY created_at DESC').all()
return rows
})

View File

@@ -0,0 +1,11 @@
import db from '../../utils/db'
export default defineEventHandler(async (event) => {
const { text, category } = await readBody(event)
if (!text?.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Text is required' })
}
const cat = category || 'General'
const result = db.prepare('INSERT INTO ideas (text, category) VALUES (?, ?)').run(text.trim(), cat)
return { id: result.lastInsertRowid }
})

View File

@@ -0,0 +1,19 @@
import { createHash } from 'crypto'
import db from '../../utils/db'
export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id')
const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
const voterHash = createHash('sha256').update(ip + ':' + id).digest('hex')
try {
db.prepare('INSERT INTO vote_log (idea_id, voter_hash) VALUES (?, ?)').run(id, voterHash)
db.prepare('UPDATE ideas SET votes = votes + 1 WHERE id = ?').run(id)
} catch {
// UNIQUE constraint = already voted
throw createError({ statusCode: 409, statusMessage: 'Already voted' })
}
const row = db.prepare('SELECT votes FROM ideas WHERE id = ?').get(id) as any
return { votes: row?.votes ?? 0 }
})

View File

@@ -0,0 +1,6 @@
import { validateSession } from '../utils/auth'
export default defineEventHandler((event) => {
const token = getCookie(event, 'session')
event.context.isAdmin = token ? validateSession(token) : false
})

41
server/utils/auth.ts Normal file
View File

@@ -0,0 +1,41 @@
import { randomBytes, timingSafeEqual } from 'crypto'
import type { H3Event } from 'h3'
import db from './db'
export function createSession(): string {
const token = randomBytes(32).toString('hex')
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
db.prepare('INSERT INTO sessions (token, expires_at) VALUES (?, ?)').run(token, expires)
return token
}
export function validateSession(token: string): boolean {
const row = db.prepare('SELECT expires_at FROM sessions WHERE token = ?').get(token) as any
if (!row) return false
if (new Date(row.expires_at) < new Date()) {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
return false
}
return true
}
export function destroySession(token: string) {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
}
export function checkPassword(input: string): boolean {
const config = useRuntimeConfig()
const expected = config.adminPassword as string
if (input.length !== expected.length) return false
return timingSafeEqual(Buffer.from(input), Buffer.from(expected))
}
export function isAdmin(event: H3Event): boolean {
return event.context.isAdmin === true
}
export function requireAdmin(event: H3Event) {
if (!isAdmin(event)) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
}

39
server/utils/db.ts Normal file
View File

@@ -0,0 +1,39 @@
import Database from 'better-sqlite3'
import { mkdirSync } from 'fs'
import { dirname } from 'path'
const config = useRuntimeConfig()
const dbPath = config.dbPath as string
mkdirSync(dirname(dbPath), { recursive: true })
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.exec(`
CREATE TABLE IF NOT EXISTS ideas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'General',
votes INTEGER NOT NULL DEFAULT 0,
hidden INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS vote_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
idea_id INTEGER NOT NULL REFERENCES ideas(id) ON DELETE CASCADE,
voter_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(idea_id, voter_hash)
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
`)
export default db