Add AI travel day summaries via OpenRouter for skippers.

Skipper-only proxy with per-entry rate limiting, encrypted payload storage, CSV export, and Plausible tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 11:26:19 +02:00
parent 85e641ed39
commit 3ac4201734
19 changed files with 752 additions and 7 deletions
+11
View File
@@ -252,3 +252,14 @@ model GpsTrackPayload {
@@index([logbookId])
}
model AiSummaryUsage {
id String @id @default(uuid())
logbookId String
entryId String
count Int @default(0)
updatedAt DateTime @updatedAt
@@unique([logbookId, entryId])
@@index([logbookId])
}
+14
View File
@@ -45,4 +45,18 @@ describe('API smoke', () => {
expect(res.status).toBe(400)
expect(res.body.error).toMatch(/Token/i)
})
it('POST /api/ai/summary requires session', async () => {
const res = await request(app)
.post('/api/ai/summary')
.send({ logbookId: 'x', entryId: 'y', context: {} })
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
it('GET /api/ai/usage requires session', async () => {
const res = await request(app).get('/api/ai/usage')
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
})
+2
View File
@@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import aiRouter from './routes/ai.js'
import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js'
@@ -118,6 +119,7 @@ export function createApp(): express.Express {
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/ai', aiRouter)
app.use('/api/feedback', feedbackRouter)
app.get('/api/health', async (_req, res) => {
+233
View File
@@ -0,0 +1,233 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
const MAX_ATTEMPTS_PER_ENTRY = 3
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
const FETCH_TIMEOUT_MS = 60_000
/** Common misconfiguration aliases → valid OpenRouter model IDs */
const MODEL_ALIASES: Record<string, string> = {
'anthropic/claude-haiku-latest': 'anthropic/claude-3.5-haiku',
'claude-haiku-latest': 'anthropic/claude-3.5-haiku'
}
const LANGUAGE_LABELS: Record<string, string> = {
de: 'German',
en: 'English',
da: 'Danish',
nb: 'Norwegian Bokmål',
sv: 'Swedish'
}
function resolveOpenRouterApiKey(): string | null {
const fromEnv =
process.env.OpenRouterAPIKey?.trim() ||
process.env.OPENROUTER_API_KEY?.trim()
return fromEnv || null
}
function resolveOpenRouterModel(): string {
const configured =
process.env.OpenRouterModel?.trim() ||
process.env.OPENROUTER_MODEL?.trim() ||
DEFAULT_MODEL
return MODEL_ALIASES[configured] ?? configured
}
function extractOpenRouterError(data: unknown): string | null {
if (typeof data !== 'object' || data === null) return null
const nested = (data as { error?: { message?: string } }).error
if (nested && typeof nested.message === 'string' && nested.message.trim()) {
return nested.message.trim()
}
const topLevel = (data as { error?: string }).error
if (typeof topLevel === 'string' && topLevel.trim()) return topLevel.trim()
return null
}
async function getLogbookOwner(logbookId: string) {
return prisma.logbook.findUnique({
where: { id: logbookId },
select: { userId: true }
})
}
async function getUsageCount(logbookId: string, entryId: string): Promise<number> {
const row = await prisma.aiSummaryUsage.findUnique({
where: { logbookId_entryId: { logbookId, entryId } },
select: { count: true }
})
return row?.count ?? 0
}
function remainingAttempts(used: number): number {
return Math.max(0, MAX_ATTEMPTS_PER_ENTRY - used)
}
function resolveLanguageLabel(language: unknown): string {
if (typeof language === 'string' && LANGUAGE_LABELS[language]) {
return LANGUAGE_LABELS[language]
}
return LANGUAGE_LABELS.en
}
function buildSystemPrompt(languageLabel: string): string {
return [
'You are a maritime logbook assistant for sailing yachts.',
`Write a concise narrative summary of one travel day in ${languageLabel}.`,
'Use 24 short paragraphs in plain prose.',
'Cover route, sailing conditions, notable events, and tank/fuel highlights when data is present.',
'Do not invent facts not supported by the input.',
'Do not include coordinates, personal names, or signature metadata.',
'Respond with the summary text only — no title, markdown, or JSON.'
].join(' ')
}
router.use(requireUser)
router.get('/usage', async (req: any, res) => {
try {
const logbookId = String(req.query.logbookId || '')
const entryId = String(req.query.entryId || '')
if (!logbookId || !entryId) {
return res.status(400).json({ error: 'logbookId and entryId are required' })
}
const logbook = await getLogbookOwner(logbookId)
if (!logbook) return res.status(404).json({ error: 'Logbook not found' })
if (logbook.userId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: Skipper only' })
}
const used = await getUsageCount(logbookId, entryId)
return res.json({ remainingAttempts: remainingAttempts(used), maxAttempts: MAX_ATTEMPTS_PER_ENTRY })
} catch (error: unknown) {
console.error('AI summary usage lookup failed:', error)
return res.status(500).json({ error: 'Failed to load AI summary usage' })
}
})
router.post('/summary', async (req: any, res) => {
try {
const { logbookId, entryId, language, context } = req.body ?? {}
if (!logbookId || !entryId || !context || typeof context !== 'object') {
return res.status(400).json({ error: 'logbookId, entryId, and context are required' })
}
const logbook = await getLogbookOwner(String(logbookId))
if (!logbook) return res.status(404).json({ error: 'Logbook not found' })
if (logbook.userId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: Skipper only' })
}
const used = await getUsageCount(String(logbookId), String(entryId))
if (used >= MAX_ATTEMPTS_PER_ENTRY) {
return res.status(429).json({
error: 'Rate limit exceeded for this travel day',
code: 'RATE_LIMITED',
remainingAttempts: 0,
maxAttempts: MAX_ATTEMPTS_PER_ENTRY
})
}
const apiKey = resolveOpenRouterApiKey()
if (!apiKey) {
return res.status(503).json({
error: 'No OpenRouter API key configured',
code: 'NO_KEY'
})
}
const languageLabel = resolveLanguageLabel(language)
const model = resolveOpenRouterModel()
const contextJson = JSON.stringify(context)
if (contextJson.length > 100_000) {
return res.status(400).json({ error: 'Travel day context is too large' })
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
let openRouterRes: Response
try {
openRouterRes = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': process.env.ORIGIN || 'https://kapteins-daagbok.eu',
'X-Title': 'Kapteins Daagbok'
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: buildSystemPrompt(languageLabel) },
{
role: 'user',
content: `Summarize this travel day from the structured log data:\n\n${contextJson}`
}
],
max_tokens: 800,
temperature: 0.4
}),
signal: controller.signal
})
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return res.status(504).json({ error: 'OpenRouter request timed out' })
}
throw error
} finally {
clearTimeout(timeoutId)
}
const data = await openRouterRes.json().catch(() => ({}))
if (!openRouterRes.ok) {
const detail = extractOpenRouterError(data)
console.error('OpenRouter error:', openRouterRes.status, data)
return res.status(502).json({
error: detail || 'OpenRouter request failed',
code: 'OPENROUTER_ERROR'
})
}
const summary =
typeof data === 'object' &&
data !== null &&
Array.isArray((data as { choices?: unknown[] }).choices) &&
(data as { choices: Array<{ message?: { content?: string } }> }).choices[0]?.message?.content
? String((data as { choices: Array<{ message?: { content?: string } }> }).choices[0].message?.content).trim()
: ''
if (!summary) {
return res.status(502).json({ error: 'OpenRouter returned an empty summary' })
}
const updated = await prisma.aiSummaryUsage.upsert({
where: {
logbookId_entryId: { logbookId: String(logbookId), entryId: String(entryId) }
},
create: {
logbookId: String(logbookId),
entryId: String(entryId),
count: 1
},
update: { count: { increment: 1 } }
})
return res.json({
summary,
remainingAttempts: remainingAttempts(updated.count),
maxAttempts: MAX_ATTEMPTS_PER_ENTRY
})
} catch (error: unknown) {
console.error('AI summary generation failed:', error)
return res.status(500).json({ error: 'Failed to generate AI summary' })
}
})
export default router