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:
44
server/api/analysis/index.post.ts
Normal file
44
server/api/analysis/index.post.ts
Normal 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)
|
||||
})
|
||||
17
server/api/auth/login.post.ts
Normal file
17
server/api/auth/login.post.ts
Normal 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 }
|
||||
})
|
||||
8
server/api/auth/logout.post.ts
Normal file
8
server/api/auth/logout.post.ts
Normal 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 }
|
||||
})
|
||||
5
server/api/auth/me.get.ts
Normal file
5
server/api/auth/me.get.ts
Normal 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
1
server/api/health.get.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default defineEventHandler(() => ({ status: 'ok' }))
|
||||
9
server/api/ideas/[id].delete.ts
Normal file
9
server/api/ideas/[id].delete.ts
Normal 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 }
|
||||
})
|
||||
20
server/api/ideas/[id].patch.ts
Normal file
20
server/api/ideas/[id].patch.ts
Normal 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 }
|
||||
})
|
||||
10
server/api/ideas/index.get.ts
Normal file
10
server/api/ideas/index.get.ts
Normal 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
|
||||
})
|
||||
11
server/api/ideas/index.post.ts
Normal file
11
server/api/ideas/index.post.ts
Normal 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 }
|
||||
})
|
||||
19
server/api/votes/[id].post.ts
Normal file
19
server/api/votes/[id].post.ts
Normal 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 }
|
||||
})
|
||||
6
server/middleware/auth.ts
Normal file
6
server/middleware/auth.ts
Normal 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
41
server/utils/auth.ts
Normal 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
39
server/utils/db.ts
Normal 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
|
||||
Reference in New Issue
Block a user