Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
443 changes: 411 additions & 32 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"build": "rimraf dist && npx tsc",
"start": "node dist/server.js",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --runInBand",
"test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --runInBand",
"test:watch": "jest --watch",
"predev": "npm run build",
"dev": "concurrently \"npx tsc -w\" \"nodemon --ext js,yml dist/server.js\"",
Expand Down
25 changes: 3 additions & 22 deletions apps/api/src/modules/ai/controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Request, Response, NextFunction } from "express";
import { AppError } from "../../middleware/errorHandler.js";
import { prisma } from "../../prisma/client.js";
import {
getAiResponse,
getAIHistory as getAIHistoryFromService,
AiServiceError,
ensureAiAccessOrThrow,
getAiAccessStatus,
Expand All @@ -19,27 +19,8 @@ export async function getAIHistory(
if (!userId)
throw new AppError("Unauthorized: User not found in request", 401);

const messages = await prisma.message.findMany({
where: { userId },
orderBy: { created_at: "asc" },
});

const paired = [];
for (let i = 0; i < messages.length; i++) {
const currentMsg = messages[i];
if (currentMsg && currentMsg.role === "user") {
const nextMsg = messages[i + 1];
paired.push({
id: currentMsg.id,
prompt: currentMsg.content,
result: nextMsg?.role === "assistant" ? nextMsg.content : "",
created_at: currentMsg.created_at,
});
if (nextMsg?.role === "assistant") i++;
}
}

return res.json(paired.reverse());
const history = await getAIHistoryFromService(userId);
return res.json(history);
} catch (err) {
next(err);
}
Expand Down
111 changes: 91 additions & 20 deletions apps/api/src/modules/ai/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@ Ukrainian → Ukrainian. English → English. NEVER mention the language, NEVER
NEVER meta-comment. Just answer directly.
Format rules: plain text only, no markdown, no tables, no bold, no emojis, no bullet points. 2–3 sentences max. Use exact numbers from the data.`;

function truncateData(data: object, maxTokens = 1500): string {
const full = JSON.stringify(data);
const maxChars = maxTokens * 4;

if (full.length <= maxChars) return full;

if (Array.isArray(data)) {
const result = [];
let totalChars = 0;
for (const item of data) {
const itemStr = JSON.stringify(item);
if (totalChars + itemStr.length > maxChars) break;
result.push(item);
totalChars += itemStr.length;
}
return (
JSON.stringify(result) +
` /* +${data.length - result.length} more items truncated */`
);
}

return full.slice(0, maxChars) + "... [truncated]";
}

type ServiceAiErrorCode = Exclude<AiErrorCode, "USING_DEFAULT_KEY">;

export type AiAccessTier = "user" | "donor" | "admin";
Expand Down Expand Up @@ -157,6 +181,30 @@ export async function incrementAiAnalysisUsage(
});
}

export async function getAIHistory(userId: string) {
const messages = await prisma.message.findMany({
where: { userId },
orderBy: { created_at: "asc" },
});

const paired = [];
for (let i = 0; i < messages.length; i++) {
const currentMsg = messages[i];
if (currentMsg && currentMsg.role === "user") {
const nextMsg = messages[i + 1];
paired.push({
id: currentMsg.id,
prompt: currentMsg.content,
result: nextMsg?.role === "assistant" ? nextMsg.content : "",
created_at: currentMsg.created_at,
});
if (nextMsg?.role === "assistant") i++;
}
}

return paired.reverse();
}

async function callGroq(
apiKey: string,
modelToUse: string,
Expand All @@ -173,7 +221,10 @@ async function callGroq(
messages: [
{ role: "system", content: systemContent },
...contextMessages,
{ role: "user", content: `${prompt}\n\nData:\n${JSON.stringify(data)}` },
{
role: "user",
content: `${prompt}\n\nData:\n${truncateData(data, 1500)}`,
},
],
});
return completion;
Expand All @@ -195,7 +246,10 @@ async function callGemini(
messages: [
{ role: "system", content: systemContent },
...contextMessages,
{ role: "user", content: `${prompt}\n\nData:\n${JSON.stringify(data)}` },
{
role: "user",
content: `${prompt}\n\nData:\n${truncateData(data, 4000)}`,
},
],
});
return completion;
Expand Down Expand Up @@ -239,22 +293,22 @@ export async function getAiResponse(
data: object,
model?: string,
) {
const activeUserKey = await prisma.userApiKey.findFirst({
where: { userId, isActive: true },
orderBy: { updatedAt: "desc" },
});

const historyMessages = await prisma.message.findMany({
where: { userId },
orderBy: { created_at: "desc" },
take: CONTEXT_LIMIT,
take: activeUserKey?.provider === "GROQ" ? 6 : CONTEXT_LIMIT,
});
historyMessages.reverse();
const contextMessages = historyMessages.map((msg) => ({
role: msg.role as "user" | "assistant",
content: msg.content,
}));

const activeUserKey = await prisma.userApiKey.findFirst({
where: { userId, isActive: true },
orderBy: { updatedAt: "desc" },
});

if (activeUserKey) {
try {
const decrypted = decryptApiKey(activeUserKey.apiKey);
Expand Down Expand Up @@ -296,29 +350,46 @@ export async function getAiResponse(
await saveMessages(userId, prompt, content);
return { model: completion.model, result: content };
} catch (err: unknown) {
console.error("[AI] Provider error:", {
status: (err as { status?: number }).status,
code: (err as { code?: string }).code,
message: err instanceof Error ? err.message : String(err),
userId,
});
const msg = err instanceof Error ? err.message : String(err);
const status = (err as { status?: number }).status;
if (status === 429 || msg.includes("rate_limit")) {

const code = (err as { code?: string }).code;
if (
status === 429 ||
code === "rate_limit_exceeded" ||
msg.includes("rate_limit")
) {
throw new AiServiceError(
"USER_KEY_LIMIT",
"Your API key has reached its rate limit.",
);
}
if (
status === 400 ||
status === 401 ||
status === 403 ||
status === 404
) {
if (status === 401 || status === 403) {
throw new AiServiceError(
"USER_KEY_INVALID",
"Your API key is invalid, unauthorized, or model access is unavailable.",
"Your API key is invalid or unauthorized.",
);
}
throw new AiServiceError(
"USER_KEY_INVALID",
"Your API key is invalid or expired.",
);
if (status === 404) {
throw new AiServiceError(
"USER_KEY_INVALID",
"Model not found. Check your selected model.",
);
}
if (status === 400) {
throw new AiServiceError(
"USER_KEY_INVALID",
"Bad request to AI provider.",
);
}
// Network / unknown — не кажемо "invalid key"
throw new AiServiceError("USER_KEY_INVALID", `AI provider error: ${msg}`);
}
}

Expand Down
9 changes: 1 addition & 8 deletions apps/api/src/modules/donation/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Stripe from "stripe";
import { Prisma } from "@prisma/client";
import type { DonationLeaderboardItem } from "@fintrack/types";
import { ENV } from "../../config/env.js";
import { prisma } from "../../prisma/client.js";
import { AppError } from "../../middleware/errorHandler.js";
Expand Down Expand Up @@ -180,14 +181,6 @@ export async function createDonationCheckoutSession(
};
}

export interface DonationLeaderboardItem {
userId: string;
name: string;
photoUrl: string | null;
totalAmountMinor: number;
currency: string;
}

export async function getDonationLeaderboard(
limit = 20,
): Promise<DonationLeaderboardItem[]> {
Expand Down
Loading
Loading