dashboard

This commit is contained in:
Bastian Wagner
2026-06-24 15:16:18 +02:00
parent 01f2aff0be
commit ba0b34d130
19 changed files with 2095 additions and 6 deletions

View File

@@ -15,9 +15,13 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
CLIENT_URL=http://localhost:4200
MCP_ACCESS_TOKEN=
MISTRAL_API_KEY=
MISTRAL_AGENT_ID=
DASHBOARD_TIMEZONE=Europe/Budapest
MAIL_ENABLED=true
SMTP_HOST=localhost
SMTP_PORT=1025

View File

@@ -7,6 +7,7 @@ import { AppService } from './app.service';
import { AssistantModule } from './assistant/assistant.module';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { ListTemplatesModule } from './list-templates/list-templates.module';
import { ListsModule } from './lists/lists.module';
import { MailModule } from './mail/mail.module';
@@ -26,7 +27,10 @@ import { DatabaseLogger } from './database/database.logger';
useFactory: (configService: ConfigService) => {
const env = {
DB_LOGGING: configService.get<string>('DB_LOGGING', 'false'),
DB_LOG_PARAMETERS: configService.get<string>('DB_LOG_PARAMETERS', 'false'),
DB_LOG_PARAMETERS: configService.get<string>(
'DB_LOG_PARAMETERS',
'false',
),
DB_LOG_MAX_PARAM_LENGTH: configService.get<string>(
'DB_LOG_MAX_PARAM_LENGTH',
'500',
@@ -56,6 +60,7 @@ import { DatabaseLogger } from './database/database.logger';
AssistantModule,
AuditModule,
AuthModule,
DashboardModule,
MailModule,
ListsModule,
ListTemplatesModule,

View File

@@ -0,0 +1,56 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('daily_dashboard_snapshots')
@Index(['userId', 'dateKey'], { unique: true })
export class DailyDashboardSnapshotEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
userId!: string;
@Column({ type: 'varchar', length: 10 })
dateKey!: string;
@Column({ type: 'varchar', length: 80 })
timezone!: string;
@Column({ type: 'json' })
selectedLists!: Array<{
listId: string;
reason: string;
source: 'ai' | 'fallback';
}>;
@Column({ type: 'json' })
requestPayload!: Record<string, unknown>;
@Column({ type: 'json', nullable: true })
responsePayload?: unknown;
@Column({ type: 'text', nullable: true })
errorMessage?: string | null;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@UpdateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
onUpdate: 'CURRENT_TIMESTAMP(3)',
})
updatedAt!: Date;
}

View File

