feat: expenses, report and day log commands

This commit is contained in:
2025-01-18 21:21:26 +01:00
parent a28d392342
commit 8681914be2
30 changed files with 5614 additions and 1 deletions

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
API_KEY=
DATABASE_URL=
DATABASE_URL_UNPOOLED=
PGDATABASE=
PGHOST=
PGHOST_UNPOOLED=
PGPASSWORD=
PGUSER=
POSTGRES_DATABASE=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_PRISMA_URL=
POSTGRES_URL=
POSTGRES_URL_NON_POOLING=
POSTGRES_URL_NO_SSL=
POSTGRES_USER=
RECIPIENT_EMAIL=
RESEND_API_KEY=
SENDER_EMAIL=

24
.eslintrc.json Normal file
View File

@@ -0,0 +1,24 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"next/core-web-vitals",
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/consistent-type-definitions": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}
}

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# analyze
/analyze
# yarn
/.yarn
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"trailingComma": "none",
"singleQuote": true,
"printWidth": 80,
"jsxSingleQuote": true,
"tabWidth": 2,
"arrowParens": "avoid"
}

5
.yarnrc.yml Normal file
View File

@@ -0,0 +1,5 @@
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules

202
README.md
View File

@@ -1 +1,201 @@
# walletwhisper # DiaryWhisper
A personal expenses and day log tracking API that works with Siri Shortcuts. Using Siri as a CLI.
## 🎯 Features
- Expenses and diary tracking through Siri
- Secure API key authentication
- PostgreSQL database for data storage
- Soft delete support for data integrity
- Flexible reporting options
## 🗣️ Supported Commands
### Expense Management
#### Add an Expense
```
add --desc "description" --cost amount --cat category
```
Example: `add --desc "Weekly groceries" --cost 87.50 --cat groceries`
#### Update an Expense
```
update expenseId --desc "new description" --cost newAmount --cat newCategory
```
All flags are optional - only include what you want to change.
Example: `update abc123 --cost 92.30 --cat groceries`
#### Delete an Expense
```
delete expenseId
```
Example: `delete abc123`
#### Generate Report
```
report --dateFrom "2025-01-01" --dateTo "2025-01-31" --export true
```
Generates and emails an expense report for the specified period. The report includes:
- Total expenses for the period
- Breakdown by category showing total amount, number of transactions and average per transaction
- Detailed list of all expenses with dates, descriptions and amounts
The `export` flag is optional - when set to true, a JSON file with the raw data will be attached to the email.
### Day Log
```
daylog --text "Meeting notes or daily summary" --date "2024-01-18"
```
Adds a log entry for a specific day. The date parameter is optional and defaults to the current date.
Logs are stored with UTC midnight timestamps for consistent date handling
Multiple entries can be added to the same day
Each entry includes the original timestamp
### System Commands
#### Check System Status
```
ping
```
Returns system operational status and timestamp.
## 🏁 Getting Started
### 📋 Prerequisites
- Node.js 18 or higher
- Docker for local development
- Vercel CLI
- Yarn package manager
- PostgreSQL
### 💻 Installation
1. Clone the repository:
```bash
git clone https://github.com/RiccardoSenica/diarywhisper
cd diarywhisper
```
2. Install dependencies:
```bash
yarn install
```
3. Set up Vercel:
```bash
# Install Vercel CLI globally
yarn global add vercel@latest
# Link to your Vercel project
yarn vercel:link
# Pull environment variables
yarn vercel:env
```
4. Set up the database:
```bash
# Push Prisma schema to database
yarn prisma:push
# Generate Prisma client
yarn prisma:generate
```
### 🔐 Environment Variables
Create a `.env` file with:
```
API_KEY=
DATABASE_URL=
DATABASE_URL_UNPOOLED=
PGDATABASE=
PGHOST=
PGHOST_UNPOOLED=
PGPASSWORD=
PGUSER=
POSTGRES_DATABASE=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_PRISMA_URL=
POSTGRES_URL=
POSTGRES_URL_NON_POOLING=
POSTGRES_URL_NO_SSL=
POSTGRES_USER=
RECIPIENT_EMAIL=
RESEND_API_KEY=
SENDER_EMAIL=
```
### 🗄️ Database Management
Reset database (⚠️ Warning: this will delete all data):
```bash
yarn prisma:reset
```
## 🔄 API Response Format
All API responses follow this structure:
```typescript
{
success: boolean;
message: string;
data?: unknown;
action?: {
type: 'notification' | 'openUrl' | 'runShortcut' | 'wait';
payload: unknown;
};
}
```
## 🔒 Security
- All requests must include a valid API key
- Soft delete is implemented to prevent data loss
## 📱 Setting Up Siri Shortcuts
1. Create a new shortcut in the Shortcuts app
2. Add a "Get Contents of URL" action
3. Configure the action:
- URL: Your deployed API endpoint
- Method: POST
- Headers: Add your API key
- Request Body: JSON with command and parameters
Example shortcut configuration:
```json
{
"command": "expense",
"parameters": {
"instruction": "add --desc \"Coffee\" --cost 3.50 --cat food"
},
"apiKey": "your_api_key_here"
}
```

