feat: expenses, report and day log commands
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal 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
24
.eslintrc.json
Normal 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
44
.gitignore
vendored
Normal 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
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
||||||
5
.yarnrc.yml
Normal file
5
.yarnrc.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
compressionLevel: mixed
|
||||||
|
|
||||||
|
enableGlobalCache: false
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
202
README.md
202
README.md
@@ -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
50
app/api/command/route.ts
Normal 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
19
app/layout.tsx
Normal 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
113
app/page.tsx
Normal 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
12
app/robots.ts
Normal 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
3
commitlint.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ['@commitlint/config-conventional']
|
||||||
|
};
|
||||||
57
package.json
Normal file
57
package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
16
prisma/migrations/20250118193404_add_day_logs/migration.sql
Normal file
16
prisma/migrations/20250118193404_add_day_logs/migration.sql
Normal 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");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
9
prisma/prisma.ts
Normal 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
39
prisma/schema.prisma
Normal 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
29
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
utils/expense.ts
Normal file
67
utils/expense.ts
Normal 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
58
utils/handler.ts
Normal 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
40
utils/registry.ts
Normal 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
57
utils/types.ts
Normal 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
43
vercel.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user