@@ -0,0 +1,42 @@
import {
Controller,
Get,
Param,
Post,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthenticatedRequest } from '../auth/auth.types';
import { DashboardService } from './dashboard.service';
@Controller('dashboard')
@UseGuards(JwtAuthGuard)
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get()
getDashboard(@Req() request: AuthenticatedRequest) {
return this.dashboardService.getDashboard(this.requireUserId(request));
}
@Post('weekly-suggestions/:suggestionId/create')
createSuggestion(
@Req() request: AuthenticatedRequest,
@Param('suggestionId') suggestionId: string,
) {
return this.dashboardService.createSuggestionList(
this.requireUserId(request),
suggestionId,
);
}
private requireUserId(request: AuthenticatedRequest): string {
if (!request.user?.sub) {
throw new UnauthorizedException('Authenticated user is required.');
}
return request.user.sub;
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ListsModule } from '../lists/lists.module';
import { DailyDashboardSnapshotEntity } from './daily-dashboard-snapshot.entity';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
import { WeeklyListSuggestionSnapshotEntity } from './weekly-list-suggestion-snapshot.entity';
@Module({
imports: [
ListsModule,
TypeOrmModule.forFeature([
DailyDashboardSnapshotEntity,
WeeklyListSuggestionSnapshotEntity,
]),
],
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,391 @@
import { BadRequestException } from '@nestjs/common';
import { InMemoryRepository } from '../testing/in-memory-repository';
import { UserList } from '../list-templates/list-template.types';
import { ListsService } from '../lists/lists.service';
import { DailyDashboardSnapshotEntity } from './daily-dashboard-snapshot.entity';
import { DashboardService } from './dashboard.service';
import { WeeklyListSuggestionSnapshotEntity } from './weekly-list-suggestion-snapshot.entity';
describe('DashboardService', () => {
const originalFetch = global.fetch;
const originalApiKey = process.env.MISTRAL_API_KEY;
const originalModel = process.env.MISTRAL_MODEL;
const originalTimezone = process.env.DASHBOARD_TIMEZONE;
let dailySnapshotsRepository: InMemoryRepository<DailyDashboardSnapshotEntity>;
let weeklySnapshotsRepository: InMemoryRepository<WeeklyListSuggestionSnapshotEntity>;
let listsService: {
listLists: jest.Mock;
createList: jest.Mock;
addItem: jest.Mock;
};
let service: DashboardService;
beforeEach(() => {
process.env.MISTRAL_API_KEY = 'test-key';
process.env.MISTRAL_MODEL = 'mistral-large-test';
process.env.DASHBOARD_TIMEZONE = 'UTC';
global.fetch = jest.fn();
dailySnapshotsRepository =
new InMemoryRepository<DailyDashboardSnapshotEntity>();
weeklySnapshotsRepository =
new InMemoryRepository<WeeklyListSuggestionSnapshotEntity>();
listsService = {
listLists: jest.fn(),
createList: jest.fn(),
addItem: jest.fn(),
};
service = new DashboardService(
dailySnapshotsRepository as never,
weeklySnapshotsRepository as never,
listsService as unknown as ListsService,
);
});
afterEach(() => {
global.fetch = originalFetch;
restoreEnv('MISTRAL_API_KEY', originalApiKey);
restoreEnv('MISTRAL_MODEL', originalModel);
restoreEnv('DASHBOARD_TIMEZONE', originalTimezone);
});
it('creates a daily snapshot once and reuses the database cache', async () => {
const lists = [
createList('list-1', 'Einkauf'),
createList('list-2', 'Todo'),
];
listsService.listLists.mockResolvedValue(lists);
await saveCurrentWeeklySnapshot(weeklySnapshotsRepository, 'user-1');
mockMistralResponses([
{
choices: [
{
message: {
content: JSON.stringify({
lists: [
{ listId: 'list-2', reason: 'Viele offene Aufgaben.' },
{ listId: 'list-1', reason: 'Aktueller Einkauf.' },
],
}),
},
},
],
},
]);
const firstResponse = await service.getDashboard('user-1');
const secondResponse = await service.getDashboard('user-1');
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(await dailySnapshotsRepository.find()).toHaveLength(1);
expect(firstResponse.importantLists.map((item) => item.list.id)).toEqual([
'list-2',
'list-1',
]);
expect(secondResponse.importantLists.map((item) => item.list.id)).toEqual([
'list-2',
'list-1',
]);
});
it('creates weekly suggestions once per user and week', async () => {
listsService.listLists.mockResolvedValue([createList('list-1', 'Einkauf')]);
mockMistralResponses([
{
choices: [
{
message: {
content: '{"lists":[{"listId":"list-1","reason":"Relevant."}]}',
},
},
],
},
{
choices: [
{
message: {
content: JSON.stringify({
suggestions: [
{
title: 'Meal Prep',
description: 'Vorbereitung fuer die Woche',
items: [{ title: 'Rezepte auswaehlen', required: true }],
reason: 'Ergaenzt Einkaufslisten.',
},
],
}),
},
},
],
},
]);
const firstResponse = await service.getDashboard('user-1');
const secondResponse = await service.getDashboard('user-1');
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(await weeklySnapshotsRepository.find()).toHaveLength(1);
expect(firstResponse.weeklySuggestions.suggestions).toHaveLength(1);
expect(secondResponse.weeklySuggestions.suggestions).toHaveLength(1);
});
it('keeps dashboard snapshots isolated per user', async () => {
listsService.listLists.mockImplementation((userId: string) =>
Promise.resolve([createList(`${userId}-list`, `Liste ${userId}`)]),
);
mockMistralResponses([
{
choices: [
{
message: {
content: '{"lists":[{"listId":"user-1-list","reason":"User 1"}]}',
},
},
],
},
{ choices: [{ message: { content: '{"suggestions":[]}' } }] },
{
choices: [
{
message: {
content: '{"lists":[{"listId":"user-2-list","reason":"User 2"}]}',
},
},
],
},
{ choices: [{ message: { content: '{"suggestions":[]}' } }] },
]);
const userOneDashboard = await service.getDashboard('user-1');
const userTwoDashboard = await service.getDashboard('user-2');
expect(userOneDashboard.importantLists[0].list.id).toBe('user-1-list');
expect(userTwoDashboard.importantLists[0].list.id).toBe('user-2-list');
expect(await dailySnapshotsRepository.find()).toHaveLength(2);
});
it('filters deleted lists from a stored daily snapshot at response time', async () => {
const { dateKey } = currentKeys();
listsService.listLists.mockResolvedValue([createList('list-2', 'Bleibt')]);
await dailySnapshotsRepository.save({
id: 'daily-1',
userId: 'user-1',
dateKey,
timezone: 'UTC',
selectedLists: [
{ listId: 'list-1', reason: 'Geloescht', source: 'ai' },
{ listId: 'list-2', reason: 'Bleibt sichtbar', source: 'ai' },
],
requestPayload: {},
responsePayload: null,
errorMessage: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await saveCurrentWeeklySnapshot(weeklySnapshotsRepository, 'user-1');
const response = await service.getDashboard('user-1');
expect(response.importantLists).toHaveLength(1);
expect(response.importantLists[0].list.id).toBe('list-2');
expect(global.fetch).not.toHaveBeenCalled();
});
it('falls back deterministically when the daily AI call fails', async () => {
const lists = [
createList('old', 'Alt', { updatedAt: '2026-01-01T00:00:00.000Z' }),
createList('open', 'Offen', {
items: [
{ id: 'item-1', title: 'A', checked: false },
{ id: 'item-2', title: 'B', checked: false },
],
}),
createList('reminder', 'Reminder', {
reminderAt: '2026-06-24T10:00:00.000Z',
}),
];
listsService.listLists.mockResolvedValue(lists);
await saveCurrentWeeklySnapshot(weeklySnapshotsRepository, 'user-1');
jest.mocked(global.fetch).mockRejectedValue(new Error('network down'));
const response = await service.getDashboard('user-1');
expect(response.importantLists[0].list.id).toBe('reminder');
expect(response.importantLists[0].source).toBe('fallback');
expect((await dailySnapshotsRepository.find())[0].errorMessage).toBe(
'network down',
);
});
it('keeps the dashboard loadable when weekly suggestions fail', async () => {
listsService.listLists.mockResolvedValue([createList('list-1', 'Einkauf')]);
mockMistralResponses([
{
choices: [
{
message: {
content: '{"lists":[{"listId":"list-1","reason":"Relevant."}]}',
},
},
],
},
]);
jest.mocked(global.fetch).mockRejectedValueOnce(new Error('weekly failed'));
const response = await service.getDashboard('user-1');
expect(response.importantLists).toHaveLength(1);
expect(response.weeklySuggestions.suggestions).toEqual([]);
expect(response.weeklySuggestions.errorMessage).toBe('weekly failed');
});
it('creates a concrete list and items from a stored weekly suggestion', async () => {
const { weekKey } = currentKeys();
const createdList = createList('created-list', 'Meal Prep');
const listWithItem = createList('created-list', 'Meal Prep', {
items: [
{ id: 'created-item', title: 'Rezepte auswaehlen', checked: false },
],
});
listsService.createList.mockResolvedValue(createdList);
listsService.addItem.mockResolvedValue(listWithItem);
await weeklySnapshotsRepository.save({
id: 'weekly-1',
userId: 'user-1',
weekKey,
timezone: 'UTC',
suggestions: [
{
id: 'suggestion-1',
title: 'Meal Prep',
description: 'Vorbereitung fuer die Woche',
items: [{ title: 'Rezepte auswaehlen', required: true }],
reason: 'Ergaenzt Einkaufslisten.',
},
],
requestPayload: {},
responsePayload: null,
errorMessage: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const response = await service.createSuggestionList(
'user-1',
'suggestion-1',
);
expect(listsService.createList).toHaveBeenCalledWith('user-1', {
name: 'Meal Prep',
description: 'Vorbereitung fuer die Woche',
kind: 'custom',
});
expect(listsService.addItem).toHaveBeenCalledWith(
'user-1',
'created-list',
{
title: 'Rezepte auswaehlen',
notes: undefined,
quantity: undefined,
required: true,
},
);
expect(response.list.id).toBe('created-list');
await expect(
service.createSuggestionList('user-1', 'suggestion-1'),
).rejects.toThrow(BadRequestException);
});
});
function createList(
id: string,
name: string,
overrides: Partial<UserList> & {
items?: Array<Partial<UserList['items'][number]>>;
} = {},
): UserList {
const items = (overrides.items ?? []).map((item, index) => ({
id: item.id ?? `${id}-item-${index}`,
title: item.title ?? `Item ${index}`,
required: item.required ?? true,
checked: item.checked ?? false,
position: item.position ?? index,
createdAt: item.createdAt ?? '2026-06-24T00:00:00.000Z',
updatedAt: item.updatedAt ?? '2026-06-24T00:00:00.000Z',
}));
return {
id,
ownerId: overrides.ownerId ?? 'user-1',
accessRole: overrides.accessRole ?? 'owner',
name,
kind: overrides.kind ?? 'custom',
reminderAt: overrides.reminderAt,
items,
collaborators: [],
createdAt: overrides.createdAt ?? '2026-06-24T00:00:00.000Z',
updatedAt: overrides.updatedAt ?? '2026-06-24T00:00:00.000Z',
};
}
function mockMistralResponses(responses: object[]): void {
for (const response of responses) {
jest.mocked(global.fetch).mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => JSON.stringify(response),
} as Response);
}
}
async function saveCurrentWeeklySnapshot(
repository: InMemoryRepository<WeeklyListSuggestionSnapshotEntity>,
userId: string,
): Promise<void> {
const { weekKey } = currentKeys();
await repository.save({
id: `${userId}-weekly`,
userId,
weekKey,
timezone: 'UTC',
suggestions: [],
requestPayload: {},
responsePayload: null,
errorMessage: null,
createdAt: new Date(),
updatedAt: new Date(),
});
}
function currentKeys(): { dateKey: string; weekKey: string } {
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth() + 1;
const dayOfMonth = now.getUTCDate();
const localDate = new Date(Date.UTC(year, month - 1, dayOfMonth));
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 {
dateKey: `${year}-${pad2(month)}-${pad2(dayOfMonth)}`,
weekKey: `${weekYear}-W${pad2(week)}`,
};
}
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
}

View File

@@ -0,0 +1,874 @@
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<T> {
requestPayload: Record<string, unknown>;
responsePayload: unknown;
value: T;
}
class DashboardAiCallError extends Error {
constructor(
message: string,
readonly requestPayload: Record<string, unknown>,
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<DailyDashboardSnapshotEntity>,
@InjectRepository(WeeklyListSuggestionSnapshotEntity)
private readonly weeklySnapshotsRepository: Repository<WeeklyListSuggestionSnapshotEntity>,
private readonly listsService: ListsService,
) {}
async getDashboard(userId: string): Promise<DashboardResponse> {
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<CreateDashboardSuggestionResponse> {
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<DailyDashboardSnapshotEntity> {
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<WeeklyListSuggestionSnapshotEntity> {
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<DashboardAiResult<SelectedListSnapshot[]>> {
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<DashboardAiResult<WeeklyListSuggestionSnapshotSuggestion[]>> {
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<string, unknown>;
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<unknown> {
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<string>();
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<string>();
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<string>();
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<string, unknown> {
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<string, unknown> | null {
if (!content) {
return null;
}
try {
const parsed = JSON.parse(content) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: 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<string, unknown>,
): Record<string, unknown> {
return error instanceof DashboardAiCallError
? error.requestPayload
: fallback;
}
private errorResponsePayload(error: unknown): unknown {
return error instanceof DashboardAiCallError ? error.responsePayload : null;
}
}

View File

@@ -0,0 +1,39 @@
import { UserList } from '../list-templates/list-template.types';
import { WeeklyListSuggestionSnapshotItem } from './weekly-list-suggestion-snapshot.entity';
export interface DashboardImportantList {
list: UserList;
reason: string;
source: 'ai' | 'fallback';
checkedItems: number;
openItems: number;
totalItems: number;
progress: number;
hasReminder: boolean;
}
export interface DashboardWeeklySuggestion {
id: string;
title: string;
description?: string;
items: WeeklyListSuggestionSnapshotItem[];
reason: string;
createdListId?: string;
}
export interface DashboardResponse {
timezone: string;
dateKey: string;
weekKey: string;
importantLists: DashboardImportantList[];
weeklySuggestions: {
suggestions: DashboardWeeklySuggestion[];
errorMessage?: string;
generatedAt?: string;
};
}
export interface CreateDashboardSuggestionResponse {
list: UserList;
suggestion: DashboardWeeklySuggestion;
}

View File

@@ -0,0 +1,68 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
export interface WeeklyListSuggestionSnapshotItem {
title: string;
notes?: string;
quantity?: number;
required: boolean;
}
export interface WeeklyListSuggestionSnapshotSuggestion {
id: string;
title: string;
description?: string;
items: WeeklyListSuggestionSnapshotItem[];
reason: string;
createdListId?: string;
}
@Entity('weekly_list_suggestion_snapshots')
@Index(['userId', 'weekKey'], { unique: true })
export class WeeklyListSuggestionSnapshotEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
userId!: string;
@Column({ type: 'varchar', length: 10 })
weekKey!: string;
@Column({ type: 'varchar', length: 80 })
timezone!: string;
@Column({ type: 'json' })
suggestions!: WeeklyListSuggestionSnapshotSuggestion[];
@Column({ type: 'json' })
requestPayload!: Record<string, unknown>;
@Column({ type: 'json', nullable: true })
responsePayload?: unknown;
@Column({ type: 'text', nullable: true })
errorMessage?: string | null;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@UpdateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
onUpdate: 'CURRENT_TIMESTAMP(3)',
})
updatedAt!: Date;
}

View File

@@ -5,6 +5,8 @@ import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity';
import { AuditLogEntity } from '../audit/audit-log.entity';
import { UserEntity } from '../auth/user.entity';
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
import { DailyDashboardSnapshotEntity } from '../dashboard/daily-dashboard-snapshot.entity';
import { WeeklyListSuggestionSnapshotEntity } from '../dashboard/weekly-list-suggestion-snapshot.entity';
import { ListTemplateEntity } from '../list-templates/list-template.entity';
import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity';
import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity';
@@ -32,6 +34,7 @@ export default new DataSource({
entities: [
AssistantChatLogEntity,
AuditLogEntity,
DailyDashboardSnapshotEntity,
UserEntity,
RefreshTokenEntity,
ListTemplateEntity,
@@ -40,6 +43,7 @@ export default new DataSource({
TemplateSeedEntity,
UserListEntity,
UserListItemEntity,
WeeklyListSuggestionSnapshotEntity,
],
migrations: ['src/database/migrations/*.ts'],
});

View File

@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateDashboardSnapshots1781800000000 implements MigrationInterface {
name = 'CreateDashboardSnapshots1781800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasTable('daily_dashboard_snapshots'))) {
await queryRunner.query(`
CREATE TABLE \`daily_dashboard_snapshots\` (
\`id\` varchar(36) NOT NULL,
\`userId\` varchar(36) NOT NULL,
\`dateKey\` varchar(10) NOT NULL,
\`timezone\` varchar(80) NOT NULL,
\`selectedLists\` json NOT NULL,
\`requestPayload\` json NOT NULL,
\`responsePayload\` json NULL,
\`errorMessage\` text NULL,
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX \`IDX_daily_dashboard_snapshots_user_id\` (\`userId\`),
UNIQUE INDEX \`IDX_daily_dashboard_snapshots_user_date\` (\`userId\`, \`dateKey\`),
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB
`);
}
if (!(await queryRunner.hasTable('weekly_list_suggestion_snapshots'))) {
await queryRunner.query(`
CREATE TABLE \`weekly_list_suggestion_snapshots\` (
\`id\` varchar(36) NOT NULL,
\`userId\` varchar(36) NOT NULL,
\`weekKey\` varchar(10) NOT NULL,
\`timezone\` varchar(80) NOT NULL,
\`suggestions\` json NOT NULL,
\`requestPayload\` json NOT NULL,
\`responsePayload\` json NULL,
\`errorMessage\` text NULL,
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX \`IDX_weekly_list_suggestion_snapshots_user_id\` (\`userId\`),
UNIQUE INDEX \`IDX_weekly_list_suggestion_snapshots_user_week\` (\`userId\`, \`weekKey\`),
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB
`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasTable('weekly_list_suggestion_snapshots')) {
await queryRunner.query('DROP TABLE `weekly_list_suggestion_snapshots`');
}
if (await queryRunner.hasTable('daily_dashboard_snapshots')) {
await queryRunner.query('DROP TABLE `daily_dashboard_snapshots`');
}
}
}