50
app/api/command/route.ts Normal file
View File

@@ -0,0 +1,50 @@
import { ShortcutsHandler } from '@utils/handler';
import { RequestSchema } from '@utils/types';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
try {
const body = await req.json();
const result = RequestSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
success: false,
message: 'Invalid request format.',
errors: result.error.errors
},
{ status: 400 }
);
}
const shortcutsHandler = new ShortcutsHandler();
const isValid = shortcutsHandler.validateRequest(result.data);
if (!isValid) {
return NextResponse.json(
{
success: false,
message: 'Unauthorized.'
},
{ status: 401 }
);
}
const response = await shortcutsHandler.processCommand(
result.data.command,
result.data.parameters
);
return NextResponse.json(response);
} catch (error) {
console.error('Error processing shortcuts request:', error);
return NextResponse.json(
{
success: false,
message: 'An error occurred while processing your request.'
},
{ status: 500 }
);
}
}

19
app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'DiaryWhisper',
description:
'Siri-enabled diary tracker for expenses and day logs'
};
export default function RootLayout({
children
}: {
children: React.ReactNode;
}) {
return (
<html lang='en'>
<body>{children}</body>
</html>
);
}

113
app/page.tsx Normal file
View File

@@ -0,0 +1,113 @@
'use client';
import React from 'react';
export default function Home() {
const commands = [
'expense add --desc "Coffee" --cost 3.50 --cat "Food"',
'expense report --dateFrom "2024-01-01" --dateTo "2024-01-31"',
'expense daylog --text "Added team lunch" --date "2024-01-18"'
];
return (
<>
<div className="container">
<div className="header">
<h1>DiaryWhisper v1.0.0</h1>
<p>Your expenses and day logs tracked via Siri Shortcuts</p>
</div>
<div className="terminal">
<div>Loading system components...</div>
<div>Initializing expense database... OK</div>
<div>Starting expense tracking daemon... OK</div>
<div>System ready_</div>
</div>
<div className="commands">
<h2>Available Commands:</h2>
{commands.map((cmd, i) => (
<div key={i} className="command">
<span>$ </span>{cmd}
</div>
))}
</div>
<div className="status">
<span>Status: OPERATIONAL</span>
<span>Database: CONNECTED</span>
<span>Last Backup: 2024-01-18 14:30 UTC</span>
</div>
</div>
<style jsx global>{`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
background: #0a0a0a;
}
`}</style>
<style jsx>{`
.container {
min-height: 100vh;
padding: 2rem;
background: #0a0a0a;
color: #00ff00;
font-family: monospace;
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.terminal {
background: #000;
border: 1px solid #00ff00;
padding: 1rem;
margin: 2rem 0;
max-width: 800px;
margin: 2rem auto;
}
.terminal div {
margin-bottom: 0.5rem;
}
.commands {
max-width: 800px;
margin: 2rem auto;
}
.command {
margin-bottom: 0.5rem;
}
.status {
max-width: 800px;
margin: 2rem auto;
padding-top: 1rem;
border-top: 1px solid #00ff00;
display: flex;
justify-content: space-between;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.status {
flex-direction: column;
gap: 0.5rem;
}
}
`}</style>
</>
);
}

