From ba0b34d1307313896f671e46f15c91218d76be77 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 24 Jun 2026 15:16:18 +0200 Subject: [PATCH] dashboard --- listify-api/.env.example | 4 + listify-api/src/app.module.ts | 7 +- .../daily-dashboard-snapshot.entity.ts | 56 ++ .../src/dashboard/dashboard.controller.ts | 42 + listify-api/src/dashboard/dashboard.module.ts | 20 + .../src/dashboard/dashboard.service.spec.ts | 391 ++++++++ .../src/dashboard/dashboard.service.ts | 874 ++++++++++++++++++ listify-api/src/dashboard/dashboard.types.ts | 39 + .../weekly-list-suggestion-snapshot.entity.ts | 68 ++ listify-api/src/database/data-source.ts | 4 + .../1781800000000-CreateDashboardSnapshots.ts | 57 ++ listify-client/src/app/app.html | 21 +- listify-client/src/app/app.routes.ts | 8 +- listify-client/src/app/app.scss | 14 +- .../app/dashboard/dashboard.component.html | 183 ++++ .../app/dashboard/dashboard.component.scss | 142 +++ .../src/app/dashboard/dashboard.component.ts | 105 +++ .../src/app/dashboard/dashboard.models.ts | 45 + .../src/app/dashboard/dashboard.service.ts | 21 + 19 files changed, 2095 insertions(+), 6 deletions(-) create mode 100644 listify-api/src/dashboard/daily-dashboard-snapshot.entity.ts create mode 100644 listify-api/src/dashboard/dashboard.controller.ts create mode 100644 listify-api/src/dashboard/dashboard.module.ts create mode 100644 listify-api/src/dashboard/dashboard.service.spec.ts create mode 100644 listify-api/src/dashboard/dashboard.service.ts create mode 100644 listify-api/src/dashboard/dashboard.types.ts create mode 100644 listify-api/src/dashboard/weekly-list-suggestion-snapshot.entity.ts create mode 100644 listify-api/src/database/migrations/1781800000000-CreateDashboardSnapshots.ts create mode 100644 listify-client/src/app/dashboard/dashboard.component.html create mode 100644 listify-client/src/app/dashboard/dashboard.component.scss create mode 100644 listify-client/src/app/dashboard/dashboard.component.ts create mode 100644 listify-client/src/app/dashboard/dashboard.models.ts create mode 100644 listify-client/src/app/dashboard/dashboard.service.ts diff --git a/listify-api/.env.example b/listify-api/.env.example index fe57fa3..605a999 100644 --- a/listify-api/.env.example +++ b/listify-api/.env.example @@ -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 diff --git a/listify-api/src/app.module.ts b/listify-api/src/app.module.ts index abf782b..1a5bbb9 100644 --- a/listify-api/src/app.module.ts +++ b/listify-api/src/app.module.ts @@ -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('DB_LOGGING', 'false'), - DB_LOG_PARAMETERS: configService.get('DB_LOG_PARAMETERS', 'false'), + DB_LOG_PARAMETERS: configService.get( + 'DB_LOG_PARAMETERS', + 'false', + ), DB_LOG_MAX_PARAM_LENGTH: configService.get( 'DB_LOG_MAX_PARAM_LENGTH', '500', @@ -56,6 +60,7 @@ import { DatabaseLogger } from './database/database.logger'; AssistantModule, AuditModule, AuthModule, + DashboardModule, MailModule, ListsModule, ListTemplatesModule, diff --git a/listify-api/src/dashboard/daily-dashboard-snapshot.entity.ts b/listify-api/src/dashboard/daily-dashboard-snapshot.entity.ts new file mode 100644 index 0000000..243a775 --- /dev/null +++ b/listify-api/src/dashboard/daily-dashboard-snapshot.entity.ts @@ -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; + + @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; +} diff --git a/listify-api/src/dashboard/dashboard.controller.ts b/listify-api/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..beeced0 --- /dev/null +++ b/listify-api/src/dashboard/dashboard.controller.ts @@ -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; + } +} diff --git a/listify-api/src/dashboard/dashboard.module.ts b/listify-api/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..0507a89 --- /dev/null +++ b/listify-api/src/dashboard/dashboard.module.ts @@ -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 {} diff --git a/listify-api/src/dashboard/dashboard.service.spec.ts b/listify-api/src/dashboard/dashboard.service.spec.ts new file mode 100644 index 0000000..4c1619d --- /dev/null +++ b/listify-api/src/dashboard/dashboard.service.spec.ts @@ -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; + let weeklySnapshotsRepository: InMemoryRepository; + 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(); + weeklySnapshotsRepository = + new InMemoryRepository(); + 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 & { + items?: Array>; + } = {}, +): 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, + userId: string, +): Promise { + 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; +} diff --git a/listify-api/src/dashboard/dashboard.service.ts b/listify-api/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..a13ada1 --- /dev/null +++ b/listify-api/src/dashboard/dashboard.service.ts @@ -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 { + 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; + } +} diff --git a/listify-api/src/dashboard/dashboard.types.ts b/listify-api/src/dashboard/dashboard.types.ts new file mode 100644 index 0000000..7f39608 --- /dev/null +++ b/listify-api/src/dashboard/dashboard.types.ts @@ -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; +} diff --git a/listify-api/src/dashboard/weekly-list-suggestion-snapshot.entity.ts b/listify-api/src/dashboard/weekly-list-suggestion-snapshot.entity.ts new file mode 100644 index 0000000..9185707 --- /dev/null +++ b/listify-api/src/dashboard/weekly-list-suggestion-snapshot.entity.ts @@ -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; + + @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; +} diff --git a/listify-api/src/database/data-source.ts b/listify-api/src/database/data-source.ts index 85b8d87..100d582 100644 --- a/listify-api/src/database/data-source.ts +++ b/listify-api/src/database/data-source.ts @@ -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'], }); diff --git a/listify-api/src/database/migrations/1781800000000-CreateDashboardSnapshots.ts b/listify-api/src/database/migrations/1781800000000-CreateDashboardSnapshots.ts new file mode 100644 index 0000000..071e92a --- /dev/null +++ b/listify-api/src/database/migrations/1781800000000-CreateDashboardSnapshots.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateDashboardSnapshots1781800000000 implements MigrationInterface { + name = 'CreateDashboardSnapshots1781800000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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`'); + } + } +} diff --git a/listify-client/src/app/app.html b/listify-client/src/app/app.html index d662399..5fe3999 100644 --- a/listify-client/src/app/app.html +++ b/listify-client/src/app/app.html @@ -13,7 +13,7 @@ @@ -64,6 +64,16 @@ >