- |
+ |
+
+
+ |
+
+ |
+
- |
+ |
Hi {{userName}},
@@ -89,49 +210,54 @@
style="
margin: 0 0 28px;
font-size: 15px;
- line-height: 1.6;
- color: #374151;
+ line-height: 1.7;
+ color: #4b5563;
"
+ class="text-secondary"
>
Our AI has analyzed your recent financial activity. Here are
some key highlights and suggestions.
-
+
-
AI Analysis
-
+
{{aiContent}}
@@ -139,72 +265,80 @@
|
-
+
{{#if hasAnomalies}}
-
Unusual Activity Detected
-
+
- We noticed some transactions that do not match your
- usual patterns. You may want to review them in the app.
+ We noticed some transactions that don't match your usual
+ patterns. You may want to review them in the app.
|
{{/if}}
-
+
+
|
diff --git a/src/lib/email/templates/budget-alert.html b/src/lib/email/templates/budget-alert.html
index 0ffe423..1595629 100644
--- a/src/lib/email/templates/budget-alert.html
+++ b/src/lib/email/templates/budget-alert.html
@@ -1,86 +1,206 @@
-
-
+
+
-
+
+
+
+
Budget Alert
+
+
+
+
+ You've reached {{percentage}}% of your {{categoryName}} budget
+ ͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏
+
+
- |
+ |
+
+
+ |
+
+ |
+
- |
+ |
Hi {{userName}},
@@ -89,32 +209,94 @@
style="
margin: 0 0 28px;
font-size: 15px;
- line-height: 1.6;
- color: #374151;
+ line-height: 1.7;
+ color: #4b5563;
+ "
+ class="text-secondary"
+ >
+ Your
+ {{categoryName}}
+ budget has reached
+ {{percentage}}% of its
+ limit.
+
+
+
+
+
- You have reached
- {{percentage}}% of
- your budget limit for
- {{categoryName}}.
+ {{percentage}}% used
-
+
|
Spent
|
${{spent}}
|
@@ -145,25 +329,25 @@
|
Budget Limit
|
${{limit}}
|
@@ -171,22 +355,23 @@
|
Remaining
|
${{remaining}}
@@ -197,53 +382,45 @@
|
-
- Keep track of your spending to stay within your budget. You
- can review your transactions and adjust your budget in the
- app.
-
-
-
AI Recommendations
-
+
{{aiRecommendations}}
@@ -251,28 +428,34 @@
|
-
+
+
|
diff --git a/src/lib/email/templates/monthly-summary.html b/src/lib/email/templates/monthly-summary.html
index a6159ed..7bb87c2 100644
--- a/src/lib/email/templates/monthly-summary.html
+++ b/src/lib/email/templates/monthly-summary.html
@@ -1,86 +1,208 @@
-
-
+
+
-
+
+
+
+
Monthly Summary
+
+
+
+
+ Your financial summary for {{period}} is ready
+ ͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏
+
+
- |
+ |
+
+
+ |
+
+ |
+
- |
+ |
Hi {{userName}},
@@ -89,16 +211,18 @@
style="
margin: 0 0 28px;
font-size: 15px;
- line-height: 1.6;
- color: #374151;
+ line-height: 1.7;
+ color: #4b5563;
"
+ class="text-secondary"
>
- Here is your financial summary for
- {{period}}.
+ Here's your financial overview for
+ {{period}}.
-
+
- |
+ |
|
-
+ |
|
Expenses
Net Savings
|
${{netSavings}}
|
@@ -236,44 +376,56 @@
-
Top Spending Categories
-
+
{{#each topCategories}}
|
{{name}}
|
${{amount}}
|
@@ -281,26 +433,32 @@
{{/each}}
-
+
+
|
diff --git a/src/lib/email/templates/password-reset.html b/src/lib/email/templates/password-reset.html
index 0d6d88d..117faa3 100644
--- a/src/lib/email/templates/password-reset.html
+++ b/src/lib/email/templates/password-reset.html
@@ -1,122 +1,246 @@
-
-
+
+
-
+
+
+
+
Reset Your Password
+
+
+
+
+ Reset your Trackit password
+ ͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏
+
+
- |
+ |
+
+
+ |
+
+ |
+
- |
+ |
Hi {{name}},
We received a request to reset the password for your Trackit
account. Click the button below to choose a new password.
-
+
-
+
+
+ This link expires in 1 hour for security.
+
+
+
|
Didn't request this?
@@ -155,27 +296,30 @@
line-height: 1.5;
color: #6b7280;
"
+ class="text-secondary"
>
- If you did not request a password reset, you can safely
+ If you didn't request a password reset, you can safely
ignore this email. Your password will remain unchanged.
|
+
- If the button above doesn't work, copy and paste the
- following link into your browser:
+ If the button doesn't work, copy and paste this link into your
+ browser:
{{resetUrl}}
@@ -185,27 +329,33 @@
|
+
|
diff --git a/src/lib/email/templates/transaction-alert.html b/src/lib/email/templates/transaction-alert.html
index ac05164..7a24f40 100644
--- a/src/lib/email/templates/transaction-alert.html
+++ b/src/lib/email/templates/transaction-alert.html
@@ -1,86 +1,203 @@
-
-
+
+
-
+
+
+
+
Transaction Alert
+
+
+
+
+ Large transaction of ${{amount}} detected on your account
+ ͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏
+
+
- |
+ |
+
+
+ |
+
+ |
+
- |
+ |
Hi {{userName}},
@@ -89,137 +206,139 @@
style="
margin: 0 0 28px;
font-size: 15px;
- line-height: 1.6;
- color: #374151;
+ line-height: 1.7;
+ color: #4b5563;
"
+ class="text-secondary"
>
- A large transaction was detected on your account. Please
- review the details below.
+ A large transaction of
+ ${{amount}} has been
+ recorded on your account, exceeding your alert threshold of
+ ${{threshold}}.
-
+
- |
+ |
-
- |
-
- Amount
-
-
- ${{amount}}
-
+ |
+ Amount
+ |
+
+ ${{amount}}
|
-
|
-
- Description
-
-
- {{description}}
-
+ Description
+ |
+
+ {{description}}
|
-
- |
-
- Date
-
-
- {{date}}
-
+ |
+ Date
+ |
+
+ {{date}}
|
-
- |
-
- Category
-
-
- {{category}}
-
+ |
+ Alert Threshold
+ |
+
+ ${{threshold}}
|
@@ -227,40 +346,34 @@
|
-
- If you do not recognize this transaction, please review it
- immediately and contact support if needed.
-
-
-
+
+
|
diff --git a/src/lib/email/templates/verification.html b/src/lib/email/templates/verification.html
index c83a406..9f66d5a 100644
--- a/src/lib/email/templates/verification.html
+++ b/src/lib/email/templates/verification.html
@@ -1,86 +1,203 @@
-
-
+
+
-
+
+
+
+
Verify Your Email
+
+
+
+
+ Verify your email to get started with Trackit
+ ͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏
+
+
- |
+ |
+
+
+ |
+
+ |
+
- |
+ |
Hi {{name}},
@@ -89,46 +206,55 @@
style="
margin: 0 0 8px;
font-size: 15px;
- line-height: 1.6;
- color: #374151;
+ line-height: 1.7;
+ color: #4b5563;
"
+ class="text-secondary"
>
- Welcome to Trackit. To get started, please verify your email
- address by clicking the button below.
+ Thanks for signing up for Trackit! To get started managing
+ your finances, please verify your email address by clicking
+ the button below.
This helps us keep your account secure and ensures you receive
- important updates about your finances.
+ important updates.
-
+
-
+
+
+ This link expires in 24 hours.
+
+
+
|
Didn't sign up for Trackit?
@@ -167,28 +310,30 @@
line-height: 1.5;
color: #6b7280;
"
+ class="text-secondary"
>
- If you did not create an account, you can safely ignore
- this email. No account will be activated without
- verification.
+ You can safely ignore this email. No account will be
+ activated without verification.
|
+
- If the button above doesn't work, copy and paste the
- following link into your browser:
+ If the button doesn't work, copy and paste this link into your
+ browser:
{{verificationUrl}}
@@ -198,27 +343,33 @@
|
+
|
diff --git a/src/lib/email/templates/weekly-digest.html b/src/lib/email/templates/weekly-digest.html
index d266422..2e30f2c 100644
--- a/src/lib/email/templates/weekly-digest.html
+++ b/src/lib/email/templates/weekly-digest.html
@@ -1,86 +1,208 @@
-
-
+
+
-
+
+
+
+
Weekly Digest
+
+
+
+
+ Your week in review: ${{weeklyTotal}} spent
+ ͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏͏
+
+
- |
+ |
+
+
+ |
+
+ |
+
- |
+ |
Hi {{userName}},
@@ -89,163 +211,205 @@
style="
margin: 0 0 28px;
font-size: 15px;
- line-height: 1.6;
- color: #374151;
+ line-height: 1.7;
+ color: #4b5563;
"
+ class="text-secondary"
>
- Here is your weekly spending summary.
+ Here's a quick look at your spending this week.
-
+
-
-
+
-
- |
- Total Spent This Week
- |
-
- ${{weeklyTotal}}
- |
-
-
- |
- {{changeText}} from last week
- |
-
-
+ Total Spent This Week
+
+
+ ${{weeklyTotal}}
+
+
+ {{changeText}} from last week
+
|
-
-
- This Week's Activity
-
+
|
- Transactions
- |
-
- {{transactionCount}}
- |
-
-
-
- Top Category
+
+
+ |
+
+ Transactions
+
+
+ {{transactionCount}}
+
+ |
+
+
|
- {{topCategory}}
+
+
+ |
+
+ Top Category
+
+
+ {{topCategory}}
+
+ |
+
+
|
-
+
+
|
diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts
index 73c964a..f9b5027 100644
--- a/src/lib/formatters.ts
+++ b/src/lib/formatters.ts
@@ -4,6 +4,7 @@ import {
CurrencyPosition,
ThousandSeparator,
} from "@prisma/client";
+import { CURRENCY_SYMBOLS } from "@/constants/formatting";
interface FormatOptions {
currency?: Currency;
@@ -14,20 +15,6 @@ interface FormatOptions {
compactNumbers?: boolean;
}
-const CURRENCY_SYMBOLS: Record = {
- USD: "$",
- EUR: "€",
- GBP: "£",
- JPY: "¥",
- AUD: "$",
- CAD: "$",
- CHF: "Fr",
- CNY: "¥",
- INR: "₹",
- SGD: "$",
- PKR: "₨",
-};
-
/**
* Format a numeric amount based on user preferences
*/
diff --git a/src/lib/inngest/functions/budget.ts b/src/lib/inngest/functions/budget.ts
index 6ed13e4..ec3620a 100644
--- a/src/lib/inngest/functions/budget.ts
+++ b/src/lib/inngest/functions/budget.ts
@@ -1,11 +1,11 @@
import { inngest } from "../client";
-import { TRANSACTION_PROCESSED_EVENT } from "../events";
+import { TRANSACTION_PROCESSED_EVENT } from "@/constants/events";
+import { TransactionProcessedSchema } from "@/constants/event-schemas";
import { BudgetService } from "@/server/services/budgetService";
-import { toNum } from "@/lib/shared/decimal";
+import { toNum } from "@shared/decimal";
import { NotificationService } from "@/server/services/notificationService";
import { NotificationType } from "@prisma/client";
-import { sendEmail } from "@/lib/email";
-import { getTemplate } from "@/lib/email/template-cache";
+import { sendEmail, compileTemplate } from "@/lib/email";
import { env } from "@/env";
import { db } from "@/server/db";
import { AIService } from "@/server/services/aiService";
@@ -19,13 +19,7 @@ export const evaluateBudgetOnTransaction = inngest.createFunction(
{ event: TRANSACTION_PROCESSED_EVENT },
async ({ event, step }) => {
const { userId, categoryId, date, accountId, transactionId } =
- event.data as {
- userId: string;
- transactionId: string;
- accountId: string;
- categoryId: string | null;
- date: string;
- };
+ TransactionProcessedSchema.parse(event.data);
// 1. Core Budget Re-evaluation
if (categoryId) {
@@ -33,7 +27,7 @@ export const evaluateBudgetOnTransaction = inngest.createFunction(
await BudgetService.evaluateBudgets({
userId,
categoryId,
- date: new Date(date),
+ date,
});
});
}
@@ -42,6 +36,7 @@ export const evaluateBudgetOnTransaction = inngest.createFunction(
await step.run("check-thresholds", async () => {
const transaction = await db.transaction.findUnique({
where: { id: transactionId },
+ select: { amount: true, description: true },
});
if (transaction) {
await BudgetService.checkLargeTransaction(
@@ -77,22 +72,17 @@ export const evaluateBudgetOnTransaction = inngest.createFunction(
});
// 2. Immediate Email Alert
- let template = await getTemplate("ai-insight.html");
-
- template = template
- .replace(/{{userName}}/g, user.name ?? "there")
- .replace(
- /{{aiContent}}/g,
- `${severe.reason}. We recommend reviewing your recent transactions for accuracy.`,
- )
- .replace(/{{appUrl}}/g, env.NEXT_PUBLIC_APP_URL ?? "")
- .replace("{{#if hasAnomalies}}", "")
- .replace("{{/if}}", "");
+ const emailHtml = await compileTemplate("ai-insight.html", {
+ userName: user.name ?? "there",
+ aiContent: `${severe.reason}. We recommend reviewing your recent transactions for accuracy.`,
+ appUrl: env.NEXT_PUBLIC_APP_URL ?? "",
+ hasAnomalies: true,
+ });
await sendEmail({
to: user.email,
subject: "High Priority: Unusual Activity Detected",
- html: template,
+ html: emailHtml,
});
}
}
diff --git a/src/lib/inngest/functions/generate-monthly-report.ts b/src/lib/inngest/functions/generate-monthly-report.ts
index e831366..79cbf30 100644
--- a/src/lib/inngest/functions/generate-monthly-report.ts
+++ b/src/lib/inngest/functions/generate-monthly-report.ts
@@ -1,7 +1,6 @@
import { db } from "@/server/db";
import { subMonths, format } from "date-fns";
-import { sendEmail } from "@/lib/email";
-import { getTemplate } from "@/lib/email/template-cache";
+import { sendEmail, compileTemplate } from "@/lib/email";
import { env } from "@/env";
import { createLogger } from "@/lib/logging";
import { inngest } from "../client";
@@ -90,22 +89,18 @@ export const generateMonthlyReport = inngest.createFunction(
const { ReportService } = await import("@/server/services/reportService");
for (const result of results) {
- const res = result as {
- success: boolean;
- reportId: string | null;
- insights: string | null;
- };
- if (!res.success || !res.reportId) continue;
-
- const report = (await db.report.findUnique({
+ if (!result.success || !result.reportId) continue;
+ const res = result;
+
+ const report = await db.report.findUnique({
where: { id: res.reportId },
- include: { user: true },
- })) as unknown as {
- user: { name: string | null; email: string | null };
- id: string;
- period: string;
- data: Record;
- } | null;
+ select: {
+ id: true,
+ period: true,
+ data: true,
+ user: { select: { name: true, email: true } },
+ },
+ });
if (!report) continue;
@@ -117,51 +112,27 @@ export const generateMonthlyReport = inngest.createFunction(
topCategories: Array<{ name: string; amount: number }>;
};
- // Read monthly summary template from cache
- let template = await getTemplate("monthly-summary.html");
-
- // Replace variables with actual data
- template = template
- .replace(/{{userName}}/g, report.user.name ?? "User")
- .replace(/{{period}}/g, report.period)
- .replace(/{{totalIncome}}/g, reportData.totalIncome.toFixed(2))
- .replace(/{{totalExpenses}}/g, reportData.totalExpenses.toFixed(2))
- .replace(/{{netSavings}}/g, reportData.netSavings.toFixed(2))
- .replace(
- /{{netSavingsColor}}/g,
- reportData.netSavings >= 0 ? "#10b981" : "#ef4444",
- )
- .replace(/{{appUrl}}/g, env.NEXT_PUBLIC_APP_URL ?? "")
- .replace(
- /{{aiInsights}}/g,
+ // Compile monthly summary template
+ const emailHtml = await compileTemplate("monthly-summary.html", {
+ userName: report.user.name ?? "User",
+ period: report.period,
+ totalIncome: reportData.totalIncome.toFixed(2),
+ totalExpenses: reportData.totalExpenses.toFixed(2),
+ netSavings: reportData.netSavings.toFixed(2),
+ netSavingsColor: reportData.netSavings >= 0 ? "#10b981" : "#ef4444",
+ appUrl: env.NEXT_PUBLIC_APP_URL ?? "",
+ aiInsights:
res.insights ?? "No AI insights available for this period.",
- );
-
- // Replace topCategories loop
- const topCategoriesHtml = reportData.topCategories
- .map(
- (cat) => `
-
- |
- ${cat.name}
- |
-
- $${cat.amount.toFixed(2)}
- |
-
- `,
- )
- .join("");
-
- template = template.replace(
- /{{#each topCategories}}[\s\S]*?{{\/each}}/,
- topCategoriesHtml,
- );
+ topCategories: reportData.topCategories.map((cat) => ({
+ name: cat.name,
+ amount: cat.amount.toFixed(2),
+ })),
+ });
await sendEmail({
to: report.user.email ?? "",
subject: `Monthly Financial Summary - ${report.period}`,
- html: template,
+ html: emailHtml,
});
await ReportService.markAsSent(report.id, report.user.email ?? "");
diff --git a/src/lib/inngest/functions/recurring.ts b/src/lib/inngest/functions/recurring.ts
index 7872719..c253003 100644
--- a/src/lib/inngest/functions/recurring.ts
+++ b/src/lib/inngest/functions/recurring.ts
@@ -1,13 +1,13 @@
import { inngest } from "../client";
-import { RECURRING_EVENT, enqueueRecurringRun } from "../events";
+import { RECURRING_EVENT, enqueueRecurringRun } from "@/constants/events";
+import { RecurringSchema } from "@/constants/event-schemas";
import { createLogger } from "@/lib/logging";
import { db } from "@/server/db";
const logger = createLogger("inngest-recurring");
import { calculateNextRunAt } from "@/lib/recurrence";
import { sendEmail } from "@/lib/email";
-import { toNum } from "@/lib/shared/decimal";
-import type { RecurringRule } from "@prisma/client";
+import { toNum } from "@shared/decimal";
import { RecurringStatus } from "@prisma/client";
import type { RecurrenceConfig } from "@/types/recurrence";
@@ -18,19 +18,32 @@ export const processRecurringTransaction = inngest.createFunction(
},
{ event: RECURRING_EVENT },
async ({ event }) => {
- const ruleId = (event.data as { ruleId?: string } | undefined)?.ruleId;
- if (!ruleId) return;
+ const { ruleId } = RecurringSchema.parse(event.data);
- const rawRule = await db.recurringRule.findUnique({
+ const rule = await db.recurringRule.findUnique({
where: { id: ruleId },
- include: { user: true },
+ select: {
+ id: true,
+ userId: true,
+ accountId: true,
+ categoryId: true,
+ amount: true,
+ type: true,
+ description: true,
+ notes: true,
+ frequency: true,
+ interval: true,
+ dayOfMonth: true,
+ dayOfWeek: true,
+ startDate: true,
+ endDate: true,
+ nextRunAt: true,
+ status: true,
+ user: { select: { email: true } },
+ },
});
- if (!rawRule) return;
- if (rawRule.status !== RecurringStatus.ACTIVE) return;
-
- const rule = rawRule as RecurringRule & {
- user?: { email?: string | null } | null;
- };
+ if (!rule) return;
+ if (rule.status !== RecurringStatus.ACTIVE) return;
if (rule.nextRunAt > new Date()) {
return;
@@ -38,22 +51,6 @@ export const processRecurringTransaction = inngest.createFunction(
const runAt = new Date();
- // Create the transaction for this recurrence instance
- const createdTx = await db.transaction.create({
- data: {
- userId: rule.userId,
- accountId: rule.accountId,
- categoryId: rule.categoryId ?? null,
- amount: rule.amount,
- type: rule.type,
- description: rule.description ?? null,
- notes: rule.notes ?? null,
- date: runAt,
- isRecurring: true,
- recurringRuleId: rule.id,
- },
- });
-
const cfg: RecurrenceConfig = {
frequency: rule.frequency as RecurrenceConfig["frequency"],
interval: rule.interval ?? 1,
@@ -78,16 +75,33 @@ export const processRecurringTransaction = inngest.createFunction(
const nextRunAt = calculateNextRunAt(cfg, runAt);
- // Persist updates to the rule. Only include `nextRunAt` when we computed a new value;
- // otherwise leave it untouched (schema requires nextRunAt to be non-nullable).
- const updateData = {
- lastRunAt: runAt,
- lastTransactionId: createdTx.id,
- status: nextRunAt ? RecurringStatus.ACTIVE : RecurringStatus.ENDED,
- ...(nextRunAt && { nextRunAt }),
- };
+ // Atomically create transaction and update rule to prevent duplicates on crash
+ await db.$transaction(async (tx) => {
+ const created = await tx.transaction.create({
+ data: {
+ userId: rule.userId,
+ accountId: rule.accountId,
+ categoryId: rule.categoryId ?? null,
+ amount: rule.amount,
+ type: rule.type,
+ description: rule.description ?? null,
+ notes: rule.notes ?? null,
+ date: runAt,
+ isRecurring: true,
+ recurringRuleId: rule.id,
+ },
+ });
- await db.recurringRule.update({ where: { id: rule.id }, data: updateData });
+ await tx.recurringRule.update({
+ where: { id: rule.id },
+ data: {
+ lastRunAt: runAt,
+ lastTransactionId: created.id,
+ status: nextRunAt ? RecurringStatus.ACTIVE : RecurringStatus.ENDED,
+ ...(nextRunAt && { nextRunAt }),
+ },
+ });
+ });
// Schedule the next occurrence, if any
if (nextRunAt) {
@@ -130,7 +144,14 @@ export const notifyUpcomingRecurring = inngest.createFunction(
lte: tomorrow,
},
},
- include: { user: true },
+ select: {
+ id: true,
+ userId: true,
+ description: true,
+ amount: true,
+ nextRunAt: true,
+ user: { select: { email: true } },
+ },
});
});
diff --git a/src/lib/inngest/functions/send-ai-insights.ts b/src/lib/inngest/functions/send-ai-insights.ts
index 20d4bfc..32b4afd 100644
--- a/src/lib/inngest/functions/send-ai-insights.ts
+++ b/src/lib/inngest/functions/send-ai-insights.ts
@@ -3,8 +3,7 @@ import { createLogger } from "@/lib/logging";
import { db } from "@/server/db";
const logger = createLogger("inngest-ai-insights");
-import { sendEmail } from "@/lib/email";
-import { getTemplate } from "@/lib/email/template-cache";
+import { sendEmail, compileTemplate } from "@/lib/email";
import { env } from "@/env";
import { format, subDays } from "date-fns";
@@ -59,19 +58,17 @@ export const sendAiInsights = inngest.createFunction(
if (insights) {
await step.run(`send-email-${user.id}`, async () => {
- // Read template from cache
- let template = await getTemplate("ai-insight.html");
-
- template = template
- .replace(/{{userName}}/g, user.name ?? "there")
- .replace(/{{aiContent}}/g, insights)
- .replace(/{{appUrl}}/g, env.NEXT_PUBLIC_APP_URL ?? "")
- .replace(/{{#if hasAnomalies}}[\s\S]*?{{\/if}}/, ""); // Cleanup if tag not used
+ const emailHtml = await compileTemplate("ai-insight.html", {
+ userName: user.name ?? "there",
+ aiContent: insights,
+ appUrl: env.NEXT_PUBLIC_APP_URL ?? "",
+ hasAnomalies: false,
+ });
await sendEmail({
to: user.email,
subject: "Your AI Spending Analysis",
- html: template,
+ html: emailHtml,
});
});
results.push({ userId: user.id, success: true });
diff --git a/src/lib/inngest/functions/send-budget-alert-email.ts b/src/lib/inngest/functions/send-budget-alert-email.ts
index 36d2115..5acad3b 100644
--- a/src/lib/inngest/functions/send-budget-alert-email.ts
+++ b/src/lib/inngest/functions/send-budget-alert-email.ts
@@ -1,13 +1,14 @@
import { inngest } from "@/lib/inngest/client";
+import { NonRetriableError } from "inngest";
import { createLogger } from "@/lib/logging";
import { db } from "@/server/db";
-import { toNum } from "@/lib/shared/decimal";
+import { toNum } from "@shared/decimal";
const logger = createLogger("inngest-budget-alert");
-import { sendEmail } from "@/lib/email";
-import { getTemplate } from "@/lib/email/template-cache";
+import { sendEmail, compileTemplate } from "@/lib/email";
import { env } from "@/env";
-import { BUDGET_THRESHOLD_REACHED_EVENT } from "@/lib/inngest/events";
+import { BUDGET_THRESHOLD_REACHED_EVENT } from "@/constants/events";
+import { BudgetThresholdSchema } from "@/constants/event-schemas";
/**
* Send Budget Alert Email
@@ -21,20 +22,26 @@ export const sendBudgetAlertEmail = inngest.createFunction(
{ event: BUDGET_THRESHOLD_REACHED_EVENT },
async ({ event, step }) => {
- const { budgetId, userId, threshold } = event.data as {
- budgetId: string;
- userId: string;
- threshold: number;
- };
+ const { budgetId, userId, threshold } = BudgetThresholdSchema.parse(
+ event.data,
+ );
const budget = await step.run("fetch-budget", async () => {
return db.budget.findUnique({
where: { id: budgetId },
- include: {
- category: true,
+ select: {
+ id: true,
+ userId: true,
+ spentAmount: true,
+ amount: true,
+ category: { select: { name: true } },
user: {
- include: {
- notificationPrefs: true,
+ select: {
+ name: true,
+ email: true,
+ notificationPrefs: {
+ select: { emailBalanceAlerts: true },
+ },
},
},
},
@@ -42,11 +49,11 @@ export const sendBudgetAlertEmail = inngest.createFunction(
});
if (!budget) {
- throw new Error(`Budget ${budgetId} not found`);
+ throw new NonRetriableError(`Budget ${budgetId} not found`);
}
if (budget.userId !== userId) {
- throw new Error(`Budget does not belong to user ${userId}`);
+ throw new NonRetriableError(`Budget does not belong to user ${userId}`);
}
await step.run("send-email", async () => {
@@ -56,11 +63,9 @@ export const sendBudgetAlertEmail = inngest.createFunction(
const percentage = (spent / limit) * 100;
const remaining = Math.max(0, limit - spent);
- // Read budget alert template from cache
- let template = await getTemplate("budget-alert.html");
-
// Fetch AI recommendations if threshold is high
- let recommendations = "";
+ let aiRecommendations =
+ "Stay mindful of your spending to keep within your budget targets.";
if (threshold >= 90) {
try {
const { AIService } = await import("@/server/services/aiService");
@@ -71,7 +76,7 @@ export const sendBudgetAlertEmail = inngest.createFunction(
typeof aiResult === "object" &&
"recommendations" in aiResult
) {
- recommendations = Array.isArray(aiResult.recommendations)
+ aiRecommendations = Array.isArray(aiResult.recommendations)
? aiResult.recommendations.join("\n")
: String(aiResult.recommendations);
}
@@ -82,19 +87,16 @@ export const sendBudgetAlertEmail = inngest.createFunction(
}
}
- template = template
- .replace(/{{userName}}/g, budget.user.name)
- .replace(/{{categoryName}}/g, budget.category.name)
- .replace(/{{percentage}}/g, percentage.toFixed(0))
- .replace(/{{spent}}/g, spent.toFixed(2))
- .replace(/{{limit}}/g, limit.toFixed(2))
- .replace(/{{remaining}}/g, remaining.toFixed(2))
- .replace(/{{appUrl}}/g, env.NEXT_PUBLIC_APP_URL ?? "")
- .replace(
- /{{aiRecommendations}}/g,
- recommendations ||
- "Stay mindful of your spending to keep within your budget targets.",
- );
+ const template = await compileTemplate("budget-alert.html", {
+ userName: budget.user.name,
+ categoryName: budget.category.name,
+ percentage: percentage.toFixed(0),
+ spent: spent.toFixed(2),
+ limit: limit.toFixed(2),
+ remaining: remaining.toFixed(2),
+ appUrl: env.NEXT_PUBLIC_APP_URL ?? "",
+ aiRecommendations,
+ });
try {
if (budget.user.notificationPrefs?.emailBalanceAlerts) {
diff --git a/src/lib/inngest/functions/send-transaction-alert-email.ts b/src/lib/inngest/functions/send-transaction-alert-email.ts
new file mode 100644
index 0000000..463f88f
--- /dev/null
+++ b/src/lib/inngest/functions/send-transaction-alert-email.ts
@@ -0,0 +1,74 @@
+import { inngest } from "@/lib/inngest/client";
+import { NonRetriableError } from "inngest";
+import { createLogger } from "@/lib/logging";
+import { db } from "@/server/db";
+import { TRANSACTION_ALERT_EVENT } from "@/constants/events";
+import { TransactionAlertSchema } from "@/constants/event-schemas";
+import { sendTemplateEmail } from "@/lib/email";
+
+const logger = createLogger("inngest-transaction-alert");
+
+/**
+ * Send Transaction Alert Email
+ * Triggered when a transaction exceeds the user's large transaction threshold.
+ */
+export const sendTransactionAlertEmail = inngest.createFunction(
+ {
+ id: "send-transaction-alert-email",
+ name: "Send Transaction Alert Email",
+ },
+ { event: TRANSACTION_ALERT_EVENT },
+
+ async ({ event, step }) => {
+ const { userId, amount, description, threshold } =
+ TransactionAlertSchema.parse(event.data);
+
+ const user = await step.run("fetch-user", async () => {
+ return db.user.findUnique({
+ where: { id: userId },
+ select: { name: true, email: true },
+ });
+ });
+
+ if (!user) {
+ throw new NonRetriableError(
+ `User ${userId} not found for transaction alert`,
+ );
+ }
+
+ await step.run("send-email", async () => {
+ try {
+ await sendTemplateEmail({
+ to: user.email,
+ subject: `Large Transaction Alert: $${amount.toFixed(2)}`,
+ template: "transaction-alert",
+ data: {
+ userName: user.name,
+ amount: amount.toFixed(2),
+ description,
+ threshold: threshold.toFixed(2),
+ date: new Date().toLocaleDateString("en-US", {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }),
+ },
+ });
+
+ logger.info("Transaction alert email sent", {
+ userId,
+ amount,
+ threshold,
+ });
+
+ return { success: true, emailSent: true };
+ } catch (error) {
+ logger.error("Failed to send transaction alert email", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
+ });
+ },
+);
diff --git a/src/lib/inngest/functions/send-weekly-digest.ts b/src/lib/inngest/functions/send-weekly-digest.ts
index 54c2b38..36927de 100644
--- a/src/lib/inngest/functions/send-weekly-digest.ts
+++ b/src/lib/inngest/functions/send-weekly-digest.ts
@@ -1,12 +1,11 @@
import { inngest } from "@/lib/inngest/client";
import { createLogger } from "@/lib/logging";
import { db } from "@/server/db";
-import { toNum } from "@/lib/shared/decimal";
+import { toNum } from "@shared/decimal";
const logger = createLogger("inngest-weekly-digest");
import { startOfWeek, endOfWeek, subWeeks, format } from "date-fns";
-import { sendEmail } from "@/lib/email";
-import { getTemplate } from "@/lib/email/template-cache";
+import { sendEmail, compileTemplate } from "@/lib/email";
import { env } from "@/env";
interface DigestResult {
@@ -57,43 +56,61 @@ export const sendWeeklyDigest = inngest.createFunction(
for (const user of users) {
try {
- // Get week's transactions
- const transactions = await db.transaction.findMany({
- where: {
- userId: user.id,
- date: {
- gte: weekStart,
- lte: weekEnd,
- },
- },
- include: {
- category: true,
- },
- });
-
- const totalIncome = transactions
- .filter((t) => t.type === "CREDIT")
- .reduce((sum, t) => sum + toNum(t.amount), 0);
-
- const totalExpenses = transactions
- .filter((t) => t.type === "DEBIT")
- .reduce((sum, t) => sum + toNum(t.amount), 0);
-
- const categorySpending = transactions
- .filter((t) => t.type === "DEBIT")
- .reduce(
- (acc, t) => {
- const category = t.category?.name ?? "Uncategorized";
- acc[category] = (acc[category] ?? 0) + toNum(t.amount);
- return acc;
- },
- {} as Record,
- );
-
- const topCategories = Object.entries(categorySpending)
- .sort(([, a], [, b]) => b - a)
- .slice(0, 5)
- .map(([name, amount]) => ({ name, amount }));
+ const dateFilter = {
+ userId: user.id,
+ date: { gte: weekStart, lte: weekEnd },
+ };
+
+ const [
+ incomeResult,
+ expenseResult,
+ spendingByCategory,
+ transactionCount,
+ ] = await Promise.all([
+ db.transaction.aggregate({
+ where: { ...dateFilter, type: "CREDIT" },
+ _sum: { amount: true },
+ }),
+ db.transaction.aggregate({
+ where: { ...dateFilter, type: "DEBIT" },
+ _sum: { amount: true },
+ }),
+ db.transaction.groupBy({
+ by: ["categoryId"],
+ where: { ...dateFilter, type: "DEBIT" },
+ _sum: { amount: true },
+ }),
+ db.transaction.count({ where: dateFilter }),
+ ]);
+
+ const totalIncome = incomeResult._sum.amount
+ ? toNum(incomeResult._sum.amount)
+ : 0;
+ const totalExpenses = expenseResult._sum.amount
+ ? toNum(expenseResult._sum.amount)
+ : 0;
+
+ const catIds = spendingByCategory
+ .map((g) => g.categoryId)
+ .filter((id): id is string => id !== null);
+ const cats =
+ catIds.length > 0
+ ? await db.category.findMany({
+ where: { id: { in: catIds } },
+ select: { id: true, name: true },
+ })
+ : [];
+ const catNameMap = new Map(cats.map((c) => [c.id, c.name]));
+
+ const topCategories = spendingByCategory
+ .map((g) => ({
+ name: g.categoryId
+ ? (catNameMap.get(g.categoryId) ?? "Uncategorized")
+ : "Uncategorized",
+ amount: g._sum.amount ? toNum(g._sum.amount) : 0,
+ }))
+ .sort((a, b) => b.amount - a.amount)
+ .slice(0, 5);
// Create report record
await db.report.create({
@@ -109,7 +126,7 @@ export const sendWeeklyDigest = inngest.createFunction(
totalIncome,
totalExpenses,
netSavings: totalIncome - totalExpenses,
- transactionCount: transactions.length,
+ transactionCount,
topCategories,
},
},
@@ -144,8 +161,6 @@ export const sendWeeklyDigest = inngest.createFunction(
});
await step.run("send-emails", async () => {
- const template = await getTemplate("weekly-digest.html");
-
for (const result of results) {
if (!result.success) continue;
@@ -153,48 +168,24 @@ export const sendWeeklyDigest = inngest.createFunction(
if (!user) continue;
try {
- const res = result;
- let emailHtml = template
- .replace(/{{userName}}/g, user.name)
- .replace(/{{period}}/g, period)
- .replace(/{{totalIncome}}/g, (res.totalIncome ?? 0).toFixed(2))
- .replace(/{{totalExpenses}}/g, (res.totalExpenses ?? 0).toFixed(2))
- .replace(
- /{{netSavings}}/g,
- ((res.totalIncome ?? 0) - (res.totalExpenses ?? 0)).toFixed(2),
- )
- .replace(
- /{{netSavingsColor}}/g,
- (res.totalIncome ?? 0) - (res.totalExpenses ?? 0) >= 0
- ? "#10b981"
- : "#ef4444",
- )
- .replace(/{{appUrl}}/g, env.NEXT_PUBLIC_APP_URL ?? "")
- .replace(
- /{{aiAnomalies}}/g,
- (res as { anomalies?: string | null }).anomalies ??
- "No unusual activity detected this week.",
- );
-
- const topCategoriesHtml = (res.topCategories ?? [])
- .map(
- (cat: { name: string; amount: number }) => `
-
- |
- ${cat.name}
- |
-
- $${cat.amount.toFixed(2)}
- |
-
- `,
- )
- .join("");
-
- emailHtml = emailHtml.replace(
- /{{#each topCategories}}[\s\S]*?{{\/each}}/,
- topCategoriesHtml,
- );
+ const netSavings =
+ (result.totalIncome ?? 0) - (result.totalExpenses ?? 0);
+
+ const emailHtml = await compileTemplate("weekly-digest.html", {
+ userName: user.name,
+ period,
+ totalIncome: (result.totalIncome ?? 0).toFixed(2),
+ totalExpenses: (result.totalExpenses ?? 0).toFixed(2),
+ netSavings: netSavings.toFixed(2),
+ netSavingsColor: netSavings >= 0 ? "#10b981" : "#ef4444",
+ appUrl: env.NEXT_PUBLIC_APP_URL ?? "",
+ aiAnomalies:
+ result.anomalies ?? "No unusual activity detected this week.",
+ topCategories: (result.topCategories ?? []).map((cat) => ({
+ name: cat.name,
+ amount: cat.amount.toFixed(2),
+ })),
+ });
await sendEmail({
to: user.email,
diff --git a/src/lib/logging/index.ts b/src/lib/logging/index.ts
index acd5953..99b7532 100644
--- a/src/lib/logging/index.ts
+++ b/src/lib/logging/index.ts
@@ -1,5 +1,7 @@
import { Logger as BetterStackLogger } from "@logtail/next";
+// Use process.env directly so this module is safe to import in client components.
+// The T3 `env` helper restricts server-only vars and would throw on the client.
const isProd = process.env.NODE_ENV === "production";
type LogMeta = Record;
diff --git a/src/lib/server/utils.ts b/src/lib/server/utils.ts
deleted file mode 100644
index 48e1aad..0000000
--- a/src/lib/server/utils.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import path from "path";
-import fs from "fs";
-
-export function renderTemplate(
- templateName: string,
- vars: Record,
-) {
- const templatePath = path.join(
- process.cwd(),
- "src",
- "lib",
- "email",
- "templates",
- `${templateName}.html`,
- );
- let html = fs.readFileSync(templatePath, "utf8");
- Object.entries(vars).forEach(([key, value]) => {
- html = html.replace(new RegExp(`{{${key}}}`, "g"), value);
- });
- return html;
-}
diff --git a/src/lib/shared/avatar.ts b/src/lib/shared/avatar.ts
index 38501da..b0bcf2a 100644
--- a/src/lib/shared/avatar.ts
+++ b/src/lib/shared/avatar.ts
@@ -1,13 +1,7 @@
import type { Gender } from "@/types/user";
+import { DEFAULT_AVATARS } from "@/constants/defaults";
-/**
- * Default avatar URLs for the application.
- * These are used as fallbacks when no custom avatar is provided.
- */
-export const DEFAULT_AVATARS = {
- BOY: "https://avatar.iran.liara.run/public/boy",
- GIRL: "https://avatar.iran.liara.run/public/girl",
-} as const;
+export { DEFAULT_AVATARS } from "@/constants/defaults";
/**
* Generate a URL for a user's avatar using their name
diff --git a/src/server/api/routers/accountRouter.ts b/src/server/api/routers/accountRouter.ts
index 08fe057..5a71000 100644
--- a/src/server/api/routers/accountRouter.ts
+++ b/src/server/api/routers/accountRouter.ts
@@ -58,10 +58,9 @@ export const accountRouter = createTRPCRouter({
const prisma = ctx.db;
const aRaw = await prisma.bankAccount.findUnique({
- where: { id: input.id },
+ where: { id: input.id, userId },
select: {
id: true,
- userId: true,
name: true,
type: true,
currency: true,
@@ -74,7 +73,7 @@ export const accountRouter = createTRPCRouter({
},
});
const a = aRaw as RawAccount | null;
- if (a?.userId !== userId) return null;
+ if (!a) return null;
return {
id: a.id,
name: a.name,
diff --git a/src/server/api/routers/aiRouter.ts b/src/server/api/routers/aiRouter.ts
index 8f61966..e893e23 100644
--- a/src/server/api/routers/aiRouter.ts
+++ b/src/server/api/routers/aiRouter.ts
@@ -1,3 +1,4 @@
+import { TRPCError } from "@trpc/server";
import { createTRPCRouter } from "@/server/api/trpc";
import { aiRateLimitedProcedure } from "@/server/api/trpc";
import { AIService } from "@/server/services/aiService";
@@ -28,7 +29,21 @@ export const aiRouter = createTRPCRouter({
scanReceipt: aiRateLimitedProcedure
.input(scanReceiptSchema)
- .mutation(async ({ input }) => {
+ .mutation(async ({ ctx, input }) => {
+ // Verify category ownership if categories provided
+ if (input.categories?.length) {
+ const categoryIds = input.categories.map((c) => c.id);
+ const owned = await ctx.db.category.findMany({
+ where: { id: { in: categoryIds }, userId: ctx.user.id },
+ select: { id: true },
+ });
+ if (owned.length !== categoryIds.length) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "One or more categories not found",
+ });
+ }
+ }
return AIService.scanReceiptWithAI({
extractedText: input.extractedText ?? null,
imageUrl: input.imageUrl ?? null,
@@ -38,7 +53,19 @@ export const aiRouter = createTRPCRouter({
categorizeTransactions: aiRateLimitedProcedure
.input(categorizeTransactionsSchema)
- .mutation(async ({ input }) => {
+ .mutation(async ({ ctx, input }) => {
+ // Verify category ownership
+ const categoryIds = input.categories.map((c) => c.id);
+ const owned = await ctx.db.category.findMany({
+ where: { id: { in: categoryIds }, userId: ctx.user.id },
+ select: { id: true },
+ });
+ if (owned.length !== categoryIds.length) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "One or more categories not found",
+ });
+ }
return AIService.categorizeTransactionsWithAI(
input.transactions,
input.categories,
diff --git a/src/server/api/routers/categoryRouter.ts b/src/server/api/routers/categoryRouter.ts
index c66b587..1b4e8de 100644
--- a/src/server/api/routers/categoryRouter.ts
+++ b/src/server/api/routers/categoryRouter.ts
@@ -57,10 +57,9 @@ export const categoryRouter = createTRPCRouter({
.input(categoryIdParam)
.query(async ({ ctx, input }) => {
const cat = await ctx.db.category.findUnique({
- where: { id: input.id },
+ where: { id: input.id, userId: ctx.user.id },
select: {
id: true,
- userId: true,
name: true,
type: true,
color: true,
@@ -69,7 +68,7 @@ export const categoryRouter = createTRPCRouter({
parentCategoryId: true,
},
});
- if (cat?.userId !== ctx.user.id) {
+ if (!cat) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Category not found",
@@ -119,21 +118,24 @@ export const categoryRouter = createTRPCRouter({
message: "Cannot set category as its own parent",
});
}
+ const allCategories = await ctx.db.category.findMany({
+ where: { userId: ctx.user.id },
+ select: { id: true, parentCategoryId: true },
+ });
+ const parentMap = new Map(
+ allCategories.map((c) => [c.id, c.parentCategoryId]),
+ );
let ancestorId: string | null = input.parentCategoryId;
while (ancestorId) {
- const ancestor: { parentCategoryId: string | null } | null =
- await ctx.db.category.findUnique({
- where: { id: ancestorId },
- select: { parentCategoryId: true },
- });
- if (!ancestor) break;
- if (ancestor.parentCategoryId === input.id) {
+ const parentId = parentMap.get(ancestorId);
+ if (parentId === undefined) break;
+ if (parentId === input.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot create a circular parent-child relationship",
});
}
- ancestorId = ancestor.parentCategoryId;
+ ancestorId = parentId;
}
}
@@ -252,6 +254,13 @@ export const categoryRouter = createTRPCRouter({
});
}
// Prevent cycle: check that newParent is not a descendant of this category
+ const allCategories = await ctx.db.category.findMany({
+ where: { userId: ctx.user.id },
+ select: { id: true, parentCategoryId: true },
+ });
+ const parentMap = new Map(
+ allCategories.map((c) => [c.id, c.parentCategoryId]),
+ );
let ancestorId: string | null = newParent.parentCategoryId;
while (ancestorId) {
if (ancestorId === input.id) {
@@ -260,11 +269,7 @@ export const categoryRouter = createTRPCRouter({
message: "Cannot create a circular parent-child relationship",
});
}
- const ancestor = await ctx.db.category.findUnique({
- where: { id: ancestorId },
- select: { parentCategoryId: true },
- });
- ancestorId = ancestor?.parentCategoryId ?? null;
+ ancestorId = parentMap.get(ancestorId) ?? null;
}
}
diff --git a/src/server/api/routers/overviewRouter.ts b/src/server/api/routers/overviewRouter.ts
index 7ea6108..82c3468 100644
--- a/src/server/api/routers/overviewRouter.ts
+++ b/src/server/api/routers/overviewRouter.ts
@@ -2,7 +2,7 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { format, subMonths, startOfMonth, endOfMonth } from "date-fns";
-import { toNum } from "@/lib/shared/decimal";
+import { toNum } from "@shared/decimal";
export const overviewRouter = createTRPCRouter({
/**
diff --git a/src/server/api/routers/reportRouter.ts b/src/server/api/routers/reportRouter.ts
index 0624e06..fa3d4d1 100644
--- a/src/server/api/routers/reportRouter.ts
+++ b/src/server/api/routers/reportRouter.ts
@@ -1,6 +1,6 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { createLogger } from "@/lib/logging";
-import { sendEmail } from "@/lib/email";
+import { sendTemplateEmail } from "@/lib/email";
const logger = createLogger("reportRouter");
import { TRPCError } from "@trpc/server";
@@ -63,15 +63,30 @@ export const reportRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND", message: "Report not found" });
}
+ const userEmail = ctx.user.email;
+ if (!userEmail) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No email address on account",
+ });
+ }
+
// Use the actual email service to resend
try {
- await sendEmail({
- to: ctx.user.email ?? report.emailSentTo ?? "",
+ await sendTemplateEmail({
+ to: userEmail,
subject: `Report: ${report.type} - ${report.period}`,
- html: `Report content for ${report.type} ${JSON.stringify(report.data, null, 2)}`,
+ template: "monthly-summary",
+ data: {
+ userName: ctx.user.name ?? "User",
+ period: report.period,
+ ...(typeof report.data === "object" && report.data !== null
+ ? (report.data as Record)
+ : {}),
+ },
});
- await ReportService.markAsSent(report.id, ctx.user.email ?? "");
+ await ReportService.markAsSent(report.id, userEmail);
return { success: true };
} catch (error) {
diff --git a/src/server/api/routers/settingsRouter.ts b/src/server/api/routers/settingsRouter.ts
index e6a5bd5..aad7cea 100644
--- a/src/server/api/routers/settingsRouter.ts
+++ b/src/server/api/routers/settingsRouter.ts
@@ -4,6 +4,7 @@ import {
updateDisplaySchema,
updateRegionalSchema,
} from "@/validation/settings";
+import { toNum } from "@shared/decimal";
export const settingsRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
@@ -28,7 +29,20 @@ export const settingsRouter = createTRPCRouter({
}),
]);
- return { preferences, display, notifications };
+ // Convert Decimal fields to numbers so they serialize across RSC → Client boundary
+ return {
+ preferences,
+ display,
+ notifications: {
+ ...notifications,
+ lowBalanceThreshold: notifications.lowBalanceThreshold
+ ? toNum(notifications.lowBalanceThreshold)
+ : null,
+ largeTransactionThreshold: notifications.largeTransactionThreshold
+ ? toNum(notifications.largeTransactionThreshold)
+ : null,
+ },
+ };
}),
updateNotifications: protectedProcedure
diff --git a/src/server/api/routers/transactionRouter.ts b/src/server/api/routers/transactionRouter.ts
index b18c497..b2fa010 100644
--- a/src/server/api/routers/transactionRouter.ts
+++ b/src/server/api/routers/transactionRouter.ts
@@ -14,7 +14,7 @@ import { calculateNextRunAt } from "@/lib/recurrence";
import {
enqueueRecurringRun,
emitTransactionProcessed,
-} from "@/lib/inngest/events";
+} from "@/constants/events";
import {
createTransactionSchema,
updateTransactionSchema,
@@ -533,14 +533,18 @@ export const transactionRouter = createTRPCRouter({
z.object({
transactionId: z.string().optional(),
fileDataUrl: z.string().min(1).max(10_000_000), // ~7.5MB base64 limit
- fileName: z.string().optional(),
+ fileName: z
+ .string()
+ .regex(/^[\w\-.]+$/, "Invalid file name characters")
+ .max(255)
+ .optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const prisma = ctx.db;
const userId = ctx.user.id;
// Lazy import of server-side upload helper
- const { uploadImage } = await import("@/lib/shared/imagekit");
+ const { uploadImage } = await import("@shared/imagekit");
const res = await uploadImage({
file: input.fileDataUrl,
diff --git a/src/server/api/routers/userRouter.ts b/src/server/api/routers/userRouter.ts
index 938ff42..9a6e97e 100644
--- a/src/server/api/routers/userRouter.ts
+++ b/src/server/api/routers/userRouter.ts
@@ -4,7 +4,7 @@ import {
publicProcedure,
protectedProcedure,
} from "@/server/api/trpc";
-import { uploadForProfile } from "@/lib/shared/imagekit";
+import { uploadForProfile } from "@shared/imagekit";
import { TRPCError } from "@trpc/server";
import type { Prisma } from "@prisma/client";
import {
@@ -49,7 +49,7 @@ export const DEFAULT_AVATAR_GIRL = "https://avatar.iran.liara.run/public/girl";
* @param {string | null | undefined} gender - User gender string (expected values: "MALE", "FEMALE", "OTHER")
* @returns {string} - Resolved avatar URL
*/
-import { getAvatarUrl } from "@/lib/shared/avatar";
+import { getAvatarUrl } from "@shared/avatar";
export const userRouter = createTRPCRouter({
/**
diff --git a/src/server/db.ts b/src/server/db.ts
index d810833..2962778 100644
--- a/src/server/db.ts
+++ b/src/server/db.ts
@@ -10,7 +10,6 @@ const createPrismaClient = () => {
typeof PrismaClient
>[0] & {
adapter?: unknown;
- accelerateUrl?: string;
};
const clientOptions: PrismaClientOptionsAug = {
@@ -18,23 +17,8 @@ const createPrismaClient = () => {
env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
};
- if (env.PRISMA_ACCELERATE_URL) {
- clientOptions.accelerateUrl = env.PRISMA_ACCELERATE_URL;
- logger.info("Using Prisma Accelerate URL from env for Prisma client");
- }
-
if (env.DATABASE_URL) {
process.env.DATABASE_URL = env.DATABASE_URL;
- logger.info(
- "Using direct database URL for Prisma client connection via process.env.DATABASE_URL",
- );
- }
-
- if (
- env.DATABASE_URL &&
- !clientOptions.adapter &&
- !clientOptions.accelerateUrl
- ) {
try {
clientOptions.adapter = new PrismaPg({
connectionString: env.DATABASE_URL,
@@ -85,7 +69,26 @@ const globalForPrisma = globalThis as unknown as {
prisma: ReturnType | undefined;
};
-export const db = globalForPrisma.prisma ?? createPrismaClient();
+/**
+ * Lazy-initialized Prisma client.
+ *
+ * During Next.js builds the module graph is evaluated but no actual DB queries
+ * run. Eager construction would fail in CI where DATABASE_URL is absent and
+ * the Prisma "client" engine requires an adapter. The Proxy defers creation
+ * until the first property access at runtime.
+ */
+function getOrCreateClient(): PrismaClient {
+ if (!globalForPrisma.prisma) {
+ globalForPrisma.prisma = createPrismaClient();
+ logger.info("Prisma client initialized", {
+ env: process.env.NODE_ENV,
+ });
+ }
+ return globalForPrisma.prisma;
+}
-if (env.NODE_ENV !== "production") globalForPrisma.prisma = db;
-logger.info("Prisma client initialized", { env: env.NODE_ENV });
+export const db: PrismaClient = new Proxy({} as PrismaClient, {
+ get(_target, prop) {
+ return getOrCreateClient()[prop as keyof PrismaClient];
+ },
+});
diff --git a/src/server/services/aiService.ts b/src/server/services/aiService.ts
index be64d74..db5312c 100644
--- a/src/server/services/aiService.ts
+++ b/src/server/services/aiService.ts
@@ -3,8 +3,8 @@ import { db } from "@/server/db";
import { GoogleGenerativeAI } from "@google/generative-ai";
import { env } from "@/env";
import { createLogger } from "@/lib/logging";
-import { toNum } from "@/lib/shared/decimal";
-import { extractJsonFromAI } from "@/lib/shared/ai-utils";
+import { toNum } from "@shared/decimal";
+import { extractJsonFromAI } from "@shared/ai-utils";
const logger = createLogger("aiService");
import {
@@ -16,8 +16,12 @@ import {
RECEIPT_PROMPT_TEMPLATE,
} from "@/constants/prompt";
-const MAX_RETRIES = 3;
-const RETRY_DELAY_MS = 1000;
+import {
+ MAX_RETRIES,
+ RETRY_DELAY_MS,
+ RATE_LIMIT_DELAY_MS,
+ VALID_FREQUENCIES,
+} from "@/constants/ai";
import type { CategoryForAI } from "@/types/category";
import type {
@@ -35,6 +39,14 @@ function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+function isRateLimitError(error: unknown): boolean {
+ return (
+ error instanceof Error &&
+ "status" in error &&
+ (error as unknown as { status: number }).status === 429
+ );
+}
+
function getApiKey(): string {
const apiKey = env?.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY;
if (!apiKey) {
@@ -79,20 +91,23 @@ async function callGeminiWithRetry(
) {
throw lastError;
}
+ const isRateLimit = isRateLimitError(error);
if (attempt < MAX_RETRIES) {
- const delay = RETRY_DELAY_MS * attempt;
+ const delay = isRateLimit
+ ? RATE_LIMIT_DELAY_MS * Math.pow(2, attempt - 1)
+ : RETRY_DELAY_MS * attempt;
logger.warn(
`Gemini API call failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${delay}ms`,
- {
- error: lastError.message,
- },
+ { error: lastError.message, isRateLimit },
);
await sleep(delay);
}
}
}
throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
+ code: isRateLimitError(lastError)
+ ? "TOO_MANY_REQUESTS"
+ : "INTERNAL_SERVER_ERROR",
message: `AI request failed after ${MAX_RETRIES} attempts: ${lastError?.message ?? "Unknown error"}`,
});
}
@@ -102,16 +117,11 @@ export class AIService {
* Generate AI-powered budget recommendations
*/
static async generateBudgetRecommendations(userId: string) {
- const [transactions, budgets] = await Promise.all([
- db.transaction.findMany({
- where: { userId },
- select: {
- type: true,
- amount: true,
- category: { select: { name: true } },
- },
- orderBy: { date: "desc" },
- take: 100,
+ const [spendingByCategory, budgets] = await Promise.all([
+ db.transaction.groupBy({
+ by: ["categoryId"],
+ where: { userId, type: "DEBIT", categoryId: { not: null } },
+ _sum: { amount: true },
}),
db.budget.findMany({
where: { userId },
@@ -123,14 +133,27 @@ export class AIService {
}),
]);
+ const categoryIds = spendingByCategory
+ .map((g) => g.categoryId)
+ .filter((id): id is string => id !== null);
+ const categories =
+ categoryIds.length > 0
+ ? await db.category.findMany({
+ where: { id: { in: categoryIds } },
+ select: { id: true, name: true },
+ })
+ : [];
+ const categoryNameMap = new Map(categories.map((c) => [c.id, c.name]));
+
const categorySpending: Record = {};
- transactions.forEach((t) => {
- if (t.type === "DEBIT" && t.category) {
- const amount = toNum(t.amount);
- categorySpending[t.category.name] =
- (categorySpending[t.category.name] ?? 0) + amount;
+ for (const group of spendingByCategory) {
+ const name = group.categoryId
+ ? categoryNameMap.get(group.categoryId)
+ : null;
+ if (name && group._sum.amount) {
+ categorySpending[name] = toNum(group._sum.amount);
}
- });
+ }
const existingBudgets = budgets
.map((b) => `${b.category.name}: $${toNum(b.amount)} ${b.period}`)
@@ -169,32 +192,50 @@ export class AIService {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0);
- const transactions = await db.transaction.findMany({
- where: {
- userId,
- date: { gte: startDate, lte: endDate },
- },
- select: {
- type: true,
- amount: true,
- category: { select: { name: true } },
- },
- });
+ const dateFilter = {
+ userId,
+ type: "DEBIT" as const,
+ date: { gte: startDate, lte: endDate },
+ };
- const totalSpent = transactions
- .filter((t) => t.type === "DEBIT")
- .reduce((sum, t) => sum + toNum(t.amount), 0);
-
- const categoryBreakdown = transactions
- .filter((t) => t.type === "DEBIT")
- .reduce(
- (acc, t) => {
- const category = t.category?.name ?? "Uncategorized";
- acc[category] = (acc[category] ?? 0) + toNum(t.amount);
- return acc;
- },
- {} as Record,
- );
+ const [totalResult, spendingByCategory] = await Promise.all([
+ db.transaction.aggregate({
+ where: dateFilter,
+ _sum: { amount: true },
+ }),
+ db.transaction.groupBy({
+ by: ["categoryId"],
+ where: dateFilter,
+ _sum: { amount: true },
+ }),
+ ]);
+
+ const totalSpent = totalResult._sum.amount
+ ? toNum(totalResult._sum.amount)
+ : 0;
+
+ const catIds = spendingByCategory
+ .map((g) => g.categoryId)
+ .filter((id): id is string => id !== null);
+ const cats =
+ catIds.length > 0
+ ? await db.category.findMany({
+ where: { id: { in: catIds } },
+ select: { id: true, name: true },
+ })
+ : [];
+ const catNameMap = new Map(cats.map((c) => [c.id, c.name]));
+
+ const categoryBreakdown: Record = {};
+ for (const group of spendingByCategory) {
+ const name = group.categoryId
+ ? (catNameMap.get(group.categoryId) ?? "Uncategorized")
+ : "Uncategorized";
+ if (group._sum.amount) {
+ categoryBreakdown[name] =
+ (categoryBreakdown[name] ?? 0) + toNum(group._sum.amount);
+ }
+ }
const prompt = SPENDING_INSIGHTS_TEMPLATE.replace("{{period}}", period)
.replace("{{totalSpent}}", totalSpent.toFixed(2))
@@ -212,8 +253,9 @@ export class AIService {
* Detect anomalous transactions
*/
static async detectAnomalies(userId: string) {
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const transactions = await db.transaction.findMany({
- where: { userId },
+ where: { userId, date: { gte: thirtyDaysAgo } },
select: {
type: true,
amount: true,
@@ -285,39 +327,40 @@ export class AIService {
* Get personalized financial advice
*/
static async getFinancialAdvice(userId: string) {
- const user = await db.user.findUnique({
- where: { id: userId },
- select: {
- id: true,
- transactions: {
- orderBy: { date: "desc" },
- take: 50,
- select: {
- type: true,
- amount: true,
- category: { select: { name: true } },
- },
- },
- budgets: {
+ const [userExists, incomeResult, expenseResult, budgets] =
+ await Promise.all([
+ db.user.findUnique({
+ where: { id: userId },
+ select: { id: true },
+ }),
+ db.transaction.aggregate({
+ where: { userId, type: "CREDIT" },
+ _sum: { amount: true },
+ }),
+ db.transaction.aggregate({
+ where: { userId, type: "DEBIT" },
+ _sum: { amount: true },
+ }),
+ db.budget.findMany({
+ where: { userId },
select: {
id: true,
amount: true,
category: { select: { name: true } },
},
- },
- },
- });
+ }),
+ ]);
- if (!user)
+ if (!userExists)
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
- const totalIncome = user.transactions
- .filter((t) => t.type === "CREDIT")
- .reduce((sum, t) => sum + toNum(t.amount), 0);
+ const totalIncome = incomeResult._sum.amount
+ ? toNum(incomeResult._sum.amount)
+ : 0;
- const totalExpenses = user.transactions
- .filter((t) => t.type === "DEBIT")
- .reduce((sum, t) => sum + toNum(t.amount), 0);
+ const totalExpenses = expenseResult._sum.amount
+ ? toNum(expenseResult._sum.amount)
+ : 0;
const savingsRate =
totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome) * 100 : 0;
@@ -328,7 +371,7 @@ export class AIService {
)
.replace("{{totalExpenses}}", totalExpenses.toFixed(2))
.replace("{{savingsRate}}", savingsRate.toFixed(1))
- .replace("{{budgetCount}}", user.budgets.length.toString());
+ .replace("{{budgetCount}}", budgets.length.toString());
return callGeminiWithRetry(prompt, (text) =>
extractJsonFromAI(text, "advice"),
@@ -342,12 +385,7 @@ export class AIService {
transactions: TransactionForAI[],
categories: CategoryForAI[],
): Promise {
- const maxRows = Number(
- env?.GEMINI_MAX_ROWS ??
- process.env.GEMINI_MAX_ROWS ??
- process.env.NEXT_PUBLIC_GEMINI_MAX_ROWS ??
- 50,
- );
+ const maxRows = env.GEMINI_MAX_ROWS;
if (!transactions || transactions.length === 0) {
return { results: [] };
@@ -435,7 +473,10 @@ export class AIService {
const parsed = JSON.parse(jsonText) as unknown;
if (!parsed || typeof parsed !== "object") {
- throw new Error("Response is not an object");
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Response is not an object",
+ });
}
let results: CategorizationResult[];
@@ -471,7 +512,10 @@ export class AIService {
},
);
} else {
- throw new Error("Response does not contain results array");
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Response does not contain results array",
+ });
}
const validatedResults: CategorizationResult[] = [];
@@ -509,7 +553,10 @@ export class AIService {
}
if (validatedResults.length === 0 && expectedCount > 0) {
- throw new Error("No valid categorization results found in AI response");
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "No valid categorization results found in AI response",
+ });
}
return {
@@ -517,9 +564,10 @@ export class AIService {
errors: errors.length > 0 ? errors : undefined,
};
} catch (error) {
- throw new Error(
- `Failed to parse AI response: ${error instanceof Error ? error.message : "Unknown parsing error"}. Raw response: ${jsonText.substring(0, 200)}...`,
- );
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to parse AI response: ${error instanceof Error ? error.message : "Unknown parsing error"}`,
+ });
}
}
@@ -532,7 +580,10 @@ export class AIService {
try {
const parsed = JSON.parse(jsonText) as unknown;
if (!parsed || typeof parsed !== "object")
- throw new Error("Parsed receipt response is not an object");
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Parsed receipt response is not an object",
+ });
const obj = parsed as Record;
@@ -592,11 +643,10 @@ export class AIService {
let recurrence: ReceiptScanResult["recurrence"] = null;
if (obj.recurrence && typeof obj.recurrence === "object") {
const r = obj.recurrence as Record;
- const validFrequencies = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"];
recurrence = {
frequency:
typeof r.frequency === "string" &&
- validFrequencies.includes(r.frequency)
+ (VALID_FREQUENCIES as readonly string[]).includes(r.frequency)
? (r.frequency as "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY")
: null,
interval: typeof r.interval === "number" ? r.interval : null,
@@ -622,7 +672,10 @@ export class AIService {
? null
: undefined;
let categoryConfidence: number | null | undefined = undefined;
- if (typeof obj.categoryConfidence === "number") {
+ if (
+ typeof obj.categoryConfidence === "number" &&
+ Number.isFinite(obj.categoryConfidence)
+ ) {
let conf = Math.round(obj.categoryConfidence);
if (conf < 0) conf = 0;
if (conf > 100) conf = 100;
@@ -723,9 +776,10 @@ export class AIService {
return result;
} catch (error) {
- throw new Error(
- `Failed to parse receipt AI response: ${error instanceof Error ? error.message : String(error)}. Raw: ${jsonText.substring(0, 200)}...`,
- );
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to parse receipt AI response: ${error instanceof Error ? error.message : String(error)}`,
+ });
}
}
}
diff --git a/src/server/services/budgetService.ts b/src/server/services/budgetService.ts
index baba699..f00ab68 100644
--- a/src/server/services/budgetService.ts
+++ b/src/server/services/budgetService.ts
@@ -1,4 +1,5 @@
import { db } from "@/server/db";
+import { BUDGET_THRESHOLDS } from "@/constants/budget";
import {
format,
startOfWeek,
@@ -9,8 +10,33 @@ import {
endOfYear,
} from "date-fns";
import { NotificationService } from "./notificationService";
-import { NotificationType, type BudgetPeriod } from "@prisma/client";
-import { toNum } from "@/lib/shared/decimal";
+import {
+ NotificationType,
+ type BudgetPeriod,
+ type Prisma,
+} from "@prisma/client";
+import { toNum } from "@shared/decimal";
+
+type BudgetWithCategory = {
+ id: string;
+ userId: string;
+ categoryId: string;
+ amount: Prisma.Decimal;
+ period: BudgetPeriod;
+ startDate: Date;
+ endDate: Date | null;
+ spentAmount: Prisma.Decimal;
+ createdAt: Date;
+ updatedAt: Date;
+ last_alert_period: string | null;
+ threshold_70_alert_sent: boolean;
+ threshold_90_alert_sent: boolean;
+ threshold_100_alert_sent: boolean;
+ category: {
+ id: string;
+ children: { id: string }[];
+ };
+};
export class BudgetService {
/**
@@ -36,8 +62,8 @@ export class BudgetService {
if (category?.userId !== userId) return;
- // 2. Identify relevant budgets (on this category OR its parent)
- const budgetIds = await db.budget.findMany({
+ // 2. Identify relevant budgets (on this category OR its parent) — load full data to avoid N+1
+ const budgets = await db.budget.findMany({
where: {
userId,
OR: [
@@ -47,20 +73,6 @@ export class BudgetService {
: []),
],
},
- select: { id: true },
- });
-
- // 3. Trigger evaluation for each relevant budget in parallel
- await Promise.all(budgetIds.map((b) => this.reevaluateBudget(b.id)));
- }
-
- /**
- * Recalculates spent amount for a specific budget and triggers alerts.
- * Always calculates for the CURRENT active period (Now).
- */
- static async reevaluateBudget(budgetId: string) {
- const budget = await db.budget.findUnique({
- where: { id: budgetId },
select: {
id: true,
userId: true,
@@ -85,6 +97,46 @@ export class BudgetService {
},
});
+ // 3. Trigger evaluation for each relevant budget in parallel (pass cached data)
+ await Promise.all(budgets.map((b) => this.reevaluateBudget(b.id, b)));
+ }
+
+ /**
+ * Recalculates spent amount for a specific budget and triggers alerts.
+ * Always calculates for the CURRENT active period (Now).
+ */
+ static async reevaluateBudget(
+ budgetId: string,
+ cachedBudget?: BudgetWithCategory | null,
+ ) {
+ const budget =
+ cachedBudget ??
+ (await db.budget.findUnique({
+ where: { id: budgetId },
+ select: {
+ id: true,
+ userId: true,
+ categoryId: true,
+ amount: true,
+ period: true,
+ startDate: true,
+ endDate: true,
+ spentAmount: true,
+ createdAt: true,
+ updatedAt: true,
+ last_alert_period: true,
+ threshold_70_alert_sent: true,
+ threshold_90_alert_sent: true,
+ threshold_100_alert_sent: true,
+ category: {
+ select: {
+ id: true,
+ children: { select: { id: true } },
+ },
+ },
+ },
+ }));
+
if (!budget) return;
// Calculate current period window based on TODAY
@@ -216,23 +268,7 @@ export class BudgetService {
if (total <= 0) return;
const percent = (spent / total) * 100;
- const thresholds = [
- {
- level: 100,
- flag: "threshold_100_alert_sent" as const,
- title: "Budget Limit Reached",
- },
- {
- level: 90,
- flag: "threshold_90_alert_sent" as const,
- title: "Budget Warning (90%)",
- },
- {
- level: 70,
- flag: "threshold_70_alert_sent" as const,
- title: "Budget Alert (70%)",
- },
- ];
+ const thresholds = BUDGET_THRESHOLDS;
for (const t of thresholds) {
if (percent >= t.level) {
@@ -256,7 +292,7 @@ export class BudgetService {
// Emit event for email worker
const { inngest } = await import("@/lib/inngest/client");
const { BUDGET_THRESHOLD_REACHED_EVENT } = await import(
- "@/lib/inngest/events"
+ "@/constants/events"
);
await inngest.send({
name: BUDGET_THRESHOLD_REACHED_EVENT,
@@ -296,6 +332,9 @@ export class BudgetService {
title: "Large Transaction Detected",
message: `A transaction for ${amount.toFixed(2)} (${description}) exceeds your set threshold of ${threshold.toFixed(2)}.`,
});
+
+ const { emitTransactionAlert } = await import("@/constants/events");
+ await emitTransactionAlert({ userId, amount, description, threshold });
}
}
@@ -310,11 +349,16 @@ export class BudgetService {
}),
db.bankAccount.findUnique({
where: { id: accountId },
- select: { name: true, balance: true },
+ select: { userId: true, name: true, balance: true },
}),
]);
- if (!prefs?.lowBalanceThreshold || !prefs.emailLowBalanceAlerts || !account)
+ if (
+ !prefs?.lowBalanceThreshold ||
+ !prefs.emailLowBalanceAlerts ||
+ !account ||
+ account.userId !== userId
+ )
return;
const threshold = toNum(prefs.lowBalanceThreshold);
diff --git a/src/server/services/fileService.ts b/src/server/services/fileService.ts
deleted file mode 100644
index 46da26a..0000000
--- a/src/server/services/fileService.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export {
- parseCSV,
- parseCSVText,
- validateAndParseTransactions,
-} from "@/lib/shared/file-parser";
diff --git a/src/server/services/notificationService.ts b/src/server/services/notificationService.ts
index b0eac80..badfc7f 100644
--- a/src/server/services/notificationService.ts
+++ b/src/server/services/notificationService.ts
@@ -51,7 +51,7 @@ export class NotificationService {
}
if (shouldEmail) {
- const { enqueueEmail } = await import("@/lib/inngest/events");
+ const { enqueueEmail } = await import("@/constants/events");
await enqueueEmail({
to: user.email,
subject: title,
diff --git a/src/server/services/reportService.ts b/src/server/services/reportService.ts
index 671095b..ac7bf5d 100644
--- a/src/server/services/reportService.ts
+++ b/src/server/services/reportService.ts
@@ -4,7 +4,7 @@ import { ReportType, ReportStatus } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import { startOfMonth, endOfMonth, format } from "date-fns";
import type { MonthlySummaryData, BudgetExceededData } from "@/types/report";
-import { toNum } from "@/lib/shared/decimal";
+import { toNum } from "@shared/decimal";
export class ReportService {
/**
diff --git a/src/store/userStore.ts b/src/store/userStore.ts
deleted file mode 100644
index e8b4a97..0000000
--- a/src/store/userStore.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { create } from "zustand";
-import { persist, createJSONStorage } from "zustand/middleware";
-import type { User } from "@/types/user";
-
-type UserState = {
- user: User | null;
- setUser: (u: User | null) => void;
- clear: () => void;
-};
-
-/**
- * Optional lightweight Zustand store for user data.
- *
- * Note: server state (user profile) is already cached via react-query. Use this
- * store only for small UI-only pieces of state closely tied to the user that
- * you want to access outside React tree (or without hooks). Otherwise prefer
- * react-query + tRPC.
- */
-export const useUserStore = create()(
- persist(
- (set) => ({
- user: null,
- setUser: (u: User | null) => set({ user: u }),
- clear: () => set({ user: null }),
- }),
- {
- name: "trackit_user",
- // Only persist non-sensitive fields
- partialize: (state) =>
- state.user
- ? {
- user: {
- id: state.user.id,
- name: state.user.name,
- image: state.user.image,
- role: state.user.role,
- } as User,
- }
- : { user: null },
- storage: createJSONStorage(() => {
- if (typeof window !== "undefined") return localStorage;
- return {
- getItem: (_key: string) => null,
- setItem: (_key: string, _value: string) => {
- /* noop */
- },
- removeItem: (_key: string) => {
- /* noop */
- },
- clear: () => {
- /* noop */
- },
- key: (_index: number) => null,
- length: 0,
- };
- }),
- },
- ),
-);
-
-export default useUserStore;
diff --git a/src/lib/trpc/invalidation.ts b/src/trpc/invalidation.ts
similarity index 100%
rename from src/lib/trpc/invalidation.ts
rename to src/trpc/invalidation.ts
diff --git a/src/validation/auth.ts b/src/validation/auth.ts
index 187c0d6..6e267ad 100644
--- a/src/validation/auth.ts
+++ b/src/validation/auth.ts
@@ -1,5 +1,14 @@
import { z } from "zod";
+const passwordSchema = z
+ .string()
+ .min(8, { message: "Password must be at least 8 characters" })
+ .regex(/[A-Z]/, { message: "Password must contain an uppercase letter" })
+ .regex(/[0-9]/, { message: "Password must contain a number" })
+ .regex(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/, {
+ message: "Password must contain a special character",
+ });
+
export const loginSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
password: z
@@ -9,14 +18,13 @@ export const loginSchema = z.object({
export const signupSchema = z
.object({
- name: z.string().min(2, { message: "Name must be at least 2 characters" }),
- email: z.string().email({ message: "Invalid email address" }),
- password: z
+ name: z
.string()
- .min(8, { message: "Password must be at least 8 characters" }),
- confirmPassword: z
- .string()
- .min(8, { message: "Password must be at least 8 characters" }),
+ .min(2, { message: "Name must be at least 2 characters" })
+ .max(100, { message: "Name must be at most 100 characters" }),
+ email: z.string().email({ message: "Invalid email address" }),
+ password: passwordSchema,
+ confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
diff --git a/src/validation/budget.ts b/src/validation/budget.ts
index 6d88e7f..464eba7 100644
--- a/src/validation/budget.ts
+++ b/src/validation/budget.ts
@@ -3,7 +3,7 @@ import { BudgetPeriod } from "@prisma/client";
export const createBudgetSchema = z.object({
categoryId: z.string().min(1),
- amount: z.number().min(0),
+ amount: z.number().min(0.01, "Budget amount must be positive"),
period: z.nativeEnum(BudgetPeriod),
startDate: z.date(),
endDate: z.date().optional(),
diff --git a/src/validation/transaction.ts b/src/validation/transaction.ts
index 54d8ad6..1578fbd 100644
--- a/src/validation/transaction.ts
+++ b/src/validation/transaction.ts
@@ -17,13 +17,16 @@ export const createTransactionSchema = z.object({
.min(1)
.refine((val) => !isNaN(parseFloat(val)) && parseFloat(val) > 0, {
message: "Amount must be a positive number",
+ })
+ .refine((val) => /^\d+(\.\d{1,2})?$/.test(val), {
+ message: "Amount can have at most 2 decimal places",
}),
type: z.enum(["DEBIT", "CREDIT", "TRANSFER"]),
categoryId: z.string().nullable().optional(),
contactId: z.string().nullable().optional(),
groupId: z.string().nullable().optional(),
- description: z.string().nullable().optional(),
- notes: z.string().nullable().optional(),
+ description: z.string().max(1000).nullable().optional(),
+ notes: z.string().max(2000).nullable().optional(),
date: z.string().optional(),
isRecurring: z.boolean().optional(),
recurrence: recurrenceSchema.optional(),
@@ -42,13 +45,16 @@ export const updateTransactionSchema = z.object({
.refine((val) => !isNaN(parseFloat(val)) && parseFloat(val) > 0, {
message: "Amount must be a positive number",
})
+ .refine((val) => /^\d+(\.\d{1,2})?$/.test(val), {
+ message: "Amount can have at most 2 decimal places",
+ })
.optional(),
type: z.enum(["DEBIT", "CREDIT", "TRANSFER"]).optional(),
categoryId: z.string().nullable().optional(),
contactId: z.string().nullable().optional(),
groupId: z.string().nullable().optional(),
- description: z.string().nullable().optional(),
- notes: z.string().nullable().optional(),
+ description: z.string().max(1000).nullable().optional(),
+ notes: z.string().max(2000).nullable().optional(),
date: z.string().optional(),
isRecurring: z.boolean().optional(),
recurrence: recurrenceSchema.optional(),
@@ -61,10 +67,10 @@ export const updateTransactionSchema = z.object({
export const transactionListInput = z.object({
accountId: z.string().optional(),
- limit: z.number().int().min(1).max(1000).default(20),
+ limit: z.number().int().min(1).max(100).default(20),
cursor: z.string().optional(),
page: z.number().int().min(1).optional(),
- q: z.string().optional(),
+ q: z.string().max(200).optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
});
diff --git a/tsconfig.json b/tsconfig.json
index 1e480cc..ab4cc3f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -32,13 +32,15 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
- "@component/*": ["./src/components/*"],
+ "@ui/*": ["./src/components/ui/*"],
+ "@common/*": ["./src/components/common/*"],
+ "@shared/*": ["./src/lib/shared/*"],
+ "@skeletons/*": ["./src/components/skeletons/*"],
"@component/home": ["./src/components/pages/(public)/home"],
"@component/about": ["./src/components/pages/(public)/about"],
"@component/features": ["./src/components/pages/(public)/features"],
"@component/blog": ["./src/components/pages/(public)/blog"],
"@component/help": ["./src/components/pages/(public)/help"],
- "@component/common": ["./src/components/common"],
"@content/*": ["./src/content/*"],
"@content/site": ["./src/content/site"],
"@types/*": ["./src/types/*"],
| | | | | | | | |