12
app/robots.ts Normal file
View File

@@ -0,0 +1,12 @@
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
disallow: '/'
}
]
};
}

3
commitlint.config.ts Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional']
};

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "diarywhisper",
"version": "1.0.0",
"description": "Siri-enabled expenses and day log tracker",
"author": "riccardo@frompixels.com",
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --config .prettierrc '**/*.{js,jsx,ts,tsx,json,md}' --write",
"typecheck": "tsc --noEmit",
"prepare": "husky install",
"audit": "audit-ci",
"vercel:link": "vercel link",
"vercel:env": "vercel env pull .env",
"prisma:migrate": "npx prisma migrate dev",
"prisma:push": "npx prisma db push",
"prisma:generate": "npx prisma generate",
"prisma:reset": "npx prisma db push --force-reset"
},
"dependencies": {
"@prisma/client": "^5.6.0",
"next": "^14.2.15",
"react": "^18",
"react-dom": "^18",
"resend": "^4.1.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@commitlint/cli": "^18.4.3",
"@commitlint/config-conventional": "^18.4.3",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"audit-ci": "^6.6.1",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.0.3",
"eslint-config-prettier": "^9.0.0",
"husky": "^8.0.3",
"lint-staged": "^15.1.0",
"prettier": "^3.1.0",
"prisma": "^5.6.0",
"typescript": "^5.6.2"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --quiet --fix"
],
"*.{json,ts,tsx}": [
"prettier --write --ignore-unknown"
]
}
}

View File

