From 846edad2a4ff5edb201bc23db7371dd7581523d0 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:34:16 -0500 Subject: [PATCH] Add AI chat with Ollama tool calling for financial queries Conversational AI chat that uses Ollama's native tool-calling API to query transactions, spending reports, account balances, dashboard summaries, and update transaction categories through natural language. Includes persistent conversation history, a full-page chat UI with sidebar, and rich inline rendering of tool results (tables, alerts, cards). Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/layout/AppLayout.vue | 1 + frontend/src/router/index.ts | 1 + frontend/src/services/chat.ts | 16 + frontend/src/types/index.ts | 30 + frontend/src/views/chat/ChatToolResult.vue | 31 + .../src/views/chat/ChatToolResultItem.vue | 139 ++ frontend/src/views/chat/ChatView.vue | 267 +++ src/Purrse.Api/Controllers/ChatController.cs | 40 + src/Purrse.Api/Program.cs | 1 + src/Purrse.Api/Services/ChatService.cs | 756 ++++++++ src/Purrse.Core/DTOs/ChatDtos.cs | 143 ++ .../Interfaces/Services/IChatService.cs | 11 + src/Purrse.Core/Models/ChatConversation.cs | 12 + src/Purrse.Core/Models/ChatMessage.cs | 13 + .../ChatConversationConfiguration.cs | 22 + .../ChatMessageConfiguration.cs | 23 + .../20260210043006_AddAiChat.Designer.cs | 1563 +++++++++++++++++ .../Migrations/20260210043006_AddAiChat.cs | 79 + .../PurrseDbContextModelSnapshot.cs | 87 + src/Purrse.Data/PurrseDbContext.cs | 2 + 20 files changed, 3237 insertions(+) create mode 100644 frontend/src/services/chat.ts create mode 100644 frontend/src/views/chat/ChatToolResult.vue create mode 100644 frontend/src/views/chat/ChatToolResultItem.vue create mode 100644 frontend/src/views/chat/ChatView.vue create mode 100644 src/Purrse.Api/Controllers/ChatController.cs create mode 100644 src/Purrse.Api/Services/ChatService.cs create mode 100644 src/Purrse.Core/DTOs/ChatDtos.cs create mode 100644 src/Purrse.Core/Interfaces/Services/IChatService.cs create mode 100644 src/Purrse.Core/Models/ChatConversation.cs create mode 100644 src/Purrse.Core/Models/ChatMessage.cs create mode 100644 src/Purrse.Data/Configurations/ChatConversationConfiguration.cs create mode 100644 src/Purrse.Data/Configurations/ChatMessageConfiguration.cs create mode 100644 src/Purrse.Data/Migrations/20260210043006_AddAiChat.Designer.cs create mode 100644 src/Purrse.Data/Migrations/20260210043006_AddAiChat.cs diff --git a/frontend/src/components/layout/AppLayout.vue b/frontend/src/components/layout/AppLayout.vue index 56047ff..b54ae9f 100644 --- a/frontend/src/components/layout/AppLayout.vue +++ b/frontend/src/components/layout/AppLayout.vue @@ -150,6 +150,7 @@ const navItems = [ { title: 'Loans', icon: 'mdi-home-city', route: '/loans' }, { title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' }, { title: 'Investments', icon: 'mdi-finance', route: '/investments' }, + { title: 'AI Chat', icon: 'mdi-robot-outline', route: '/chat' }, { title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' }, { title: 'Settings', icon: 'mdi-cog', route: '/settings' }, ] diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index f2afd5a..1e8e0f0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -17,6 +17,7 @@ const routes = [ { path: '/loans/:id?', name: 'loans', component: () => import('@/views/loans/LoansView.vue'), meta: { auth: true }, props: true }, { path: '/reports', name: 'reports', component: () => import('@/views/reports/ReportsView.vue'), meta: { auth: true } }, { path: '/investments', name: 'investments', component: () => import('@/views/investments/InvestmentsView.vue'), meta: { auth: true } }, + { path: '/chat', name: 'chat', component: () => import('@/views/chat/ChatView.vue'), meta: { auth: true } }, { path: '/plugins', name: 'plugins', component: () => import('@/views/plugins/PluginsView.vue'), meta: { auth: true } }, { path: '/settings', name: 'settings', component: () => import('@/views/settings/SettingsView.vue'), meta: { auth: true } }, ] diff --git a/frontend/src/services/chat.ts b/frontend/src/services/chat.ts new file mode 100644 index 0000000..6545bfd --- /dev/null +++ b/frontend/src/services/chat.ts @@ -0,0 +1,16 @@ +import api from './api' +import type { ChatConversation, ChatConversationDetail, SendChatMessageResponse } from '@/types' + +export const chatApi = { + sendMessage: (data: { conversationId?: string; message: string }) => + api.post('/chat/send', data), + + getConversations: () => + api.get('/chat/conversations'), + + getConversation: (id: string) => + api.get(`/chat/conversations/${id}`), + + deleteConversation: (id: string) => + api.delete(`/chat/conversations/${id}`), +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 60c2dd1..d32670f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -528,6 +528,36 @@ export interface ClassifyUncategorizedResult { skipped: number } +// AI Chat +export interface ChatConversation { + id: string + title: string | null + createdAt: string + updatedAt: string +} + +export interface ChatMessage { + id: string + role: 'user' | 'assistant' | 'tool' + content: string + toolResultType: string | null + toolResultJson: string | null + createdAt: string +} + +export interface ChatConversationDetail { + id: string + title: string | null + messages: ChatMessage[] + createdAt: string + updatedAt: string +} + +export interface SendChatMessageResponse { + conversationId: string + assistantMessage: ChatMessage +} + // Split form helper export interface SplitFormItem { categoryId: string | null diff --git a/frontend/src/views/chat/ChatToolResult.vue b/frontend/src/views/chat/ChatToolResult.vue new file mode 100644 index 0000000..2609a4c --- /dev/null +++ b/frontend/src/views/chat/ChatToolResult.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/views/chat/ChatToolResultItem.vue b/frontend/src/views/chat/ChatToolResultItem.vue new file mode 100644 index 0000000..dc5b2bb --- /dev/null +++ b/frontend/src/views/chat/ChatToolResultItem.vue @@ -0,0 +1,139 @@ + + + diff --git a/frontend/src/views/chat/ChatView.vue b/frontend/src/views/chat/ChatView.vue new file mode 100644 index 0000000..7976826 --- /dev/null +++ b/frontend/src/views/chat/ChatView.vue @@ -0,0 +1,267 @@ + + + diff --git a/src/Purrse.Api/Controllers/ChatController.cs b/src/Purrse.Api/Controllers/ChatController.cs new file mode 100644 index 0000000..31c014e --- /dev/null +++ b/src/Purrse.Api/Controllers/ChatController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Purrse.Core.DTOs; +using Purrse.Core.Interfaces.Services; +using System.Security.Claims; + +namespace Purrse.Api.Controllers; + +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class ChatController : ControllerBase +{ + private readonly IChatService _chatService; + private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + public ChatController(IChatService chatService) => _chatService = chatService; + + [HttpPost("send")] + public async Task> SendMessage(SendChatMessageRequest request) + => Ok(await _chatService.SendMessageAsync(UserId, request)); + + [HttpGet("conversations")] + public async Task>> GetConversations() + => Ok(await _chatService.GetConversationsAsync(UserId)); + + [HttpGet("conversations/{id}")] + public async Task> GetConversation(Guid id) + { + var conversation = await _chatService.GetConversationAsync(UserId, id); + return conversation == null ? NotFound() : Ok(conversation); + } + + [HttpDelete("conversations/{id}")] + public async Task DeleteConversation(Guid id) + { + await _chatService.DeleteConversationAsync(UserId, id); + return NoContent(); + } +} diff --git a/src/Purrse.Api/Program.cs b/src/Purrse.Api/Program.cs index 764f17e..a04a36c 100644 --- a/src/Purrse.Api/Program.cs +++ b/src/Purrse.Api/Program.cs @@ -80,6 +80,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient("Ollama"); // Built-in file parsers diff --git a/src/Purrse.Api/Services/ChatService.cs b/src/Purrse.Api/Services/ChatService.cs new file mode 100644 index 0000000..ee64b93 --- /dev/null +++ b/src/Purrse.Api/Services/ChatService.cs @@ -0,0 +1,756 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Purrse.Core.DTOs; +using Purrse.Core.Interfaces.Services; +using Purrse.Core.Models; +using Purrse.Data; + +namespace Purrse.Api.Services; + +public class ChatService : IChatService +{ + private readonly PurrseDbContext _db; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ITransactionService _transactionService; + private readonly ICategoryService _categoryService; + private readonly IAccountService _accountService; + private readonly IReportService _reportService; + private readonly IDashboardService _dashboardService; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions OllamaJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private static readonly JsonSerializerOptions CamelCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private const string SystemPromptTemplate = + """ + You are a helpful personal finance assistant for Purrse. + You can search transactions, view spending reports, check account balances, and update transaction categories. + When the user asks about their finances, use the available tools to look up real data. + Always present financial amounts formatted as currency. + When showing transactions, include the date, payee, amount, and category. + Today's date is {0}. + """; + + public ChatService( + PurrseDbContext db, + IHttpClientFactory httpClientFactory, + ITransactionService transactionService, + ICategoryService categoryService, + IAccountService accountService, + IReportService reportService, + IDashboardService dashboardService, + ILogger logger) + { + _db = db; + _httpClientFactory = httpClientFactory; + _transactionService = transactionService; + _categoryService = categoryService; + _accountService = accountService; + _reportService = reportService; + _dashboardService = dashboardService; + _logger = logger; + } + + public async Task SendMessageAsync(Guid userId, SendChatMessageRequest request) + { + // Get or create conversation + ChatConversation conversation; + if (request.ConversationId.HasValue) + { + conversation = await _db.ChatConversations + .Include(c => c.Messages.OrderBy(m => m.CreatedAt)) + .FirstOrDefaultAsync(c => c.Id == request.ConversationId.Value && c.UserId == userId) + ?? throw new KeyNotFoundException("Conversation not found"); + } + else + { + conversation = new ChatConversation + { + Id = Guid.NewGuid(), + UserId = userId, + Title = request.Message.Length > 50 ? request.Message[..50] + "..." : request.Message, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + _db.ChatConversations.Add(conversation); + await _db.SaveChangesAsync(); + + // Reload with empty messages collection + conversation.Messages = new List(); + } + + // Load AI settings + var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId); + var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434"; + var modelName = settings?.ModelName ?? "llama3.1:8b"; + + // Save user message + var userMessage = new ChatMessage + { + Id = Guid.NewGuid(), + ConversationId = conversation.Id, + Role = "user", + Content = request.Message, + CreatedAt = DateTime.UtcNow + }; + _db.ChatMessages.Add(userMessage); + await _db.SaveChangesAsync(); + + // Build Ollama message list + var ollamaMessages = new List(); + + // System prompt + ollamaMessages.Add(new OllamaToolMessage + { + Role = "system", + Content = string.Format(SystemPromptTemplate, DateTime.UtcNow.ToString("yyyy-MM-dd")) + }); + + // Last 20 conversation messages + var recentMessages = conversation.Messages + .OrderBy(m => m.CreatedAt) + .TakeLast(20) + .ToList(); + + foreach (var msg in recentMessages) + { + var ollamaMsg = new OllamaToolMessage { Role = msg.Role, Content = msg.Content }; + if (msg.ToolCallsJson != null) + { + ollamaMsg.ToolCalls = JsonSerializer.Deserialize>(msg.ToolCallsJson, OllamaJsonOptions); + } + ollamaMessages.Add(ollamaMsg); + } + + // Add current user message + ollamaMessages.Add(new OllamaToolMessage { Role = "user", Content = request.Message }); + + // Build tool definitions + var tools = BuildToolDefinitions(); + + // Collect tool results from this turn + var toolResults = new List<(string type, string json)>(); + + // Tool-call loop (max 5 iterations) + ChatMessage? assistantDbMessage = null; + for (int i = 0; i < 5; i++) + { + var ollamaRequest = new OllamaToolChatRequest + { + Model = modelName, + Messages = ollamaMessages, + Stream = false, + Tools = tools + }; + + var response = await CallOllamaAsync(ollamaUrl, ollamaRequest); + + if (response?.Message?.ToolCalls != null && response.Message.ToolCalls.Count > 0) + { + // Save assistant message with tool calls + var assistantToolMsg = new ChatMessage + { + Id = Guid.NewGuid(), + ConversationId = conversation.Id, + Role = "assistant", + Content = response.Message.Content ?? string.Empty, + ToolCallsJson = JsonSerializer.Serialize(response.Message.ToolCalls, OllamaJsonOptions), + CreatedAt = DateTime.UtcNow + }; + _db.ChatMessages.Add(assistantToolMsg); + + // Add assistant message to Ollama context + ollamaMessages.Add(new OllamaToolMessage + { + Role = "assistant", + Content = response.Message.Content ?? string.Empty, + ToolCalls = response.Message.ToolCalls + }); + + // Execute each tool call + foreach (var toolCall in response.Message.ToolCalls) + { + var (toolResultType, data, summaryForLlm) = await ExecuteToolAsync(userId, toolCall); + + var toolResultJsonObj = JsonSerializer.Serialize(new { type = toolResultType, data }, CamelCaseOptions); + toolResults.Add((toolResultType, toolResultJsonObj)); + + // Save tool message + var toolMsg = new ChatMessage + { + Id = Guid.NewGuid(), + ConversationId = conversation.Id, + Role = "tool", + Content = summaryForLlm, + ToolResultJson = toolResultJsonObj, + CreatedAt = DateTime.UtcNow + }; + _db.ChatMessages.Add(toolMsg); + + // Add tool result to Ollama context + ollamaMessages.Add(new OllamaToolMessage + { + Role = "tool", + Content = summaryForLlm + }); + } + + await _db.SaveChangesAsync(); + } + else + { + // No tool calls - save final assistant message + var content = response?.Message?.Content ?? "I'm sorry, I couldn't process that request."; + assistantDbMessage = new ChatMessage + { + Id = Guid.NewGuid(), + ConversationId = conversation.Id, + Role = "assistant", + Content = content, + CreatedAt = DateTime.UtcNow + }; + + // Aggregate tool results onto the final assistant message + if (toolResults.Count > 0) + { + assistantDbMessage.ToolResultJson = toolResults.Count == 1 + ? toolResults[0].json + : JsonSerializer.Serialize(toolResults.Select(r => JsonSerializer.Deserialize(r.json)).ToList(), CamelCaseOptions); + } + + _db.ChatMessages.Add(assistantDbMessage); + await _db.SaveChangesAsync(); + break; + } + } + + // If we exhausted the loop without a final message, create one + if (assistantDbMessage == null) + { + assistantDbMessage = new ChatMessage + { + Id = Guid.NewGuid(), + ConversationId = conversation.Id, + Role = "assistant", + Content = "I've gathered the information. Let me know if you need anything else.", + CreatedAt = DateTime.UtcNow + }; + if (toolResults.Count > 0) + { + assistantDbMessage.ToolResultJson = toolResults.Count == 1 + ? toolResults[0].json + : JsonSerializer.Serialize(toolResults.Select(r => JsonSerializer.Deserialize(r.json)).ToList(), CamelCaseOptions); + } + _db.ChatMessages.Add(assistantDbMessage); + await _db.SaveChangesAsync(); + } + + // Update conversation timestamp + conversation.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + + // Determine tool result type for the response + string? resultType = null; + string? resultJson = null; + if (toolResults.Count > 0) + { + resultType = toolResults.Count == 1 ? toolResults[0].type : "multiple"; + resultJson = assistantDbMessage.ToolResultJson; + } + + return new SendChatMessageResponse( + conversation.Id, + new ChatMessageResponse( + assistantDbMessage.Id, + assistantDbMessage.Role, + assistantDbMessage.Content, + resultType, + resultJson, + assistantDbMessage.CreatedAt + ) + ); + } + + public async Task> GetConversationsAsync(Guid userId) + { + return await _db.ChatConversations + .Where(c => c.UserId == userId) + .OrderByDescending(c => c.UpdatedAt) + .Select(c => new ChatConversationResponse(c.Id, c.Title, c.CreatedAt, c.UpdatedAt)) + .ToListAsync(); + } + + public async Task GetConversationAsync(Guid userId, Guid conversationId) + { + var conversation = await _db.ChatConversations + .Include(c => c.Messages.OrderBy(m => m.CreatedAt)) + .FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId); + + if (conversation == null) return null; + + // Return only user and assistant messages + // Aggregate tool results from preceding tool messages onto assistant messages + var messages = new List(); + var pendingToolResults = new List<(string type, string json)>(); + + foreach (var msg in conversation.Messages.OrderBy(m => m.CreatedAt)) + { + if (msg.Role == "tool") + { + if (msg.ToolResultJson != null) + { + try + { + var parsed = JsonSerializer.Deserialize(msg.ToolResultJson); + var type = parsed.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown"; + pendingToolResults.Add((type, msg.ToolResultJson)); + } + catch + { + pendingToolResults.Add(("unknown", msg.ToolResultJson)); + } + } + continue; + } + + if (msg.Role == "assistant" && msg.ToolCallsJson != null && msg.Content == string.Empty) + { + // Skip intermediate assistant messages that only contain tool calls + continue; + } + + string? toolResultType = null; + string? toolResultJson = null; + + if (msg.Role == "assistant" && pendingToolResults.Count > 0) + { + toolResultType = pendingToolResults.Count == 1 ? pendingToolResults[0].type : "multiple"; + toolResultJson = pendingToolResults.Count == 1 + ? pendingToolResults[0].json + : JsonSerializer.Serialize(pendingToolResults.Select(r => JsonSerializer.Deserialize(r.json)).ToList(), CamelCaseOptions); + pendingToolResults.Clear(); + } + else if (msg.Role == "assistant" && msg.ToolResultJson != null) + { + // Use stored tool result if present + try + { + var parsed = JsonSerializer.Deserialize(msg.ToolResultJson); + if (parsed.ValueKind == JsonValueKind.Array) + { + toolResultType = "multiple"; + } + else + { + toolResultType = parsed.TryGetProperty("type", out var t) ? t.GetString() : "unknown"; + } + toolResultJson = msg.ToolResultJson; + } + catch + { + toolResultJson = msg.ToolResultJson; + } + } + + messages.Add(new ChatMessageResponse( + msg.Id, + msg.Role, + msg.Content, + toolResultType, + toolResultJson, + msg.CreatedAt + )); + } + + return new ChatConversationDetailResponse( + conversation.Id, + conversation.Title, + messages, + conversation.CreatedAt, + conversation.UpdatedAt + ); + } + + public async Task DeleteConversationAsync(Guid userId, Guid conversationId) + { + var conversation = await _db.ChatConversations + .FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId) + ?? throw new KeyNotFoundException("Conversation not found"); + + _db.ChatConversations.Remove(conversation); + await _db.SaveChangesAsync(); + } + + private async Task CallOllamaAsync(string ollamaUrl, OllamaToolChatRequest request) + { + var client = _httpClientFactory.CreateClient("Ollama"); + client.Timeout = TimeSpan.FromMinutes(3); + + var json = JsonSerializer.Serialize(request, OllamaJsonOptions); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + _logger.LogDebug("Sending chat request to Ollama: {Url}", $"{ollamaUrl}/api/chat"); + + var response = await client.PostAsync($"{ollamaUrl}/api/chat", content); + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody, OllamaJsonOptions); + } + + private async Task<(string toolResultType, object data, string summaryForLlm)> ExecuteToolAsync(Guid userId, OllamaToolCall toolCall) + { + var name = toolCall.Function.Name; + var args = toolCall.Function.Arguments; + + _logger.LogInformation("Executing tool: {ToolName} with args: {Args}", name, JsonSerializer.Serialize(args)); + + try + { + return name switch + { + "search_transactions" => await ExecuteSearchTransactionsAsync(userId, args), + "get_spending_by_category" => await ExecuteGetSpendingByCategoryAsync(userId, args), + "get_income_vs_expense" => await ExecuteGetIncomeVsExpenseAsync(userId, args), + "get_account_balances" => await ExecuteGetAccountBalancesAsync(userId, args), + "get_dashboard_summary" => await ExecuteGetDashboardSummaryAsync(userId), + "update_transaction_category" => await ExecuteUpdateTransactionCategoryAsync(userId, args), + _ => ("error", new { error = $"Unknown tool: {name}" }, $"Error: Unknown tool '{name}'") + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Tool execution failed: {ToolName}", name); + return ("error", new { error = ex.Message }, $"Error executing {name}: {ex.Message}"); + } + } + + private async Task<(string, object, string)> ExecuteSearchTransactionsAsync(Guid userId, Dictionary args) + { + Guid? categoryId = null; + var categoryName = ParseStringArg(args, "category_name"); + if (categoryName != null) + { + var categories = await _categoryService.GetAllAsync(userId); + var match = categories.FirstOrDefault(c => c.Name.Equals(categoryName, StringComparison.OrdinalIgnoreCase)); + if (match != null) categoryId = match.Id; + } + + var searchRequest = new TransactionSearchRequest( + AccountId: null, + StartDate: ParseDateArg(args, "start_date"), + EndDate: ParseDateArg(args, "end_date"), + MinAmount: ParseDecimalArg(args, "min_amount"), + MaxAmount: ParseDecimalArg(args, "max_amount"), + PayeeName: ParseStringArg(args, "payee_name"), + CategoryId: categoryId, + Status: null, + SearchText: ParseStringArg(args, "search_text"), + Page: 1, + PageSize: Math.Min(ParseIntArg(args, "page_size") ?? 25, 25) + ); + + var result = await _transactionService.SearchAsync(userId, searchRequest); + + var summary = new StringBuilder(); + summary.AppendLine($"Found {result.TotalCount} transaction(s)."); + foreach (var txn in result.Items.Take(25)) + { + summary.AppendLine($"- {txn.Date:yyyy-MM-dd} | {txn.PayeeName ?? "Unknown"} | {txn.Amount:C} | {txn.CategoryName ?? "Uncategorized"} (ID: {txn.Id})"); + } + + return ("transactions", result, summary.ToString()); + } + + private async Task<(string, object, string)> ExecuteGetSpendingByCategoryAsync(Guid userId, Dictionary args) + { + var startDate = ParseDateArg(args, "start_date") ?? new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1); + var endDate = ParseDateArg(args, "end_date") ?? DateTime.UtcNow; + + var report = await _reportService.GetSpendingByCategoryAsync(userId, startDate, endDate); + + var summary = new StringBuilder(); + summary.AppendLine($"Spending from {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}: Total {report.TotalSpending:C}"); + foreach (var cat in report.Categories.Take(15)) + { + summary.AppendLine($"- {cat.CategoryName}: {cat.Amount:C} ({cat.Percentage:F1}%)"); + } + + return ("spending_report", report, summary.ToString()); + } + + private async Task<(string, object, string)> ExecuteGetIncomeVsExpenseAsync(Guid userId, Dictionary args) + { + var startDate = ParseDateArg(args, "start_date") ?? DateTime.UtcNow.AddMonths(-6); + var endDate = ParseDateArg(args, "end_date") ?? DateTime.UtcNow; + + var report = await _reportService.GetIncomeVsExpenseAsync(userId, startDate, endDate); + + var summary = new StringBuilder(); + summary.AppendLine($"Income vs Expenses from {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}:"); + foreach (var month in report.Months) + { + summary.AppendLine($"- {month.Year}-{month.Month:D2}: Income {month.Income:C}, Expenses {month.Expenses:C}, Net {month.Net:C}"); + } + + return ("income_expense", report, summary.ToString()); + } + + private async Task<(string, object, string)> ExecuteGetAccountBalancesAsync(Guid userId, Dictionary args) + { + var accounts = await _accountService.GetAllAsync(userId); + + var summary = new StringBuilder(); + summary.AppendLine($"You have {accounts.Count} account(s):"); + foreach (var acct in accounts) + { + summary.AppendLine($"- {acct.Name} ({acct.Type}): {acct.Balance:C}"); + } + + return ("accounts", accounts, summary.ToString()); + } + + private async Task<(string, object, string)> ExecuteGetDashboardSummaryAsync(Guid userId) + { + var dashboard = await _dashboardService.GetDashboardAsync(userId); + + var summary = new StringBuilder(); + summary.AppendLine($"Net Worth: {dashboard.NetWorth:C} (Assets: {dashboard.TotalAssets:C}, Liabilities: {dashboard.TotalLiabilities:C})"); + summary.AppendLine($"Spending this month: {dashboard.SpendingSummary.ThisMonth:C} (last month: {dashboard.SpendingSummary.LastMonth:C})"); + if (dashboard.UpcomingBills.Count > 0) + { + summary.AppendLine("Upcoming bills:"); + foreach (var bill in dashboard.UpcomingBills.Take(5)) + { + summary.AppendLine($"- {bill.PayeeName}: {bill.Amount:C} due {bill.DueDate:yyyy-MM-dd}"); + } + } + + return ("dashboard", dashboard, summary.ToString()); + } + + private async Task<(string, object, string)> ExecuteUpdateTransactionCategoryAsync(Guid userId, Dictionary args) + { + var transactionId = ParseGuidArg(args, "transaction_id") + ?? throw new ArgumentException("transaction_id is required"); + + var categoryName = ParseStringArg(args, "category_name") + ?? throw new ArgumentException("category_name is required"); + + // Resolve category by name + var categories = await _categoryService.GetAllAsync(userId); + var category = categories.FirstOrDefault(c => c.Name.Equals(categoryName, StringComparison.OrdinalIgnoreCase)) + ?? throw new ArgumentException($"Category '{categoryName}' not found"); + + // Get existing transaction + var existing = await _transactionService.GetByIdAsync(userId, transactionId) + ?? throw new KeyNotFoundException($"Transaction not found"); + + // Build update request preserving all fields + var updateRequest = new UpdateTransactionRequest( + Date: existing.Date, + Amount: existing.Amount, + PayeeName: existing.PayeeName, + PayeeId: existing.PayeeId, + CategoryId: category.Id, + Memo: existing.Memo, + ReferenceNumber: existing.ReferenceNumber, + CheckNumber: existing.CheckNumber, + Type: existing.Type, + Status: existing.Status, + Splits: null + ); + + var updated = await _transactionService.UpdateAsync(userId, transactionId, updateRequest); + + var result = new + { + transactionId = updated.Id, + payeeName = updated.PayeeName, + oldCategory = existing.CategoryName ?? "Uncategorized", + newCategory = category.Name, + amount = updated.Amount, + date = updated.Date + }; + + return ("category_update", result, + $"Updated transaction '{updated.PayeeName ?? "Unknown"}' ({updated.Date:yyyy-MM-dd}, {updated.Amount:C}) category from '{existing.CategoryName ?? "Uncategorized"}' to '{category.Name}'."); + } + + private static List BuildToolDefinitions() + { + return new List + { + new() + { + Type = "function", + Function = new OllamaFunction + { + Name = "search_transactions", + Description = "Search for transactions by date range, payee, category, amount, or text. Returns matching transactions.", + Parameters = new OllamaFunctionParameters + { + Type = "object", + Properties = new Dictionary + { + ["start_date"] = new() { Type = "string", Description = "Start date in YYYY-MM-DD format" }, + ["end_date"] = new() { Type = "string", Description = "End date in YYYY-MM-DD format" }, + ["payee_name"] = new() { Type = "string", Description = "Payee name to filter by (partial match)" }, + ["category_name"] = new() { Type = "string", Description = "Category name to filter by (exact match)" }, + ["min_amount"] = new() { Type = "number", Description = "Minimum transaction amount" }, + ["max_amount"] = new() { Type = "number", Description = "Maximum transaction amount" }, + ["search_text"] = new() { Type = "string", Description = "General text search across payee, memo, etc." }, + ["page_size"] = new() { Type = "integer", Description = "Number of results to return (max 25, default 25)" } + } + } + } + }, + new() + { + Type = "function", + Function = new OllamaFunction + { + Name = "get_spending_by_category", + Description = "Get a spending breakdown by category for a date range. Shows how much was spent in each category.", + Parameters = new OllamaFunctionParameters + { + Type = "object", + Properties = new Dictionary + { + ["start_date"] = new() { Type = "string", Description = "Start date in YYYY-MM-DD format" }, + ["end_date"] = new() { Type = "string", Description = "End date in YYYY-MM-DD format" } + }, + Required = new List { "start_date", "end_date" } + } + } + }, + new() + { + Type = "function", + Function = new OllamaFunction + { + Name = "get_income_vs_expense", + Description = "Get monthly income vs expense comparison for a date range. Shows income, expenses, and net for each month.", + Parameters = new OllamaFunctionParameters + { + Type = "object", + Properties = new Dictionary + { + ["start_date"] = new() { Type = "string", Description = "Start date in YYYY-MM-DD format" }, + ["end_date"] = new() { Type = "string", Description = "End date in YYYY-MM-DD format" } + }, + Required = new List { "start_date", "end_date" } + } + } + }, + new() + { + Type = "function", + Function = new OllamaFunction + { + Name = "get_account_balances", + Description = "Get all accounts with their current balances, types, and status.", + Parameters = new OllamaFunctionParameters + { + Type = "object", + Properties = new Dictionary() + } + } + }, + new() + { + Type = "function", + Function = new OllamaFunction + { + Name = "get_dashboard_summary", + Description = "Get a dashboard overview including net worth, assets, liabilities, recent transactions, top spending categories, and upcoming bills.", + Parameters = new OllamaFunctionParameters + { + Type = "object", + Properties = new Dictionary() + } + } + }, + new() + { + Type = "function", + Function = new OllamaFunction + { + Name = "update_transaction_category", + Description = "Update the category of a specific transaction. Use the transaction ID from search results.", + Parameters = new OllamaFunctionParameters + { + Type = "object", + Properties = new Dictionary + { + ["transaction_id"] = new() { Type = "string", Description = "The transaction ID (GUID) to update" }, + ["category_name"] = new() { Type = "string", Description = "The new category name to assign" } + }, + Required = new List { "transaction_id", "category_name" } + } + } + } + }; + } + + // Argument parsing helpers + private static string? ParseStringArg(Dictionary args, string key) + { + if (!args.TryGetValue(key, out var value)) return null; + if (value is JsonElement el) return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString(); + return value?.ToString(); + } + + private static DateTime? ParseDateArg(Dictionary args, string key) + { + var str = ParseStringArg(args, key); + if (str == null) return null; + return DateTime.TryParse(str, out var dt) ? dt : null; + } + + private static Guid? ParseGuidArg(Dictionary args, string key) + { + var str = ParseStringArg(args, key); + if (str == null) return null; + return Guid.TryParse(str, out var guid) ? guid : null; + } + + private static decimal? ParseDecimalArg(Dictionary args, string key) + { + if (!args.TryGetValue(key, out var value)) return null; + if (value is JsonElement el) + { + return el.ValueKind switch + { + JsonValueKind.Number => el.GetDecimal(), + JsonValueKind.String => decimal.TryParse(el.GetString(), out var d) ? d : null, + _ => null + }; + } + return decimal.TryParse(value?.ToString(), out var dec) ? dec : null; + } + + private static int? ParseIntArg(Dictionary args, string key) + { + if (!args.TryGetValue(key, out var value)) return null; + if (value is JsonElement el) + { + return el.ValueKind switch + { + JsonValueKind.Number => el.GetInt32(), + JsonValueKind.String => int.TryParse(el.GetString(), out var i) ? i : null, + _ => null + }; + } + return int.TryParse(value?.ToString(), out var result) ? result : null; + } +} diff --git a/src/Purrse.Core/DTOs/ChatDtos.cs b/src/Purrse.Core/DTOs/ChatDtos.cs new file mode 100644 index 0000000..d9a0911 --- /dev/null +++ b/src/Purrse.Core/DTOs/ChatDtos.cs @@ -0,0 +1,143 @@ +using System.Text.Json.Serialization; + +namespace Purrse.Core.DTOs; + +// API DTOs +public record SendChatMessageRequest(Guid? ConversationId, string Message); + +public record SendChatMessageResponse(Guid ConversationId, ChatMessageResponse AssistantMessage); + +public record ChatMessageResponse( + Guid Id, + string Role, + string Content, + string? ToolResultType, + string? ToolResultJson, + DateTime CreatedAt +); + +public record ChatConversationResponse( + Guid Id, + string? Title, + DateTime CreatedAt, + DateTime UpdatedAt +); + +public record ChatConversationDetailResponse( + Guid Id, + string? Title, + List Messages, + DateTime CreatedAt, + DateTime UpdatedAt +); + +// Ollama tool-calling DTOs (separate from existing OllamaChatRequest to avoid breaking categorization) +public class OllamaToolChatRequest +{ + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("messages")] + public List Messages { get; set; } = new(); + + [JsonPropertyName("stream")] + public bool Stream { get; set; } + + [JsonPropertyName("tools")] + public List? Tools { get; set; } +} + +public class OllamaToolMessage +{ + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + + [JsonPropertyName("tool_calls")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ToolCalls { get; set; } +} + +public class OllamaTool +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "function"; + + [JsonPropertyName("function")] + public OllamaFunction Function { get; set; } = new(); +} + +public class OllamaFunction +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("parameters")] + public OllamaFunctionParameters Parameters { get; set; } = new(); +} + +public class OllamaFunctionParameters +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = new(); + + [JsonPropertyName("required")] + public List? Required { get; set; } +} + +public class OllamaPropertySchema +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "string"; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("enum")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Enum { get; set; } +} + +public class OllamaToolCall +{ + [JsonPropertyName("function")] + public OllamaToolCallFunction Function { get; set; } = new(); +} + +public class OllamaToolCallFunction +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("arguments")] + public Dictionary Arguments { get; set; } = new(); +} + +public class OllamaToolChatResponse +{ + [JsonPropertyName("message")] + public OllamaToolResponseMessage? Message { get; set; } + + [JsonPropertyName("done")] + public bool Done { get; set; } +} + +public class OllamaToolResponseMessage +{ + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + + [JsonPropertyName("tool_calls")] + public List? ToolCalls { get; set; } +} diff --git a/src/Purrse.Core/Interfaces/Services/IChatService.cs b/src/Purrse.Core/Interfaces/Services/IChatService.cs new file mode 100644 index 0000000..4104ec8 --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IChatService.cs @@ -0,0 +1,11 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface IChatService +{ + Task SendMessageAsync(Guid userId, SendChatMessageRequest request); + Task> GetConversationsAsync(Guid userId); + Task GetConversationAsync(Guid userId, Guid conversationId); + Task DeleteConversationAsync(Guid userId, Guid conversationId); +} diff --git a/src/Purrse.Core/Models/ChatConversation.cs b/src/Purrse.Core/Models/ChatConversation.cs new file mode 100644 index 0000000..c3b59b3 --- /dev/null +++ b/src/Purrse.Core/Models/ChatConversation.cs @@ -0,0 +1,12 @@ +namespace Purrse.Core.Models; + +public class ChatConversation +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string? Title { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public User User { get; set; } = null!; + public ICollection Messages { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/ChatMessage.cs b/src/Purrse.Core/Models/ChatMessage.cs new file mode 100644 index 0000000..f44fedf --- /dev/null +++ b/src/Purrse.Core/Models/ChatMessage.cs @@ -0,0 +1,13 @@ +namespace Purrse.Core.Models; + +public class ChatMessage +{ + public Guid Id { get; set; } + public Guid ConversationId { get; set; } + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string? ToolCallsJson { get; set; } + public string? ToolResultJson { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public ChatConversation Conversation { get; set; } = null!; +} diff --git a/src/Purrse.Data/Configurations/ChatConversationConfiguration.cs b/src/Purrse.Data/Configurations/ChatConversationConfiguration.cs new file mode 100644 index 0000000..fe161e9 --- /dev/null +++ b/src/Purrse.Data/Configurations/ChatConversationConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class ChatConversationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("chat_conversations"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Title).HasMaxLength(200); + + builder.HasOne(c => c.User) + .WithMany() + .HasForeignKey(c => c.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(c => c.UserId); + } +} diff --git a/src/Purrse.Data/Configurations/ChatMessageConfiguration.cs b/src/Purrse.Data/Configurations/ChatMessageConfiguration.cs new file mode 100644 index 0000000..da96aad --- /dev/null +++ b/src/Purrse.Data/Configurations/ChatMessageConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class ChatMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("chat_messages"); + builder.HasKey(m => m.Id); + builder.Property(m => m.Role).HasMaxLength(20).IsRequired(); + builder.Property(m => m.Content).IsRequired(); + + builder.HasOne(m => m.Conversation) + .WithMany(c => c.Messages) + .HasForeignKey(m => m.ConversationId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(m => m.ConversationId); + } +} diff --git a/src/Purrse.Data/Migrations/20260210043006_AddAiChat.Designer.cs b/src/Purrse.Data/Migrations/20260210043006_AddAiChat.Designer.cs new file mode 100644 index 0000000..803ea60 --- /dev/null +++ b/src/Purrse.Data/Migrations/20260210043006_AddAiChat.Designer.cs @@ -0,0 +1,1563 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Purrse.Data; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + [DbContext(typeof(PurrseDbContext))] + [Migration("20260210043006_AddAiChat")] + partial class AddAiChat + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Balance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreditLimit") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Institution") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("InterestRate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsClosed") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.AiCategorizationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfidenceThreshold") + .HasColumnType("double precision"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("ModelName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OllamaUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SystemPrompt") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdatePayeeDefaults") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("UserPromptTemplate") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ai_categorization_settings", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EscrowAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LoanDetailId") + .HasColumnType("uuid"); + + b.Property("PaymentAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PaymentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PaymentNumber") + .HasColumnType("integer"); + + b.Property("PrincipalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RemainingBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("LoanDetailId"); + + b.ToTable("amortization_entries", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.BankSyncSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EncryptedPlaidClientId") + .HasColumnType("text"); + + b.Property("EncryptedPlaidSecret") + .HasColumnType("text"); + + b.Property("PlaidEnvironment") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("bank_sync_settings", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Year", "Month") + .IsUnique(); + + b.ToTable("budgets", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.BudgetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("BudgetedAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.HasIndex("CategoryId"); + + b.ToTable("budget_items", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("UserId", "Name", "ParentId") + .IsUnique(); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ChatConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("chat_conversations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ToolCallsJson") + .HasColumnType("text"); + + b.Property("ToolResultJson") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DuplicateCount") + .HasColumnType("integer"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedCount") + .HasColumnType("integer"); + + b.Property("ParsedDataJson") + .HasColumnType("text"); + + b.Property("SkippedCount") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalCount") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("UserId"); + + b.ToTable("import_batches", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.InvestmentHolding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AsOfDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CostBasis") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SecurityId") + .HasColumnType("uuid"); + + b.Property("Shares") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("SecurityId"); + + b.ToTable("investment_holdings", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalAccountId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountMask") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastKnownBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SyncConnectionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("SyncConnectionId", "ExternalAccountId") + .IsUnique(); + + b.ToTable("linked_accounts", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EscrowAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExtraPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestRate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.Property("MaturityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MonthlyPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OriginalBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OriginationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TermMonths") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("loan_details", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultCategoryId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DefaultCategoryId"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("payees", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PayeeAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Alias"); + + b.HasIndex("PayeeId"); + + b.ToTable("payee_aliases", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PluginRegistrationId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("PluginRegistrationId", "Key") + .IsUnique(); + + b.ToTable("plugin_configurations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryAssembly") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PluginId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("PluginId") + .IsUnique(); + + b.ToTable("plugin_registrations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("StatementBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("StatementDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("reconciliations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ScheduledTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AutoPost") + .HasColumnType("boolean"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Frequency") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Memo") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("NextDueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.Property("PayeeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReminderDaysBefore") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("PayeeId"); + + b.HasIndex("UserId"); + + b.ToTable("scheduled_transactions", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Security", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SecurityType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Symbol") + .IsUnique(); + + b.ToTable("securities", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SecurityPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SecurityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SecurityId", "Date") + .IsUnique(); + + b.ToTable("security_prices", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstitutionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("InstitutionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PlaidItemId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("SyncIntervalMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlaidItemId"); + + b.HasIndex("UserId"); + + b.ToTable("sync_connections", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("LinkedAccountId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SyncConnectionId") + .HasColumnType("uuid"); + + b.Property("TransactionsFetched") + .HasColumnType("integer"); + + b.Property("TransactionsImported") + .HasColumnType("integer"); + + b.Property("TransactionsSkipped") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LinkedAccountId"); + + b.HasIndex("StartedAt"); + + b.HasIndex("SyncConnectionId"); + + b.ToTable("sync_logs", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CheckNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FitId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ImportBatchId") + .HasColumnType("uuid"); + + b.Property("IsVoid") + .HasColumnType("boolean"); + + b.Property("Memo") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.Property("PayeeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReconciliationId") + .HasColumnType("uuid"); + + b.Property("ReferenceNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TransferTransactionId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("Date"); + + b.HasIndex("Fingerprint"); + + b.HasIndex("FitId"); + + b.HasIndex("ImportBatchId"); + + b.HasIndex("PayeeId"); + + b.HasIndex("ReconciliationId"); + + b.HasIndex("TransferTransactionId") + .IsUnique(); + + b.ToTable("transactions", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.TransactionSplit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Memo") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TransactionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("TransactionId"); + + b.ToTable("transaction_splits", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.AiCategorizationSettings", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithOne("AiCategorizationSettings") + .HasForeignKey("Purrse.Core.Models.AiCategorizationSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => + { + b.HasOne("Purrse.Core.Models.LoanDetail", "LoanDetail") + .WithMany("AmortizationEntries") + .HasForeignKey("LoanDetailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LoanDetail"); + }); + + modelBuilder.Entity("Purrse.Core.Models.BankSyncSettings", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithOne("BankSyncSettings") + .HasForeignKey("Purrse.Core.Models.BankSyncSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.BudgetItem", b => + { + b.HasOne("Purrse.Core.Models.Budget", "Budget") + .WithMany("Items") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany("BudgetItems") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.HasOne("Purrse.Core.Models.Category", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ChatConversation", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ChatMessage", b => + { + b.HasOne("Purrse.Core.Models.ChatConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.InvestmentHolding", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("InvestmentHoldings") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Security", "Security") + .WithMany("Holdings") + .HasForeignKey("SecurityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Security"); + }); + + modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("LinkedAccounts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection") + .WithMany("LinkedAccounts") + .HasForeignKey("SyncConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("SyncConnection"); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithOne("LoanDetail") + .HasForeignKey("Purrse.Core.Models.LoanDetail", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.HasOne("Purrse.Core.Models.Category", "DefaultCategory") + .WithMany() + .HasForeignKey("DefaultCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DefaultCategory"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PayeeAlias", b => + { + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany("Aliases") + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Payee"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginConfiguration", b => + { + b.HasOne("Purrse.Core.Models.PluginRegistration", "PluginRegistration") + .WithMany("Configurations") + .HasForeignKey("PluginRegistrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PluginRegistration"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("Reconciliations") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ScheduledTransaction", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany() + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("Payee"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SecurityPrice", b => + { + b.HasOne("Purrse.Core.Models.Security", "Security") + .WithMany("Prices") + .HasForeignKey("SecurityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Security"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany("SyncConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncLog", b => + { + b.HasOne("Purrse.Core.Models.LinkedAccount", "LinkedAccount") + .WithMany() + .HasForeignKey("LinkedAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection") + .WithMany("SyncLogs") + .HasForeignKey("SyncConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedAccount"); + + b.Navigation("SyncConnection"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Transaction", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany("Transactions") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.ImportBatch", "ImportBatch") + .WithMany("Transactions") + .HasForeignKey("ImportBatchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany("Transactions") + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Reconciliation", "Reconciliation") + .WithMany("Transactions") + .HasForeignKey("ReconciliationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Transaction", "TransferTransaction") + .WithOne() + .HasForeignKey("Purrse.Core.Models.Transaction", "TransferTransactionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("ImportBatch"); + + b.Navigation("Payee"); + + b.Navigation("Reconciliation"); + + b.Navigation("TransferTransaction"); + }); + + modelBuilder.Entity("Purrse.Core.Models.TransactionSplit", b => + { + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Transaction", "Transaction") + .WithMany("Splits") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.Navigation("InvestmentHoldings"); + + b.Navigation("LinkedAccounts"); + + b.Navigation("LoanDetail"); + + b.Navigation("Reconciliations"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.Navigation("BudgetItems"); + + b.Navigation("Children"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ChatConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.Navigation("AmortizationEntries"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.Navigation("Aliases"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginRegistration", b => + { + b.Navigation("Configurations"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Security", b => + { + b.Navigation("Holdings"); + + b.Navigation("Prices"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b => + { + b.Navigation("LinkedAccounts"); + + b.Navigation("SyncLogs"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Transaction", b => + { + b.Navigation("Splits"); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Navigation("Accounts"); + + b.Navigation("AiCategorizationSettings"); + + b.Navigation("BankSyncSettings"); + + b.Navigation("SyncConnections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Purrse.Data/Migrations/20260210043006_AddAiChat.cs b/src/Purrse.Data/Migrations/20260210043006_AddAiChat.cs new file mode 100644 index 0000000..9d8dc1a --- /dev/null +++ b/src/Purrse.Data/Migrations/20260210043006_AddAiChat.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + /// + public partial class AddAiChat : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "chat_conversations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_chat_conversations", x => x.Id); + table.ForeignKey( + name: "FK_chat_conversations_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "chat_messages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ConversationId = table.Column(type: "uuid", nullable: false), + Role = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Content = table.Column(type: "text", nullable: false), + ToolCallsJson = table.Column(type: "text", nullable: true), + ToolResultJson = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_chat_messages", x => x.Id); + table.ForeignKey( + name: "FK_chat_messages_chat_conversations_ConversationId", + column: x => x.ConversationId, + principalTable: "chat_conversations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_chat_conversations_UserId", + table: "chat_conversations", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_chat_messages_ConversationId", + table: "chat_messages", + column: "ConversationId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "chat_messages"); + + migrationBuilder.DropTable( + name: "chat_conversations"); + } + } +} diff --git a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs index 9cc65bc..1b19c2e 100644 --- a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs +++ b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs @@ -302,6 +302,66 @@ namespace Purrse.Data.Migrations b.ToTable("categories", (string)null); }); + modelBuilder.Entity("Purrse.Core.Models.ChatConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("chat_conversations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ToolCallsJson") + .HasColumnType("text"); + + b.Property("ToolResultJson") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("chat_messages", (string)null); + }); + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => { b.Property("Id") @@ -1129,6 +1189,28 @@ namespace Purrse.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Purrse.Core.Models.ChatConversation", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ChatMessage", b => + { + b.HasOne("Purrse.Core.Models.ChatConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => { b.HasOne("Purrse.Core.Models.Account", "Account") @@ -1411,6 +1493,11 @@ namespace Purrse.Data.Migrations b.Navigation("Transactions"); }); + modelBuilder.Entity("Purrse.Core.Models.ChatConversation", b => + { + b.Navigation("Messages"); + }); + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => { b.Navigation("Transactions"); diff --git a/src/Purrse.Data/PurrseDbContext.cs b/src/Purrse.Data/PurrseDbContext.cs index cdeb5f5..dd91959 100644 --- a/src/Purrse.Data/PurrseDbContext.cs +++ b/src/Purrse.Data/PurrseDbContext.cs @@ -31,6 +31,8 @@ public class PurrseDbContext : DbContext public DbSet SyncLogs => Set(); public DbSet BankSyncSettings => Set(); public DbSet AiCategorizationSettings => Set(); + public DbSet ChatConversations => Set(); + public DbSet ChatMessages => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) {