@@ -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 2– 4 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