@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "Expense" (
"id" TEXT NOT NULL,
"description" TEXT NOT NULL,
"cost" DOUBLE PRECISION NOT NULL,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"categoryId" TEXT NOT NULL,
CONSTRAINT "Expense_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Expense_categoryId_idx" ON "Expense"("categoryId");
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `Category` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "DayLog" (
"id" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"comments" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DayLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DayLog_date_key" ON "DayLog"("date");
-- CreateIndex
CREATE INDEX "DayLog_date_idx" ON "DayLog"("date");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

9
prisma/prisma.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
export default prisma;

39
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,39 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
}
model Expense {
id String @id @default(cuid())
description String
cost Float
deleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categoryId String
category Category @relation(fields: [categoryId], references: [id])
@@index([categoryId])
}
model Category {
id String @id @default(cuid())
name String @unique
createdAt DateTime @default(now())
expenses Expense[]
}
model DayLog {
id String @id @default(cuid())
date DateTime @unique // When querying, ensure date is set to midnight UTC
comments Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([date])
}

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@api/*": ["./app/api/*"],
"@prisma/*": ["./prisma/*"],
"@utils/*": ["./utils/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", ".yarn", ".next", ".vercel", ".vscode"]
}

View 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
View 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
View 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
View 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
View 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}`);
}
}
}

67
utils/expense.ts Normal file
View File

@@ -0,0 +1,67 @@
import prisma from '@prisma/prisma';
import { ExpenseType } from './types';
const createOrGetCategory = async (name: string) => {
const category = await prisma.category.upsert({
where: { name },
update: {},
create: { name }
});
return category;
};
export const createExpense = async (data: ExpenseType) => {
const category = await createOrGetCategory(data.categoryName);
const newExpense = await prisma.expense.create({
data: {
description: data.description,
cost: data.cost,
categoryId: category.id,
deleted: false
},
include: {
category: true
}
});
return newExpense;
};
export const updateExpense = async (id: string, data: Partial<ExpenseType>) => {
let categoryId = undefined;
if (data.categoryName) {
const category = await createOrGetCategory(data.categoryName);
categoryId = category.id;
}
const updatedExpense = await prisma.expense.update({
where: {
id,
deleted: false
},
data: {
...(data.description && { description: data.description }),
...(data.cost && { cost: data.cost }),
...(categoryId && { categoryId })
},
include: {
category: true
}
});
return updatedExpense;
};
export const deleteExpense = async (id: string) => {
await prisma.expense.update({
where: {
id,
deleted: false
},
data: {
deleted: true
}
});
};

58
utils/handler.ts Normal file
View File

@@ -0,0 +1,58 @@
import { ShortcutsRequest, ShortcutsResponse } from './types';
import { CommandRegistry } from './registry';
export class ShortcutsHandler {
private registry: CommandRegistry;
constructor() {
this.registry = new CommandRegistry();
}
validateRequest(req: ShortcutsRequest): boolean {
try {
const isValidUser = req.apiKey === process.env.API_KEY;
return !!isValidUser;
} catch (error) {
console.error('Error validating request:', error);
return false;
}
}
async processCommand(
command: string,
parameters?: Record<string, string>
): Promise<ShortcutsResponse> {
if (command === 'expense') {
const handler = this.registry.getCommand(command);
if (!handler) {
return {
success: false,
message: `Unknown command: ${command}`
};
}
return handler(parameters);
}
const handler = this.registry.getCommand(command);
if (!handler) {
return {
success: false,
message: `Unknown command: ${command}`
};
}
try {
return await handler(parameters);
} catch (error) {
console.error(`Error processing command ${command}:`, error);
return {
success: false,
message:
error instanceof Error
? error.message
: 'An error occurred while processing your command'
};
}
}
}

40
utils/registry.ts Normal file
View File

@@ -0,0 +1,40 @@
import { ShortcutsResponse } from './types';
import { pingCommand } from './commands/ping';
import { diaryCommand } from './commands/diary';
import { CommandParser, diaryCommands } from './commands/commandParser';
type CommandHandler = (
parameters?: Record<string, string>
) => Promise<ShortcutsResponse>;
export class CommandRegistry {
private commands: Map<string, CommandHandler>;
private parser: CommandParser;
constructor() {
this.commands = new Map();
this.parser = new CommandParser();
this.registerDefaultCommands();
}
private registerDefaultCommands() {
this.commands.set('ping', pingCommand);
this.commands.set('diary', diaryCommand);
diaryCommands.forEach(cmd => {
this.parser.registerCommand(cmd);
});
}
register(command: string, handler: CommandHandler) {
this.commands.set(command.toLowerCase(), handler);
}
getCommand(command: string): CommandHandler | undefined {
return this.commands.get(command.toLowerCase());
}
getParser(): CommandParser {
return this.parser;
}
}

57
utils/types.ts Normal file
View File

@@ -0,0 +1,57 @@
import { Category, Expense } from '@prisma/client';
import { z } from 'zod';
interface Flag {
name: string;
type: 'string' | 'number' | 'boolean' | 'date';
required: boolean;
alias?: string;
}
export interface CommandDefinition {
name: string;
flags: Flag[];
hasId?: boolean;
}
export const RequestSchema = z.object({
command: z.string(),
parameters: z.record(z.string()).optional(),
apiKey: z.string()
});
export type ShortcutsRequest = z.infer<typeof RequestSchema>;
export interface ShortcutsResponse {
success: boolean;
message: string;
data?: unknown;
action?: {
type: 'notification' | 'openUrl' | 'runShortcut' | 'wait';
payload: unknown;
};
}
const ExpenseSchema = z.object({
description: z.string().min(1),
cost: z.number().positive(),
categoryName: z.string()
});
export type ExpenseType = z.infer<typeof ExpenseSchema>;
export interface ReportData {
expenses: (Expense & { category: Category })[];
summary: {
totalExpenses: number;
byCategory: {
category: string;
total: number;
count: number;
}[];
};
dateRange: {
from: Date;
to: Date;
};
}

43
vercel.json Normal file
View File

@@ -0,0 +1,43 @@
{
"functions": {
"app/api/**/*": {
"maxDuration": 30
}
},
"headers": [
{
"source": "/api/(.*)",
"headers": [
{
"key": "Access-Control-Allow-Methods",
"value": "GET, POST, OPTIONS"
},
{
"key": "Access-Control-Allow-Headers",
"value": "Content-Type, Accept"
},
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains; preload"
},
{
"key": "Content-Security-Policy",
"value": "default-src 'none'"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{ "key": "Referrer-Policy", "value": "same-origin" },
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
]
}
]
}

4077
yarn.lock Normal file

File diff suppressed because it is too large Load Diff