diff --git a/.github/workflows/admin.yml b/.github/workflows/admin.yml index fee3e5e..05c655a 100644 --- a/.github/workflows/admin.yml +++ b/.github/workflows/admin.yml @@ -56,6 +56,7 @@ jobs: run: | pnpm --filter kal-shared build pnpm --filter kal-baml build + pnpm --filter kal-backend build - name: Lint run: pnpm --filter kal-admin lint @@ -90,6 +91,7 @@ jobs: run: | pnpm --filter kal-shared build pnpm --filter kal-baml build + pnpm --filter kal-backend build - name: Build admin run: pnpm --filter kal-admin build diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 2777105..1c8a54a 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -56,6 +56,7 @@ jobs: run: | pnpm --filter kal-shared build pnpm --filter kal-baml build + pnpm --filter kal-backend build - name: Lint run: pnpm --filter kal-frontend lint @@ -90,6 +91,7 @@ jobs: run: | pnpm --filter kal-shared build pnpm --filter kal-baml build + pnpm --filter kal-backend build - name: Build frontend run: pnpm --filter kal-frontend build diff --git a/packages/kal-admin/src/app/api/auth/login/route.ts b/packages/kal-admin/src/app/api/auth/login/route.ts index 81c3404..77b8791 100644 --- a/packages/kal-admin/src/app/api/auth/login/route.ts +++ b/packages/kal-admin/src/app/api/auth/login/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; const SESSION_COOKIE = "kal_admin_session"; diff --git a/packages/kal-admin/src/app/api/trpc/[...trpc]/route.ts b/packages/kal-admin/src/app/api/trpc/[...trpc]/route.ts index 6899c52..c0df5e4 100644 --- a/packages/kal-admin/src/app/api/trpc/[...trpc]/route.ts +++ b/packages/kal-admin/src/app/api/trpc/[...trpc]/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; const ADMIN_SECRET = process.env.ADMIN_SECRET ?? ""; diff --git a/packages/kal-admin/src/app/dashboard/api-keys/page.tsx b/packages/kal-admin/src/app/dashboard/api-keys/page.tsx index bc55ae4..5865129 100644 --- a/packages/kal-admin/src/app/dashboard/api-keys/page.tsx +++ b/packages/kal-admin/src/app/dashboard/api-keys/page.tsx @@ -1,8 +1,6 @@ "use client"; -import { useState } from "react"; import { trpc } from "@/lib/trpc"; -import { format } from "date-fns"; function StatCard({ label, diff --git a/packages/kal-admin/src/app/dashboard/feedback/page.tsx b/packages/kal-admin/src/app/dashboard/feedback/page.tsx index 3bb54db..df9a7ee 100644 --- a/packages/kal-admin/src/app/dashboard/feedback/page.tsx +++ b/packages/kal-admin/src/app/dashboard/feedback/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { useMemo, useState } from "react"; -import { trpc } from "@/lib/trpc"; import { format } from "date-fns"; +import { useState } from "react"; + +import { trpc } from "@/lib/trpc"; type BugStatus = "open" | "in_progress" | "resolved" | "closed" | "wont_fix"; type BugStatusFilter = "all" | BugStatus; @@ -27,33 +28,6 @@ function StarRating({ rating }: { rating: number }) { ); } -function StatusBadge({ status }: { status: BugStatus }) { - const styles: Record = { - open: "bg-status-warning/15 text-status-warning border-status-warning/20", - in_progress: "bg-status-info/15 text-status-info border-status-info/20", - resolved: - "bg-status-success/15 text-status-success border-status-success/20", - closed: "bg-slate-500/15 text-slate-400 border-slate-500/20", - wont_fix: "bg-status-danger/15 text-status-danger border-status-danger/20", - }; - - const labels: Record = { - open: "Open", - in_progress: "In Progress", - resolved: "Resolved", - closed: "Closed", - wont_fix: "Won't Fix", - }; - - return ( - - {labels[status]} - - ); -} - function StatCard({ label, value, diff --git a/packages/kal-admin/src/app/dashboard/logs/page.tsx b/packages/kal-admin/src/app/dashboard/logs/page.tsx index eb8f9ed..8b5d3b0 100644 --- a/packages/kal-admin/src/app/dashboard/logs/page.tsx +++ b/packages/kal-admin/src/app/dashboard/logs/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { useMemo, useState } from "react"; import { Chart as ChartJS, CategoryScale, @@ -10,9 +9,11 @@ import { Tooltip, Legend, } from "chart.js"; +import { format } from "date-fns"; +import { useMemo, useState } from "react"; import { Bar } from "react-chartjs-2"; + import { trpc } from "@/lib/trpc"; -import { format, formatDistanceToNow } from "date-fns"; ChartJS.register( CategoryScale, @@ -126,9 +127,9 @@ function StatusBadge({ statusCode }: { statusCode: number }) { function LogsTable({ logs, loading, - onPageChange, - currentPage, - pageSize, + onPageChange: _onPageChange, + currentPage: _currentPage, + pageSize: _pageSize, }: { logs: Array<{ _id?: string; @@ -375,10 +376,10 @@ export default function LogsPage() { endpointPrefix: "/api/v1/", }); - const isLoading = logsLoading || statsLoading; + const _isLoading = logsLoading || statsLoading; const totalPages = logsData ? Math.ceil(logsData.total / limit) : 0; - const currentPreset = + const _currentPreset = presetFilter === "api" ? "API" : presetFilter === "health" diff --git a/packages/kal-admin/src/app/dashboard/settings/page.tsx b/packages/kal-admin/src/app/dashboard/settings/page.tsx index 6a6eaa2..9a07cdb 100644 --- a/packages/kal-admin/src/app/dashboard/settings/page.tsx +++ b/packages/kal-admin/src/app/dashboard/settings/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; + import { trpc } from "@/lib/trpc"; type Tier = "free" | "tier_1" | "tier_2"; diff --git a/packages/kal-admin/src/app/dashboard/users/page.tsx b/packages/kal-admin/src/app/dashboard/users/page.tsx index 0268c93..aaf4c7b 100644 --- a/packages/kal-admin/src/app/dashboard/users/page.tsx +++ b/packages/kal-admin/src/app/dashboard/users/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { useMemo, useState } from "react"; import { Chart as ChartJS, CategoryScale, @@ -10,9 +9,11 @@ import { Tooltip, Legend, } from "chart.js"; +import { formatDistanceToNow } from "date-fns"; +import { useMemo, useState } from "react"; import { Bar } from "react-chartjs-2"; + import { trpc } from "@/lib/trpc"; -import { formatDistanceToNow } from "date-fns"; ChartJS.register( CategoryScale, @@ -310,7 +311,7 @@ export default function UsersPage() { const { data: growth, isLoading: growthLoading } = trpc.user.growth.useQuery(); - const isLoading = usersLoading || statsLoading; + const _isLoading = usersLoading || statsLoading; const filteredUsers = useMemo(() => { if (!users) return []; diff --git a/packages/kal-admin/src/app/layout.tsx b/packages/kal-admin/src/app/layout.tsx index 807e4be..2ae2aa0 100644 --- a/packages/kal-admin/src/app/layout.tsx +++ b/packages/kal-admin/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; + import "./globals.css"; import { TRPCProvider } from "@/lib/trpc-provider"; diff --git a/packages/kal-admin/src/app/login/page.tsx b/packages/kal-admin/src/app/login/page.tsx index 3c9468c..6417054 100644 --- a/packages/kal-admin/src/app/login/page.tsx +++ b/packages/kal-admin/src/app/login/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, FormEvent } from "react"; import { useRouter } from "next/navigation"; +import { useState, type FormEvent } from "react"; export default function LoginPage() { const router = useRouter(); diff --git a/packages/kal-admin/src/lib/trpc.ts b/packages/kal-admin/src/lib/trpc.ts index 627df4c..0900477 100644 --- a/packages/kal-admin/src/lib/trpc.ts +++ b/packages/kal-admin/src/lib/trpc.ts @@ -1,6 +1,6 @@ import { createTRPCReact } from "@trpc/react-query"; import type { CreateTRPCReact } from "@trpc/react-query"; -import type { AppRouter } from "kal-backend/src/routers"; +import type { AppRouter } from "kal-backend"; export const trpc: CreateTRPCReact = createTRPCReact(); diff --git a/packages/kal-admin/vercel.json b/packages/kal-admin/vercel.json index b140ef8..2ff1e04 100644 --- a/packages/kal-admin/vercel.json +++ b/packages/kal-admin/vercel.json @@ -1,6 +1,6 @@ { "framework": "nextjs", - "installCommand": "pnpm install", - "buildCommand": "pnpm build", + "installCommand": "cd ../.. && pnpm install --frozen-lockfile", + "buildCommand": "cd ../.. && pnpm turbo build --filter=kal-admin...", "outputDirectory": ".next" } diff --git a/packages/kal-backend/package.json b/packages/kal-backend/package.json index 49bc519..1c48c31 100644 --- a/packages/kal-backend/package.json +++ b/packages/kal-backend/package.json @@ -4,6 +4,13 @@ "private": true, "type": "module", "main": "./dist/index.js", + "types": "./dist/routers/index.d.ts", + "exports": { + ".": { + "types": "./dist/routers/index.d.ts", + "import": "./dist/index.js" + } + }, "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", diff --git a/packages/kal-backend/src/index.ts b/packages/kal-backend/src/index.ts index 3913c89..0f48617 100644 --- a/packages/kal-backend/src/index.ts +++ b/packages/kal-backend/src/index.ts @@ -6,11 +6,9 @@ import cookieParser from "cookie-parser"; import cors from "cors"; import express from "express"; import session from "express-session"; -import swaggerUi from "swagger-ui-express"; - import helmet from "helmet"; - import { API_BASE_PATH } from "kal-shared"; +import swaggerUi from "swagger-ui-express"; import { createContext } from "./lib/context.js"; import { connectDB } from "./lib/db.js"; diff --git a/packages/kal-backend/src/lib/cache.ts b/packages/kal-backend/src/lib/cache.ts index c793c13..fb1340d 100644 --- a/packages/kal-backend/src/lib/cache.ts +++ b/packages/kal-backend/src/lib/cache.ts @@ -1,5 +1,5 @@ -import { getRedis, isRedisAvailable } from "./redis.js"; import { logger } from "./logger.js"; +import { getRedis, isRedisAvailable } from "./redis.js"; /** * Cache service providing high-level caching operations diff --git a/packages/kal-backend/src/lib/context.ts b/packages/kal-backend/src/lib/context.ts index e086494..1a529de 100644 --- a/packages/kal-backend/src/lib/context.ts +++ b/packages/kal-backend/src/lib/context.ts @@ -22,9 +22,10 @@ async function syncUserFromLogto( const now = new Date(); const usersCollection = db.collection("users"); - + // Use name, username, or email (before @) as display name - const displayName = claims.name || claims.username || claims.email?.split("@")[0] || ""; + const displayName = + claims.name || claims.username || claims.email?.split("@")[0] || ""; // Upsert user - create if not exists, update if exists const result = await usersCollection.findOneAndUpdate( @@ -76,15 +77,21 @@ export async function createContext({ // ------------------------------------------------------------------------ // VIRTUAL ADMIN AUTH (Env-Based for Open Source/Bootstrap) // ------------------------------------------------------------------------ - const adminSecretHeader = req.headers["x-admin-secret"] as string | undefined; + const adminSecretHeader = req.headers["x-admin-secret"] as + | string + | undefined; const envAdminSecret = process.env.ADMIN_SECRET; - if (adminSecretHeader && envAdminSecret && adminSecretHeader === envAdminSecret) { + if ( + adminSecretHeader && + envAdminSecret && + adminSecretHeader === envAdminSecret + ) { // Create a virtual super admin in memory (no DB access needed) // We use a predefined ID and full permissions const { ObjectId } = await import("mongodb"); user = { - _id: new ObjectId("000000000000000000000000") as any, // Fixed Virtual ID + _id: new ObjectId("000000000000000000000000") as unknown as User["_id"], // Fixed Virtual ID logtoId: "admin-virtual-account", email: "admin@system.local", name: "System Administrator", @@ -108,9 +115,13 @@ export async function createContext({ // Try Logto ID from frontend header (when using Next.js proxy/tRPC) const headerLogtoId = req.headers["x-logto-id"] as string | undefined; if (headerLogtoId) { - user = await db.collection("users").findOne({ logtoId: headerLogtoId }); - - const headerEmail = req.headers["x-logto-email"] as string | undefined; + user = await db + .collection("users") + .findOne({ logtoId: headerLogtoId }); + + const headerEmail = req.headers["x-logto-email"] as + | string + | undefined; const headerName = req.headers["x-logto-name"] as string | undefined; // If user not found but we have claims in headers (trusted from frontend), create/sync them @@ -124,13 +135,15 @@ export async function createContext({ }); } } else if (headerName && !user.name) { - // Update if local name is missing but header has one - await db.collection("users").updateOne( - { _id: user._id }, - { $set: { name: headerName, email: headerEmail || user.email } } - ); - user.name = headerName; - if (headerEmail) user.email = headerEmail; + // Update if local name is missing but header has one + await db + .collection("users") + .updateOne( + { _id: user._id }, + { $set: { name: headerName, email: headerEmail || user.email } } + ); + user.name = headerName; + if (headerEmail) user.email = headerEmail; } } diff --git a/packages/kal-backend/src/lib/platform-settings.ts b/packages/kal-backend/src/lib/platform-settings.ts index 6f4d154..4d1158f 100644 --- a/packages/kal-backend/src/lib/platform-settings.ts +++ b/packages/kal-backend/src/lib/platform-settings.ts @@ -1,29 +1,34 @@ import { RATE_LIMITS, type RateLimitConfig, type UserTier } from "kal-shared"; +import type { Db, Filter, Document } from "mongodb"; + import { cache } from "./cache.js"; -import type { Db } from "mongodb"; const SETTINGS_ID = "rate_limits"; const CACHE_KEY = "platform:rate_limits"; const CACHE_TTL = 60 * 5; // 5 minutes cache for middleware to pick up // Helper to get effective limits (DB > Default) -export async function getEffectiveRateLimits(db: Db): Promise> { +export async function getEffectiveRateLimits( + db: Db +): Promise> { // Try cache first const cached = await cache.get>(CACHE_KEY); if (cached) return cached; // Try DB // Note: db.collection might throw if db is not connected, but assumed connected here - const doc = await db.collection("platform_settings").findOne({ _id: SETTINGS_ID as any }); - + const doc = await db + .collection("platform_settings") + .findOne({ _id: SETTINGS_ID } as unknown as Filter); + let merged = { ...RATE_LIMITS }; if (doc && doc.limits) { // Merge DB limits with defaults // We'll replace per tier if exists in DB to override code defaults merged = { - ...merged, - ...doc.limits + ...merged, + ...doc.limits, }; } @@ -33,5 +38,5 @@ export async function getEffectiveRateLimits(db: Db): Promise("rate_limit_usage"); - + // Composite _id ensures one document per user per day const compositeId = `${userId}_${today}`; try { // First, get current usage to check windows - const existingUsage = await collection.findOne({ _id: compositeId as unknown as string }); - + const existingUsage = await collection.findOne({ + _id: compositeId as unknown as string, + }); + // Check if we're in new time windows const isNewMinute = !existingUsage?.minuteWindow || new Date(existingUsage.minuteWindow).getTime() < minuteStart.getTime(); - + const isNewSecond = !existingUsage?.secondWindow || new Date(existingUsage.secondWindow).getTime() < secondStart.getTime(); @@ -91,7 +98,7 @@ export async function checkRateLimit( $set: Record; $setOnInsert?: Record; } - + const updateDoc: UpdateOperation = { $inc: { dailyCount: 1 }, $set: { updatedAt: now }, @@ -128,12 +135,12 @@ export async function checkRateLimit( ); const usage = result; - + if (!usage) { // This shouldn't happen with upsert, but handle gracefully - logger.warn("Rate limit upsert returned null", { - userId: userId.substring(0, 8), - compositeId + logger.warn("Rate limit upsert returned null", { + userId: userId.substring(0, 8), + compositeId, }); return { limited: false, @@ -224,27 +231,33 @@ export async function checkRateLimit( // Log the error with full context for debugging const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : undefined; - + logger.error("Rate limit check failed", { userId: userId.substring(0, 8), compositeId, tier, error: errorMessage, }); - + // Log additional details for MongoDB-specific errors - if (errorMessage.includes("E11000") || errorMessage.includes("duplicate key")) { - logger.error("CRITICAL: Duplicate key error in rate_limit_usage - possible _id issue", { - compositeId, - error: errorMessage, - }); + if ( + errorMessage.includes("E11000") || + errorMessage.includes("duplicate key") + ) { + logger.error( + "CRITICAL: Duplicate key error in rate_limit_usage - possible _id issue", + { + compositeId, + error: errorMessage, + } + ); } - + // Log stack trace for debugging if (errorStack) { console.error("Rate limit error stack trace:", errorStack); } - + // Return non-limiting result to avoid blocking users due to DB issues // But log it so we can investigate return { diff --git a/packages/kal-backend/src/routers/chat.ts b/packages/kal-backend/src/routers/chat.ts index e18047b..1c98664 100644 --- a/packages/kal-backend/src/routers/chat.ts +++ b/packages/kal-backend/src/routers/chat.ts @@ -5,10 +5,10 @@ import { type ParsedRecipe, type RecipeIngredient, UserIntent, -} from 'kal-baml'; -import type { ChatMessage, ChatThread, ChatThreadPreview } from 'kal-shared'; -import { ObjectId } from 'mongodb'; -import { z } from 'zod'; +} from "kal-baml"; +import type { ChatMessage, ChatThread, ChatThreadPreview } from "kal-shared"; +import { ObjectId } from "mongodb"; +import { z } from "zod"; import { isFoodQuery, @@ -17,13 +17,13 @@ import { searchRecipeIngredients, formatNutritionForChat, calculateTotalNutrition, -} from '../lib/kalori-api-tool.js'; -import { router, protectedProcedure } from '../lib/trpc.js'; +} from "../lib/kalori-api-tool.js"; +import { router, protectedProcedure } from "../lib/trpc.js"; // Helper to ensure user is authenticated function requireUser(user: { logtoId: string } | null): { logtoId: string } { if (!user) { - throw new Error('Authentication required'); + throw new Error("Authentication required"); } return user; } @@ -39,19 +39,19 @@ export const chatRouter = router({ // Create a new chat thread createThread: protectedProcedure.mutation(async ({ ctx }) => { const user = requireUser(ctx.user); - console.log('[Chat] createThread called by user:', user.logtoId); + console.log("[Chat] createThread called by user:", user.logtoId); const now = new Date(); const thread = { userId: user.logtoId, - title: 'New Conversation', + title: "New Conversation", createdAt: now, updatedAt: now, messageCount: 0, }; - const result = await ctx.db.collection('chat_threads').insertOne(thread); - console.log('[Chat] Thread created:', result.insertedId.toString()); + const result = await ctx.db.collection("chat_threads").insertOne(thread); + console.log("[Chat] Thread created:", result.insertedId.toString()); return { _id: result.insertedId.toString(), @@ -73,7 +73,7 @@ export const chatRouter = router({ const limit = input?.limit ?? 20; const threads = await ctx.db - .collection('chat_threads') + .collection("chat_threads") .find({ userId: user.logtoId }) .sort({ updatedAt: -1 }) .limit(limit) @@ -83,7 +83,7 @@ export const chatRouter = router({ const threadsWithPreview: ChatThreadPreview[] = await Promise.all( threads.map(async (thread) => { const lastMessage = await ctx.db - .collection('chat_messages') + .collection("chat_messages") .findOne( { threadId: thread._id.toString() }, { sort: { createdAt: -1 } } @@ -110,21 +110,21 @@ export const chatRouter = router({ const threadId = input.threadId; // Verify ownership - const thread = await ctx.db.collection('chat_threads').findOne({ + const thread = await ctx.db.collection("chat_threads").findOne({ _id: new ObjectId(threadId), userId: user.logtoId, }); if (!thread) { - throw new Error('Thread not found or access denied'); + throw new Error("Thread not found or access denied"); } // Delete all messages in the thread - await ctx.db.collection('chat_messages').deleteMany({ threadId }); + await ctx.db.collection("chat_messages").deleteMany({ threadId }); // Delete the thread await ctx.db - .collection('chat_threads') + .collection("chat_threads") .deleteOne({ _id: new ObjectId(threadId) }); // Clear any cached recipes for this thread @@ -150,44 +150,44 @@ export const chatRouter = router({ const { threadId, content } = input; const now = new Date(); - console.log('[Chat] sendMessage called:', { + console.log("[Chat] sendMessage called:", { user: user.logtoId, threadId, content: content.slice(0, 50), }); // Verify thread ownership - const thread = await ctx.db.collection('chat_threads').findOne({ + const thread = await ctx.db.collection("chat_threads").findOne({ _id: new ObjectId(threadId), userId: user.logtoId, }); if (!thread) { - console.log('[Chat] Thread not found or access denied:', threadId); - throw new Error('Thread not found or access denied'); + console.log("[Chat] Thread not found or access denied:", threadId); + throw new Error("Thread not found or access denied"); } // Save user message - const userMessage: Omit = { + const userMessage: Omit = { threadId, userId: user.logtoId, - role: 'User', + role: "User", content, createdAt: now, }; const userMsgResult = await ctx.db - .collection('chat_messages') + .collection("chat_messages") .insertOne(userMessage); console.log( - '[Chat] User message saved:', + "[Chat] User message saved:", userMsgResult.insertedId.toString() ); // Get recent conversation history for context (last 10 messages) const recentMessages = await ctx.db - .collection('chat_messages') + .collection("chat_messages") .find({ threadId }) .sort({ createdAt: -1 }) .limit(10) @@ -200,20 +200,24 @@ export const chatRouter = router({ const conversationContext = recentMessages .slice(-5) .map((m) => `${m.role}: ${(m.content as string).slice(0, 100)}`) - .join('\n'); + .join("\n"); // Classify intent to determine how to handle the message - console.log('[Thinking] Classifying user intent...'); + console.log("[Thinking] Classifying user intent..."); let intentResult; try { intentResult = await classifyIntent(content, conversationContext); - console.log('[Thinking] Intent:', intentResult.intent, 'Confidence:', intentResult.confidence); - } catch (error) { - console.log('[Thinking] Intent classification failed, using fallback'); + console.log( + "[Thinking] Intent:", + intentResult.intent, + "Confidence:", + intentResult.confidence + ); + } catch (_error) { intentResult = { intent: UserIntent.GeneralChat, confidence: 0.5, - reasoning: 'Fallback', + reasoning: "Fallback", extracted_food_terms: [], requires_api_lookup: isFoodQuery(content), requires_recipe_parse: false, @@ -221,26 +225,32 @@ export const chatRouter = router({ } // Process based on intent - let foodContext = ''; - let recipeContext = ''; + let foodContext = ""; + let recipeContext = ""; // Check if user is asking about a recipe's nutrition if (isRecipeNutritionQuery(content)) { - console.log('[Tool: recipe] Recipe nutrition query detected'); + console.log("[Tool: recipe] Recipe nutrition query detected"); // Check if we have a recent recipe for this thread const cachedRecipe = recentRecipes.get(threadId); if (cachedRecipe) { - console.log('[Tool: recipe] Using cached recipe:', cachedRecipe.name); + console.log("[Tool: recipe] Using cached recipe:", cachedRecipe.name); // Search for all ingredients - const ingredients = cachedRecipe.ingredients.map((i: RecipeIngredient) => ({ - name: i.name, - quantity: i.quantity, - })); - - console.log('[Tool: recipe] Searching nutrition for', ingredients.length, 'ingredients'); + const ingredients = cachedRecipe.ingredients.map( + (i: RecipeIngredient) => ({ + name: i.name, + quantity: i.quantity, + }) + ); + + console.log( + "[Tool: recipe] Searching nutrition for", + ingredients.length, + "ingredients" + ); const nutritionResults = await searchRecipeIngredients(ingredients); // Calculate totals @@ -250,46 +260,58 @@ export const chatRouter = router({ foodContext = `Recipe: ${cachedRecipe.name}\n\nIngredient nutrition:\n${formatNutritionForChat(nutritionResults)}`; foodContext += `\n\n**Total Nutrition:**\n- Calories: ${totals.totalCalories} cal\n- Protein: ${totals.totalProtein}g\n- Carbs: ${totals.totalCarbs}g\n- Fat: ${totals.totalFat}g`; if (totals.hasEstimates) { - foodContext += '\n\n_Note: Some values are AI estimates as the ingredients were not found in our database._'; + foodContext += + "\n\n_Note: Some values are AI estimates as the ingredients were not found in our database._"; } } else { // Try to find a recipe in recent messages - console.log('[Tool: recipe] No cached recipe, looking in conversation...'); + console.log( + "[Tool: recipe] No cached recipe, looking in conversation..." + ); // Look for recipe in recent assistant messages const recentAssistantMessages = recentMessages - .filter((m) => m.role === 'Assistant') + .filter((m) => m.role === "Assistant") .slice(-3); for (const msg of recentAssistantMessages) { const msgContent = msg.content as string; if ( - msgContent.includes('Ingredients') || - msgContent.includes('Recipe') + msgContent.includes("Ingredients") || + msgContent.includes("Recipe") ) { - console.log('[Tool: recipe] Found recipe in conversation, parsing...'); + console.log( + "[Tool: recipe] Found recipe in conversation, parsing..." + ); try { const parsedRecipe = await parseRecipe(msgContent); recentRecipes.set(threadId, parsedRecipe); // Search for all ingredients - const ingredients = parsedRecipe.ingredients.map((i: RecipeIngredient) => ({ - name: i.name, - quantity: i.quantity, - })); - - console.log('[Tool: recipe] Searching nutrition for', ingredients.length, 'ingredients'); - const nutritionResults = await searchRecipeIngredients(ingredients); + const ingredients = parsedRecipe.ingredients.map( + (i: RecipeIngredient) => ({ + name: i.name, + quantity: i.quantity, + }) + ); + + console.log( + "[Tool: recipe] Searching nutrition for", + ingredients.length, + "ingredients" + ); + const nutritionResults = + await searchRecipeIngredients(ingredients); const totals = calculateTotalNutrition(nutritionResults); foodContext = `Recipe: ${parsedRecipe.name}\n\nIngredient nutrition:\n${formatNutritionForChat(nutritionResults)}`; foodContext += `\n\n**Total Nutrition:**\n- Calories: ${totals.totalCalories} cal\n- Protein: ${totals.totalProtein}g\n- Carbs: ${totals.totalCarbs}g\n- Fat: ${totals.totalFat}g`; if (totals.hasEstimates) { - foodContext += '\n\n_Note: Some values are AI estimates._'; + foodContext += "\n\n_Note: Some values are AI estimates._"; } break; } catch (error) { - console.log('[Tool: recipe] Failed to parse recipe:', error); + console.log("[Tool: recipe] Failed to parse recipe:", error); } } } @@ -298,28 +320,32 @@ export const chatRouter = router({ // Handle recipe requests - save for later nutrition queries else if ( intentResult.intent === UserIntent.RecipeRequest || - content.toLowerCase().includes('recipe') + content.toLowerCase().includes("recipe") ) { - console.log('[Tool: recipe] Recipe request detected'); - recipeContext = 'User is asking for a recipe. After providing the recipe, they may ask about its nutrition.'; + console.log("[Tool: recipe] Recipe request detected"); + recipeContext = + "User is asking for a recipe. After providing the recipe, they may ask about its nutrition."; } // Handle food queries - else if ( - intentResult.requires_api_lookup || - isFoodQuery(content) - ) { - console.log('[Tool: api_checker] Food query detected, searching API...'); + else if (intentResult.requires_api_lookup || isFoodQuery(content)) { + console.log( + "[Tool: api_checker] Food query detected, searching API..." + ); const foodResults = await searchKaloriApi(content); if (foodResults.length > 0) { foodContext = formatNutritionForChat(foodResults); - console.log('[Tool: api_checker] Added', foodResults.length, 'results to context'); + console.log( + "[Tool: api_checker] Added", + foodResults.length, + "results to context" + ); } } // Build messages for SmartChat const chatMessages = recentMessages.map((m) => ({ - role: m.role as 'User' | 'Assistant' | 'System', + role: m.role as "User" | "Assistant" | "System", content: m.content as string, })); @@ -340,67 +366,81 @@ Always respond in English. Be friendly, concise, and accurate. ${recipeContext}`; // Generate AI response using SmartChat with full context - console.log('[Streaming] Generating AI response with SmartChat...'); + console.log("[Streaming] Generating AI response with SmartChat..."); let aiResponse = await smartChat({ messages: chatMessages, systemPrompt, foodContext: foodContext || undefined, }); - console.log('[Streaming] Response generated:', aiResponse.slice(0, 50) + '...'); + console.log( + "[Streaming] Response generated:", + aiResponse.slice(0, 50) + "..." + ); // If this was a recipe response, parse it and append nutrition summary if ( intentResult.intent === UserIntent.RecipeRequest || - aiResponse.toLowerCase().includes('ingredients') + aiResponse.toLowerCase().includes("ingredients") ) { try { const parsedRecipe = await parseRecipe(aiResponse); if (parsedRecipe.ingredients.length > 0) { - console.log('[Tool: recipe] Caching recipe:', parsedRecipe.name); + console.log("[Tool: recipe] Caching recipe:", parsedRecipe.name); recentRecipes.set(threadId, parsedRecipe); // Calculate nutrition for all ingredients - console.log('[Tool: recipe] Calculating nutrition for', parsedRecipe.ingredients.length, 'ingredients'); - const ingredients = parsedRecipe.ingredients.map((i: RecipeIngredient) => ({ - name: i.name, - quantity: i.quantity, - })); + console.log( + "[Tool: recipe] Calculating nutrition for", + parsedRecipe.ingredients.length, + "ingredients" + ); + const ingredients = parsedRecipe.ingredients.map( + (i: RecipeIngredient) => ({ + name: i.name, + quantity: i.quantity, + }) + ); const nutritionResults = await searchRecipeIngredients(ingredients); const totals = calculateTotalNutrition(nutritionResults); // Build nutrition summary - let nutritionSummary = '\n\n---\n\n**Nutrition Summary (estimated per serving):**\n'; + let nutritionSummary = + "\n\n---\n\n**Nutrition Summary (estimated per serving):**\n"; nutritionSummary += `- **Calories:** ${totals.totalCalories} cal\n`; nutritionSummary += `- **Protein:** ${totals.totalProtein}g\n`; nutritionSummary += `- **Carbs:** ${totals.totalCarbs}g\n`; nutritionSummary += `- **Fat:** ${totals.totalFat}g\n`; if (totals.hasEstimates) { - nutritionSummary += '\n_Some values are estimated as ingredients were not found in our database._'; + nutritionSummary += + "\n_Some values are estimated as ingredients were not found in our database._"; } // Append to response aiResponse += nutritionSummary; - console.log('[Tool: recipe] Nutrition summary appended'); + console.log("[Tool: recipe] Nutrition summary appended"); } } catch (error) { - console.log('[Tool: recipe] Failed to parse/calculate nutrition:', error); + console.log( + "[Tool: recipe] Failed to parse/calculate nutrition:", + error + ); // Not a recipe or failed to parse - that's okay } } // Save assistant message - const assistantMessage: Omit = { + const assistantMessage: Omit = { threadId, userId: user.logtoId, - role: 'Assistant', + role: "Assistant", content: aiResponse, createdAt: new Date(), }; const assistantResult = await ctx.db - .collection('chat_messages') + .collection("chat_messages") .insertOne(assistantMessage); // Update thread @@ -413,11 +453,11 @@ ${recipeContext}`; // Auto-generate title from first message if (isFirstMessage) { updateData.title = - content.slice(0, 50) + (content.length > 50 ? '...' : ''); + content.slice(0, 50) + (content.length > 50 ? "..." : ""); } await ctx.db - .collection('chat_threads') + .collection("chat_threads") .updateOne({ _id: new ObjectId(threadId) }, { $set: updateData }); return { @@ -446,13 +486,13 @@ ${recipeContext}`; const { threadId, limit, before } = input; // Verify thread ownership - const thread = await ctx.db.collection('chat_threads').findOne({ + const thread = await ctx.db.collection("chat_threads").findOne({ _id: new ObjectId(threadId), userId: user.logtoId, }); if (!thread) { - throw new Error('Thread not found or access denied'); + throw new Error("Thread not found or access denied"); } // Build query @@ -462,7 +502,7 @@ ${recipeContext}`; } const messages = await ctx.db - .collection('chat_messages') + .collection("chat_messages") .find(query) .sort({ createdAt: -1 }) .limit(limit) @@ -475,7 +515,7 @@ ${recipeContext}`; _id: msg._id.toString(), threadId: msg.threadId as string, userId: msg.userId as string, - role: msg.role as 'User' | 'Assistant', + role: msg.role as "User" | "Assistant", content: msg.content as string, createdAt: msg.createdAt as Date, })); @@ -493,7 +533,7 @@ ${recipeContext}`; const user = requireUser(ctx.user); const { threadId, title } = input; - const result = await ctx.db.collection('chat_threads').updateOne( + const result = await ctx.db.collection("chat_threads").updateOne( { _id: new ObjectId(threadId), userId: user.logtoId, @@ -504,7 +544,7 @@ ${recipeContext}`; ); if (result.matchedCount === 0) { - throw new Error('Thread not found or access denied'); + throw new Error("Thread not found or access denied"); } return { success: true }; diff --git a/packages/kal-backend/src/routers/food.ts b/packages/kal-backend/src/routers/food.ts index 5738a47..a05b1e4 100644 --- a/packages/kal-backend/src/routers/food.ts +++ b/packages/kal-backend/src/routers/food.ts @@ -6,8 +6,8 @@ import { import { ObjectId } from "mongodb"; import { z } from "zod"; -import { cache, invalidateCache } from "../lib/cache.js"; import { CacheKeys, CacheTTL } from "../lib/cache-keys.js"; +import { cache, invalidateCache } from "../lib/cache.js"; import { buildSearchQuery } from "../lib/search.js"; import { router, publicProcedure, protectedProcedure } from "../lib/trpc.js"; diff --git a/packages/kal-backend/src/routers/platform-settings.ts b/packages/kal-backend/src/routers/platform-settings.ts index 428a744..0996dca 100644 --- a/packages/kal-backend/src/routers/platform-settings.ts +++ b/packages/kal-backend/src/routers/platform-settings.ts @@ -1,10 +1,12 @@ -import { z } from "zod"; -import { router, publicProcedure, protectedProcedure } from "../lib/trpc.js"; import { RATE_LIMITS, type RateLimitConfig, type UserTier } from "kal-shared"; +import type { Filter, Document } from "mongodb"; +import { z } from "zod"; + import { getEffectiveRateLimits, invalidateRateLimitsCache, } from "../lib/platform-settings.js"; +import { router, publicProcedure, protectedProcedure } from "../lib/trpc.js"; const SETTINGS_ID = "rate_limits"; @@ -60,7 +62,7 @@ export const platformSettingsRouter = router({ // Update DB await ctx.db.collection("platform_settings").updateOne( - { _id: SETTINGS_ID as any }, + { _id: SETTINGS_ID } as unknown as Filter, { $set: { [`limits.${tier}`]: newConfig, @@ -89,7 +91,7 @@ export const platformSettingsRouter = router({ const defaultLimits = RATE_LIMITS[input.tier as UserTier]; await ctx.db.collection("platform_settings").updateOne( - { _id: SETTINGS_ID as any }, + { _id: SETTINGS_ID } as unknown as Filter, { $set: { [`limits.${input.tier}`]: defaultLimits, diff --git a/packages/kal-backend/src/routers/user.ts b/packages/kal-backend/src/routers/user.ts index a1188c0..15980f6 100644 --- a/packages/kal-backend/src/routers/user.ts +++ b/packages/kal-backend/src/routers/user.ts @@ -1,5 +1,5 @@ -import { cache } from "../lib/cache.js"; import { CacheTTL } from "../lib/cache-keys.js"; +import { cache } from "../lib/cache.js"; import { router, publicProcedure } from "../lib/trpc.js"; export const userRouter = router({ diff --git a/packages/kal-frontend/src/lib/trpc.ts b/packages/kal-frontend/src/lib/trpc.ts index 4ce03a7..0900477 100644 --- a/packages/kal-frontend/src/lib/trpc.ts +++ b/packages/kal-frontend/src/lib/trpc.ts @@ -1,5 +1,6 @@ import { createTRPCReact } from "@trpc/react-query"; import type { CreateTRPCReact } from "@trpc/react-query"; -import type { AppRouter } from "kal-backend/src/routers"; +import type { AppRouter } from "kal-backend"; -export const trpc: CreateTRPCReact = createTRPCReact(); +export const trpc: CreateTRPCReact = + createTRPCReact(); diff --git a/packages/kal-shared/package.json b/packages/kal-shared/package.json index 6df932e..7a47ceb 100644 --- a/packages/kal-shared/package.json +++ b/packages/kal-shared/package.json @@ -4,6 +4,13 @@ "private": true, "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, "scripts": { "build": "tsc", "dev": "tsc --watch",