import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { Repository } from 'typeorm'; import { UserList } from '../list-templates/list-template.types'; import { ListsService } from '../lists/lists.service'; import { DailyDashboardSnapshotEntity } from './daily-dashboard-snapshot.entity'; import { CreateDashboardSuggestionResponse, DashboardImportantList, DashboardResponse, DashboardWeeklySuggestion, } from './dashboard.types'; import { WeeklyListSuggestionSnapshotEntity, WeeklyListSuggestionSnapshotItem, WeeklyListSuggestionSnapshotSuggestion, } from './weekly-list-suggestion-snapshot.entity'; interface MistralCompletionResponse { choices?: Array<{ message?: { content?: string | null; }; messages?: Array<{ role?: string; content?: string | null | unknown[]; }>; }>; outputs?: Array<{ type?: string; role?: string; content?: string | null | unknown[]; }>; output_text?: string | null; } interface DashboardAiResult { requestPayload: Record; responsePayload: unknown; value: T; } class DashboardAiCallError extends Error { constructor( message: string, readonly requestPayload: Record, readonly responsePayload: unknown, ) { super(message); } } interface SelectedListSnapshot { listId: string; reason: string; source: 'ai' | 'fallback'; } @Injectable() export class DashboardService { private readonly endpoint = 'https://api.mistral.ai/v1/conversations'; private readonly defaultModel = 'mistral-large-latest'; private readonly defaultTimezone = 'Europe/Budapest'; constructor( @InjectRepository(DailyDashboardSnapshotEntity) private readonly dailySnapshotsRepository: Repository, @InjectRepository(WeeklyListSuggestionSnapshotEntity) private readonly weeklySnapshotsRepository: Repository, private readonly listsService: ListsService, ) {} async getDashboard(userId: string): Promise { const timezone = this.dashboardTimezone(); const now = new Date(); const dateKey = this.dateKey(now, timezone); const weekKey = this.weekKey(now, timezone); const visibleLists = await this.listsService.listLists(userId); const dailySnapshot = await this.getOrCreateDailySnapshot( userId, dateKey, timezone, visibleLists, ); const weeklySnapshot = await this.getOrCreateWeeklySnapshot( userId, weekKey, timezone, visibleLists, ); return { timezone, dateKey, weekKey, importantLists: this.toImportantLists(dailySnapshot, visibleLists), weeklySuggestions: { suggestions: weeklySnapshot.suggestions.map((suggestion) => ({ ...suggestion, })), errorMessage: weeklySnapshot.errorMessage ?? undefined, generatedAt: weeklySnapshot.createdAt?.toISOString(), }, }; } async createSuggestionList( userId: string, suggestionId: string, ): Promise { const timezone = this.dashboardTimezone(); const weekKey = this.weekKey(new Date(), timezone); const snapshot = await this.weeklySnapshotsRepository.findOne({ where: { userId, weekKey }, }); if (!snapshot) { throw new NotFoundException('Weekly suggestions were not found.'); } const suggestion = snapshot.suggestions.find( (candidate) => candidate.id === suggestionId, ); if (!suggestion) { throw new NotFoundException('Weekly suggestion was not found.'); } if (suggestion.createdListId) { throw new BadRequestException('Weekly suggestion was already created.'); } const list = await this.listsService.createList(userId, { name: suggestion.title, description: suggestion.description, kind: 'custom', }); let updatedList = list; for (const item of suggestion.items.slice(0, 40)) { updatedList = await this.listsService.addItem(userId, list.id, { title: item.title, notes: item.notes, quantity: item.quantity, required: item.required, }); } suggestion.createdListId = updatedList.id; await this.weeklySnapshotsRepository.save(snapshot); return { list: updatedList, suggestion: { ...suggestion }, }; } private async getOrCreateDailySnapshot( userId: string, dateKey: string, timezone: string, visibleLists: UserList[], ): Promise { const existingSnapshot = await this.dailySnapshotsRepository.findOne({ where: { userId, dateKey }, }); if (existingSnapshot) { return existingSnapshot; } try { const result = await this.selectImportantListsWithAi(visibleLists); return this.dailySnapshotsRepository.save( this.dailySnapshotsRepository.create({ id: randomUUID(), userId, dateKey, timezone, selectedLists: result.value, requestPayload: result.requestPayload, responsePayload: result.responsePayload, errorMessage: null, }), ); } catch (error) { return this.dailySnapshotsRepository.save( this.dailySnapshotsRepository.create({ id: randomUUID(), userId, dateKey, timezone, selectedLists: this.fallbackImportantLists(visibleLists), requestPayload: this.errorRequestPayload(error, { provider: 'listify', strategy: 'deterministic-dashboard-fallback', }), responsePayload: this.errorResponsePayload(error), errorMessage: this.errorMessage(error), }), ); } } private async getOrCreateWeeklySnapshot( userId: string, weekKey: string, timezone: string, visibleLists: UserList[], ): Promise { const existingSnapshot = await this.weeklySnapshotsRepository.findOne({ where: { userId, weekKey }, }); if (existingSnapshot) { return existingSnapshot; } try { const result = await this.createWeeklySuggestionsWithAi(visibleLists); return this.weeklySnapshotsRepository.save( this.weeklySnapshotsRepository.create({ id: randomUUID(), userId, weekKey, timezone, suggestions: result.value, requestPayload: result.requestPayload, responsePayload: result.responsePayload, errorMessage: null, }), ); } catch (error) { return this.weeklySnapshotsRepository.save( this.weeklySnapshotsRepository.create({ id: randomUUID(), userId, weekKey, timezone, suggestions: [], requestPayload: this.errorRequestPayload(error, { provider: 'mistral', endpoint: this.endpoint, errorPhase: 'weekly-suggestions', }), responsePayload: this.errorResponsePayload(error), errorMessage: this.errorMessage(error), }), ); } } private async selectImportantListsWithAi( lists: UserList[], ): Promise> { if (lists.length === 0) { return { requestPayload: { provider: 'listify', listCount: 0 }, responsePayload: null, value: [], }; } const prompt = this.createDailyPrompt(lists); const aiResponse = await this.callMistral({ prompt, instructions: 'Waehle fuer das Listify-Dashboard die 5 wichtigsten Listen. Antworte nur mit JSON im Format {"lists":[{"listId":"...","reason":"..."}]}. Keine Markdown-Ausgabe.', }); const selectedLists = this.normalizeSelectedLists( aiResponse.responsePayload, lists, ); return { requestPayload: aiResponse.requestPayload, responsePayload: aiResponse.responsePayload, value: selectedLists.length > 0 ? selectedLists : this.fallbackImportantLists(lists), }; } private async createWeeklySuggestionsWithAi( lists: UserList[], ): Promise> { const prompt = this.createWeeklyPrompt(lists); const aiResponse = await this.callMistral({ prompt, instructions: 'Erzeuge 5 neue Listify-Listenvorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","description":"...","items":[{"title":"...","notes":"...","quantity":1,"required":true}],"reason":"..."}]}. Keine Markdown-Ausgabe.', }); return { requestPayload: aiResponse.requestPayload, responsePayload: aiResponse.responsePayload, value: this.normalizeWeeklySuggestions(aiResponse.responsePayload), }; } private async callMistral(input: { prompt: string; instructions: string; }): Promise<{ requestPayload: Record; responsePayload: unknown; }> { const apiKey = process.env.MISTRAL_API_KEY; const model = process.env.MISTRAL_MODEL ?? this.defaultModel; const requestPayload = this.requestMetadata( input.prompt, input.instructions, ); if (!apiKey) { throw new DashboardAiCallError( 'Mistral API key is not configured.', requestPayload, null, ); } const response = await fetch(this.endpoint, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model, inputs: [{ role: 'user', content: input.prompt }], instructions: input.instructions, stream: false, }), }); const responsePayload = await this.readResponsePayload(response); if (!response.ok) { throw new DashboardAiCallError( 'Mistral conversation request failed.', requestPayload, responsePayload, ); } return { requestPayload, responsePayload }; } private readResponsePayload(response: Response): Promise { return response.text().then((rawBody) => { if (!rawBody) { return null; } try { return JSON.parse(rawBody) as unknown; } catch { return { rawBody }; } }); } private normalizeSelectedLists( responsePayload: unknown, visibleLists: UserList[], ): SelectedListSnapshot[] { const visibleListIds = new Set(visibleLists.map((list) => list.id)); const selectedLists: SelectedListSnapshot[] = []; const seenListIds = new Set(); const parsed = this.parseObject( this.extractMistralContent(responsePayload), ); const candidates = Array.isArray(parsed?.lists) ? parsed.lists : []; for (const value of candidates) { if (!value || typeof value !== 'object' || Array.isArray(value)) { continue; } const candidate = value as { listId?: unknown; reason?: unknown }; const listId = typeof candidate.listId === 'string' ? candidate.listId : ''; if ( !visibleListIds.has(listId) || seenListIds.has(listId) || selectedLists.length >= 5 ) { continue; } seenListIds.add(listId); selectedLists.push({ listId, reason: this.compactString( typeof candidate.reason === 'string' ? candidate.reason : undefined, 360, ) ?? 'Diese Liste ist heute relevant.', source: 'ai', }); } if (selectedLists.length < Math.min(5, visibleLists.length)) { for (const fallback of this.fallbackImportantLists(visibleLists)) { if (seenListIds.has(fallback.listId)) { continue; } selectedLists.push(fallback); seenListIds.add(fallback.listId); if (selectedLists.length === 5) { break; } } } return selectedLists; } private normalizeWeeklySuggestions( responsePayload: unknown, ): WeeklyListSuggestionSnapshotSuggestion[] { const parsed = this.parseObject( this.extractMistralContent(responsePayload), ); const candidates = Array.isArray(parsed?.suggestions) ? parsed.suggestions : []; const suggestions: WeeklyListSuggestionSnapshotSuggestion[] = []; const seenTitles = new Set(); for (const value of candidates) { if (!value || typeof value !== 'object' || Array.isArray(value)) { continue; } const candidate = value as { title?: unknown; description?: unknown; items?: unknown; reason?: unknown; }; const title = this.compactString( typeof candidate.title === 'string' ? candidate.title : undefined, 160, ); if (!title) { continue; } const titleKey = this.normalizedKey(title); if (seenTitles.has(titleKey)) { continue; } const items = this.normalizeSuggestionItems(candidate.items); suggestions.push({ id: randomUUID(), title, description: this.compactString( typeof candidate.description === 'string' ? candidate.description : undefined, 500, ), items, reason: this.compactString( typeof candidate.reason === 'string' ? candidate.reason : undefined, 360, ) ?? 'Passt zu deinen bisherigen Listen.', }); seenTitles.add(titleKey); if (suggestions.length === 5) { break; } } return suggestions; } private normalizeSuggestionItems( value: unknown, ): WeeklyListSuggestionSnapshotItem[] { if (!Array.isArray(value)) { return []; } const items: WeeklyListSuggestionSnapshotItem[] = []; const seenTitles = new Set(); for (const itemValue of value) { if ( !itemValue || typeof itemValue !== 'object' || Array.isArray(itemValue) ) { continue; } const candidate = itemValue as { title?: unknown; notes?: unknown; quantity?: unknown; required?: unknown; }; const title = this.compactString( typeof candidate.title === 'string' ? candidate.title : undefined, 220, ); if (!title) { continue; } const titleKey = this.normalizedKey(title); if (seenTitles.has(titleKey)) { continue; } items.push({ title, notes: this.compactString( typeof candidate.notes === 'string' ? candidate.notes : undefined, 500, ), quantity: typeof candidate.quantity === 'number' && Number.isFinite(candidate.quantity) && candidate.quantity > 0 ? candidate.quantity : undefined, required: typeof candidate.required === 'boolean' ? candidate.required : true, }); seenTitles.add(titleKey); if (items.length === 12) { break; } } return items; } private fallbackImportantLists(lists: UserList[]): SelectedListSnapshot[] { return [...lists] .sort( (left, right) => this.fallbackScore(right) - this.fallbackScore(left), ) .slice(0, 5) .map((list) => ({ listId: list.id, reason: this.fallbackReason(list), source: 'fallback', })); } private fallbackScore(list: UserList): number { const openItems = list.items.filter((item) => !item.checked).length; const updatedAt = new Date(list.updatedAt).getTime(); const recencyScore = Number.isFinite(updatedAt) ? Math.max(0, 30 - (Date.now() - updatedAt) / 86_400_000) : 0; const reminderScore = list.reminderAt ? 120 : 0; return reminderScore + openItems * 8 + recencyScore; } private fallbackReason(list: UserList): string { const openItems = list.items.filter((item) => !item.checked).length; if (list.reminderAt && openItems > 0) { return 'Diese Liste hat eine Erinnerung und noch offene Eintraege.'; } if (list.reminderAt) { return 'Diese Liste hat eine aktive Erinnerung.'; } if (openItems > 0) { return `Diese Liste hat ${openItems} offene ${openItems === 1 ? 'Eintrag' : 'Eintraege'}.`; } return 'Diese Liste wurde zuletzt aktualisiert.'; } private toImportantLists( snapshot: DailyDashboardSnapshotEntity, visibleLists: UserList[], ): DashboardImportantList[] { const listsById = new Map(visibleLists.map((list) => [list.id, list])); return snapshot.selectedLists .map((selectedList) => { const list = listsById.get(selectedList.listId); if (!list) { return null; } const checkedItems = list.items.filter((item) => item.checked).length; const totalItems = list.items.length; const openItems = totalItems - checkedItems; return { list, reason: selectedList.reason, source: selectedList.source, checkedItems, openItems, totalItems, progress: totalItems === 0 ? 0 : checkedItems / totalItems, hasReminder: Boolean(list.reminderAt), }; }) .filter((item): item is DashboardImportantList => item !== null); } private createDailyPrompt(lists: UserList[]): string { return [ 'Waehle die 5 wichtigsten vorhandenen Listen fuer das heutige Dashboard aus.', 'Bewerte Erinnerungen, offene Items, Aktualitaet, Kontext und praktischen Nutzen.', 'Nutze ausschliesslich die angegebenen listId-Werte.', '', this.formatListsForPrompt(lists), ].join('\n'); } private createWeeklyPrompt(lists: UserList[]): string { return [ 'Erzeuge 5 neue Listenvorschlaege fuer diesen User.', 'Die Vorschlaege sollen vorhandene Listen sinnvoll ergaenzen und nicht einfach duplizieren.', 'Jeder Vorschlag braucht Titel, kurze Beschreibung, 3 bis 8 Beispiel-Items und eine kurze Begruendung.', '', this.formatListsForPrompt(lists), ].join('\n'); } private formatListsForPrompt(lists: UserList[]): string { if (lists.length === 0) { return 'Vorhandene sichtbare Listen: keine'; } const lines = ['Vorhandene sichtbare Listen:']; for (const list of lists.slice(0, 80)) { const checkedItems = list.items.filter((item) => item.checked).length; const openItems = list.items.length - checkedItems; lines.push( [ `- listId: ${list.id}`, `Name: ${this.compactString(list.name, 160)}`, `Typ: ${list.kind}`, `Beschreibung: ${this.compactString(list.description, 240) ?? 'keine'}`, `Items: ${list.items.length}`, `Offen: ${openItems}`, `Erledigt: ${checkedItems}`, `Erinnerung: ${list.reminderAt ?? 'keine'}`, `Aktualisiert: ${list.updatedAt}`, `Beispiel-Items: ${ list.items .slice(0, 8) .map((item) => item.title) .join(', ') || 'keine' }`, ].join(' | '), ); } return lines.join('\n'); } private requestMetadata( prompt: string, instructions?: string, ): Record { return { provider: 'mistral', endpoint: this.endpoint, model: process.env.MISTRAL_MODEL ?? this.defaultModel, prompt, instructions, }; } private extractMistralContent(responsePayload: unknown): string | null { if (!responsePayload || typeof responsePayload !== 'object') { return null; } const response = responsePayload as MistralCompletionResponse; const directContent = response.choices?.[0]?.message?.content?.trim(); if (directContent) { return directContent; } for (const choice of response.choices ?? []) { const assistantMessages = (choice.messages ?? []) .filter((message) => message.role === 'assistant') .reverse(); for (const message of assistantMessages) { const content = this.contentToText(message.content).trim(); if (content) { return content; } } } const outputText = response.output_text?.trim(); if (outputText) { return outputText; } for (const output of [...(response.outputs ?? [])].reverse()) { const content = this.contentToText(output.content).trim(); if ( content && (output.role === 'assistant' || output.type?.includes('message')) ) { return content; } } return null; } private contentToText(value: unknown): string { if (typeof value === 'string') { return value; } if (!Array.isArray(value)) { return ''; } return value .map((part) => { if (typeof part === 'string') { return part; } if (!part || typeof part !== 'object' || Array.isArray(part)) { return null; } const candidate = part as { text?: unknown }; return typeof candidate.text === 'string' ? candidate.text : null; }) .filter((part): part is string => part !== null) .join('\n'); } private parseObject(content: string | null): Record | null { if (!content) { return null; } try { const parsed = JSON.parse(content) as unknown; return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null; } catch { return null; } } private dateKey(date: Date, timezone: string): string { const parts = this.localDateParts(date, timezone); return `${parts.year}-${this.pad2(parts.month)}-${this.pad2(parts.day)}`; } private weekKey(date: Date, timezone: string): string { const parts = this.localDateParts(date, timezone); const localDate = new Date( Date.UTC(parts.year, parts.month - 1, parts.day), ); const day = localDate.getUTCDay() || 7; localDate.setUTCDate(localDate.getUTCDate() + 4 - day); const weekYear = localDate.getUTCFullYear(); const yearStart = new Date(Date.UTC(weekYear, 0, 1)); const week = Math.ceil( ((localDate.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7, ); return `${weekYear}-W${this.pad2(week)}`; } private localDateParts( date: Date, timezone: string, ): { year: number; month: number; day: number } { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(date); const partValue = (type: string) => Number(parts.find((part) => part.type === type)?.value); return { year: partValue('year'), month: partValue('month'), day: partValue('day'), }; } private dashboardTimezone(): string { return process.env.DASHBOARD_TIMEZONE?.trim() || this.defaultTimezone; } private pad2(value: number): string { return String(value).padStart(2, '0'); } private compactString( value: string | undefined, maxLength: number, ): string | undefined { const compacted = value?.replace(/\s+/g, ' ').trim(); if (!compacted) { return undefined; } return compacted.length <= maxLength ? compacted : `${compacted.slice(0, maxLength - 3)}...`; } private normalizedKey(value: string): string { return value.trim().replace(/\s+/g, ' ').toLowerCase(); } private errorMessage(error: unknown): string { return error instanceof Error ? error.message : 'Dashboard generation failed.'; } private errorRequestPayload( error: unknown, fallback: Record, ): Record { return error instanceof DashboardAiCallError ? error.requestPayload : fallback; } private errorResponsePayload(error: unknown): unknown { return error instanceof DashboardAiCallError ? error.responsePayload : null; } }