feat: expenses, report and day log commands
This commit is contained in:
148
utils/commands/commandParser.ts
Normal file
148
utils/commands/commandParser.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { CommandDefinition } from '@utils/types';
|
||||
|
||||
export class CommandParser {
|
||||
private commands: Map<string, CommandDefinition>;
|
||||
|
||||
constructor() {
|
||||
this.commands = new Map();
|
||||
}
|
||||
|
||||
registerCommand(definition: CommandDefinition) {
|
||||
this.commands.set(definition.name.toLowerCase(), definition);
|
||||
}
|
||||
|
||||
parse(input: string): {
|
||||
command: string;
|
||||
id?: string;
|
||||
flags: Record<string, string | number | boolean | Date>;
|
||||
} {
|
||||
const parts = input.match(/(?:[^\s"]+|"[^"]*")+/g);
|
||||
if (!parts || parts.length === 0) {
|
||||
throw new Error('Invalid command format');
|
||||
}
|
||||
|
||||
const command = parts[0].toLowerCase();
|
||||
const definition = this.commands.get(command);
|
||||
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
let currentIndex = 1;
|
||||
const flags: Record<string, string | number | boolean | Date> = {};
|
||||
|
||||
if (definition.hasId) {
|
||||
if (parts.length < 2) {
|
||||
throw new Error(`Command ${command} requires an ID`);
|
||||
}
|
||||
const id = parts[1];
|
||||
currentIndex = 2;
|
||||
flags.id = id;
|
||||
}
|
||||
|
||||
while (currentIndex < parts.length) {
|
||||
const flag = parts[currentIndex];
|
||||
if (!flag.startsWith('--')) {
|
||||
throw new Error(`Invalid flag format at: ${flag}`);
|
||||
}
|
||||
|
||||
const flagName = flag.slice(2);
|
||||
const flagDef = definition.flags.find(
|
||||
f => f.name === flagName || f.alias === flagName
|
||||
);
|
||||
|
||||
if (!flagDef) {
|
||||
throw new Error(`Unknown flag: ${flagName}`);
|
||||
}
|
||||
|
||||
currentIndex++;
|
||||
if (currentIndex >= parts.length) {
|
||||
throw new Error(`Missing value for flag: ${flagName}`);
|
||||
}
|
||||
|
||||
const value = parts[currentIndex].replace(/^"(.*)"$/, '$1');
|
||||
|
||||
switch (flagDef.type) {
|
||||
case 'number': {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) {
|
||||
throw new Error(`Invalid number for flag ${flagName}: ${value}`);
|
||||
}
|
||||
flags[flagDef.name] = num;
|
||||
break;
|
||||
}
|
||||
case 'date': {
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new Error(`Invalid date for flag ${flagName}: ${value}`);
|
||||
}
|
||||
flags[flagDef.name] = date;
|
||||
break;
|
||||
}
|
||||
case 'boolean': {
|
||||
flags[flagDef.name] = value.toLowerCase() === 'true';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
flags[flagDef.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
currentIndex++;
|
||||
}
|
||||
|
||||
for (const flagDef of definition.flags) {
|
||||
if (flagDef.required && !(flagDef.name in flags)) {
|
||||
throw new Error(`Missing required flag: ${flagDef.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command,
|
||||
...(flags.id ? { id: flags.id as string } : {}),
|
||||
flags: Object.fromEntries(
|
||||
Object.entries(flags).filter(([key]) => key !== 'id')
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const diaryCommands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'add',
|
||||
flags: [
|
||||
{ name: 'desc', type: 'string', required: true },
|
||||
{ name: 'cost', type: 'number', required: true },
|
||||
{ name: 'cat', type: 'string', required: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'update',
|
||||
hasId: true,
|
||||
flags: [
|
||||
{ name: 'desc', type: 'string', required: false },
|
||||
{ name: 'cost', type: 'number', required: false },
|
||||
{ name: 'cat', type: 'string', required: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
hasId: true,
|
||||
flags: []
|
||||
},
|
||||
{
|
||||
name: 'report',
|
||||
flags: [
|
||||
{ name: 'dateFrom', type: 'date', required: true },
|
||||
{ name: 'dateTo', type: 'date', required: true },
|
||||
{ name: 'export', type: 'boolean', required: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'daylog',
|
||||
flags: [
|
||||
{ name: 'text', type: 'string', required: true },
|
||||
{ name: 'date', type: 'date', required: false }
|
||||
]
|
||||
}
|
||||
];
|
||||
66
utils/commands/dayLog.ts
Normal file
66
utils/commands/dayLog.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import prisma from '@prisma/prisma';
|
||||
import { ShortcutsResponse } from '@utils/types';
|
||||
|
||||
export async function processDayLog(
|
||||
text: string,
|
||||
date?: Date
|
||||
): Promise<ShortcutsResponse> {
|
||||
try {
|
||||
const normalizedDate = new Date(date || new Date());
|
||||
normalizedDate.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const newComment: Prisma.JsonObject = {
|
||||
text,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const existingLog = await prisma.dayLog.findUnique({
|
||||
where: {
|
||||
date: normalizedDate
|
||||
}
|
||||
});
|
||||
|
||||
if (existingLog) {
|
||||
let existingComments: Prisma.JsonArray = [];
|
||||
|
||||
if (Array.isArray(existingLog.comments)) {
|
||||
existingComments = existingLog.comments as Prisma.JsonArray;
|
||||
}
|
||||
|
||||
const updatedLog = await prisma.dayLog.update({
|
||||
where: {
|
||||
id: existingLog.id
|
||||
},
|
||||
data: {
|
||||
comments: [...existingComments, newComment]
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Added comment to existing log for ${normalizedDate.toLocaleDateString()}`,
|
||||
data: updatedLog
|
||||
};
|
||||
} else {
|
||||
const newLog = await prisma.dayLog.create({
|
||||
data: {
|
||||
date: normalizedDate,
|
||||
comments: [newComment]
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Created new log for ${normalizedDate.toLocaleDateString()}`,
|
||||
data: newLog
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing daylog:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to process daylog'
|
||||
};
|
||||
}
|
||||
}
|
||||
159
utils/commands/diary.ts
Normal file
159
utils/commands/diary.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { ExpenseType, ShortcutsResponse } from '../types';
|
||||
import { CommandParser, diaryCommands } from './commandParser';
|
||||
import { Category, Expense } from '@prisma/client';
|
||||
import { ExpenseReporter } from './report';
|
||||
import { createExpense, deleteExpense, updateExpense } from '@utils/expense';
|
||||
import { processDayLog } from '@utils/commands/dayLog';
|
||||
|
||||
const formatResponse = (expense: Expense & { category: Category }) => ({
|
||||
id: expense.id,
|
||||
description: expense.description,
|
||||
cost: expense.cost,
|
||||
category: expense.category.name,
|
||||
createdAt: expense.createdAt,
|
||||
updatedAt: expense.updatedAt
|
||||
});
|
||||
|
||||
export async function diaryCommand(
|
||||
parameters: Record<string, string> | undefined
|
||||
): Promise<ShortcutsResponse> {
|
||||
try {
|
||||
if (!parameters || !parameters['instruction']) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Message parameter is missing.'
|
||||
};
|
||||
}
|
||||
|
||||
const parser = new CommandParser();
|
||||
diaryCommands.forEach(cmd => parser.registerCommand(cmd));
|
||||
|
||||
const parsedCommand = parser.parse(parameters['instruction']);
|
||||
|
||||
switch (parsedCommand.command) {
|
||||
case 'add': {
|
||||
if (!parsedCommand.flags.cat) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Category is required'
|
||||
};
|
||||
}
|
||||
|
||||
const expense = await createExpense({
|
||||
description: parsedCommand.flags.desc as string,
|
||||
cost: parsedCommand.flags.cost as number,
|
||||
categoryName: parsedCommand.flags.cat as string
|
||||
});
|
||||
|
||||
const formatted = formatResponse(expense);
|
||||
return {
|
||||
success: true,
|
||||
message: `Added expense: ${formatted.description} (${formatted.cost.toFixed(2)}€) in category ${formatted.category}`,
|
||||
data: formatted
|
||||
};
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
if (!parsedCommand.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Expense ID is required for update'
|
||||
};
|
||||
}
|
||||
|
||||
const updateData: Partial<ExpenseType> = {};
|
||||
if (parsedCommand.flags.desc)
|
||||
updateData.description = parsedCommand.flags.desc as string;
|
||||
if (parsedCommand.flags.cost)
|
||||
updateData.cost = parsedCommand.flags.cost as number;
|
||||
if (parsedCommand.flags.cat)
|
||||
updateData.categoryName = parsedCommand.flags.cat as string;
|
||||
|
||||
const expense = await updateExpense(parsedCommand.id, updateData);
|
||||
const formatted = formatResponse(expense);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Updated expense: ${formatted.description} (${formatted.cost.toFixed(2)}€) in category ${formatted.category}`,
|
||||
data: formatted
|
||||
};
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
if (!parsedCommand.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Expense ID is required for deletion'
|
||||
};
|
||||
}
|
||||
|
||||
await deleteExpense(parsedCommand.id);
|
||||
return {
|
||||
success: true,
|
||||
message: `Deleted expense with ID: ${parsedCommand.id}`
|
||||
};
|
||||
}
|
||||
|
||||
case 'report': {
|
||||
try {
|
||||
const reporter = new ExpenseReporter();
|
||||
const from = parsedCommand.flags.dateFrom as Date;
|
||||
const to = parsedCommand.flags.dateTo as Date;
|
||||
const includeJson = (parsedCommand.flags.export as boolean) || false;
|
||||
|
||||
await reporter.sendReport(from, to, includeJson);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Report sent for period: ${formatDate(from)} to ${formatDate(to)}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to generate report'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case 'daylog': {
|
||||
const text = parsedCommand.flags.text as string;
|
||||
const date = (parsedCommand.flags.date as Date) || new Date();
|
||||
|
||||
return processDayLog(text, date);
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
message: `Unknown command: ${parsedCommand.command}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing expense command:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Record to update not found')) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Expense not found or already deleted'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error ? error.message : 'An unexpected error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
11
utils/commands/ping.ts
Normal file
11
utils/commands/ping.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ShortcutsResponse } from '../types';
|
||||
|
||||
export async function pingCommand(): Promise<ShortcutsResponse> {
|
||||
return {
|
||||
success: true,
|
||||
message: 'The system is operational.',
|
||||
data: {
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
201
utils/commands/report.ts
Normal file
201
utils/commands/report.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Resend } from 'resend';
|
||||
import prisma from '@prisma/prisma';
|
||||
import { ReportData } from '@utils/types';
|
||||
|
||||
export class ExpenseReporter {
|
||||
private resend: Resend;
|
||||
private senderEmail: string;
|
||||
private recipientEmail: string;
|
||||
|
||||
constructor() {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
throw new Error('RESEND_API_KEY environment variable is not set');
|
||||
}
|
||||
if (!process.env.SENDER_EMAIL) {
|
||||
throw new Error('SENDER_EMAIL environment variable is not set');
|
||||
}
|
||||
if (!process.env.RECIPIENT_EMAIL) {
|
||||
throw new Error('RECIPIENT_EMAIL environment variable is not set');
|
||||
}
|
||||
this.resend = new Resend(process.env.RESEND_API_KEY);
|
||||
this.senderEmail = process.env.SENDER_EMAIL;
|
||||
this.recipientEmail = process.env.RECIPIENT_EMAIL;
|
||||
}
|
||||
|
||||
private async generateReport(from: Date, to: Date): Promise<ReportData> {
|
||||
const startDate = new Date(from);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const endDate = new Date(to);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
|
||||
const expenses = await prisma.expense.findMany({
|
||||
where: {
|
||||
deleted: false,
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
include: {
|
||||
category: true
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
const totalExpenses = expenses.reduce((sum, exp) => sum + exp.cost, 0);
|
||||
|
||||
const categoryMap = new Map<string, { total: number; count: number }>();
|
||||
expenses.forEach(exp => {
|
||||
const current = categoryMap.get(exp.category.name) || {
|
||||
total: 0,
|
||||
count: 0
|
||||
};
|
||||
categoryMap.set(exp.category.name, {
|
||||
total: current.total + exp.cost,
|
||||
count: current.count + 1
|
||||
});
|
||||
});
|
||||
|
||||
const byCategory = Array.from(categoryMap.entries())
|
||||
.map(([category, stats]) => ({
|
||||
category,
|
||||
total: stats.total,
|
||||
count: stats.count
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return {
|
||||
expenses,
|
||||
summary: {
|
||||
totalExpenses,
|
||||
byCategory
|
||||
},
|
||||
dateRange: { from, to }
|
||||
};
|
||||
}
|
||||
|
||||
private generateHtmlReport(data: ReportData): string {
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const formatCurrency = (amount: number) =>
|
||||
amount.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||
th { background-color: #f5f5f5; }
|
||||
.summary { margin: 20px 0; padding: 20px; background: #f9f9f9; border-radius: 5px; }
|
||||
.category-summary { margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Expense Report</h1>
|
||||
<p>From ${formatDate(data.dateRange.from)} to ${formatDate(data.dateRange.to)}</p>
|
||||
|
||||
<div class="summary">
|
||||
<h2>Summary</h2>
|
||||
<p><strong>Total Expenses:</strong> ${formatCurrency(data.summary.totalExpenses)}</p>
|
||||
|
||||
<div class="category-summary">
|
||||
<h3>Expenses by Category</h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Total</th>
|
||||
<th>Count</th>
|
||||
<th>Average</th>
|
||||
</tr>
|
||||
${data.summary.byCategory
|
||||
.map(
|
||||
cat => `
|
||||
<tr>
|
||||
<td>${cat.category}</td>
|
||||
<td>${formatCurrency(cat.total)}</td>
|
||||
<td>${cat.count}</td>
|
||||
<td>${formatCurrency(cat.total / cat.count)}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Detailed Expenses</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Category</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
${data.expenses
|
||||
.map(
|
||||
exp => `
|
||||
<tr>
|
||||
<td>${formatDate(exp.createdAt)}</td>
|
||||
<td>${exp.description}</td>
|
||||
<td>${exp.category.name}</td>
|
||||
<td>${formatCurrency(exp.cost)}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
async sendReport(
|
||||
from: Date,
|
||||
to: Date,
|
||||
includeJson: boolean = false
|
||||
): Promise<void> {
|
||||
const reportData = await this.generateReport(from, to);
|
||||
const htmlContent = this.generateHtmlReport(reportData);
|
||||
|
||||
const attachments = [];
|
||||
if (includeJson) {
|
||||
const jsonData = JSON.stringify(reportData, null, 2);
|
||||
attachments.push({
|
||||
filename: 'expense-report.json',
|
||||
content: Buffer.from(jsonData).toString('base64'),
|
||||
contentType: 'application/json' // Added MIME type
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.resend.emails.send({
|
||||
from: this.senderEmail,
|
||||
to: this.recipientEmail,
|
||||
subject: `Expense Report: ${from.toLocaleDateString()} - ${to.toLocaleDateString()}`,
|
||||
html: htmlContent,
|
||||
attachments
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error('Failed to send email: No id returned from Resend');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
throw new Error(`Email sending failed: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user