Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87dc07488b | |||
| e4c9b0d2ec | |||
| 36102810ac | |||
| 266d6c162f | |||
|
|
bb2fc5548a | ||
| ee8415f500 | |||
|
|
126dc37eb7 | ||
| f8ca5261f7 | |||
|
|
48060795d0 | ||
| 6e8286e624 | |||
|
|
d7521302de | ||
|
|
c3dae18818 | ||
|
|
cdef3ec86a | ||
|
|
fc75d5da15 | ||
|
|
6589e8a815 | ||
|
|
26cfc526d1 | ||
|
|
7c1e3989a5 | ||
|
|
89a20c214b | ||
| 8c60f400ea | |||
| 3bd243778c |
19
.env.example
19
.env.example
@@ -1,19 +1,6 @@
|
||||
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=
|
||||
SWEEGO_API_KEY=
|
||||
SWEEGO_FROM=
|
||||
DEFAULT_CATEGORY=
|
||||
|
||||
46
.gitea/workflows/deploy.yml
Normal file
46
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
lint-build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
if [ -f package-lock.json ]; then
|
||||
npm ci
|
||||
else
|
||||
npm install
|
||||
fi
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to VPS
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H 51.210.247.57 >> ~/.ssh/known_hosts
|
||||
ssh debian@51.210.247.57 << 'EOF'
|
||||
cd /home/debian/diarywhisper
|
||||
git pull origin main
|
||||
cd /home/debian/infrastructure
|
||||
docker-compose up -d --build diarywhisper
|
||||
EOF
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
37
README.md
37
README.md
@@ -17,19 +17,19 @@ A personal expenses and day log tracking API that works with Siri Shortcuts. Usi
|
||||
#### Add an Expense
|
||||
|
||||
```
|
||||
add --desc "description" --cost amount --cat category
|
||||
add -desc "description" -cost amount -cat category -date "date"
|
||||
```
|
||||
|
||||
Example: `add --desc "Weekly groceries" --cost 87.50 --cat groceries`
|
||||
Example: `add -desc "Weekly groceries" -cost 87.50 -cat groceries -date "2025-01-15"`
|
||||
|
||||
#### Update an Expense
|
||||
|
||||
```
|
||||
update expenseId --desc "new description" --cost newAmount --cat newCategory
|
||||
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`
|
||||
Example: `update abc123 -cost 92.30 -cat groceries`
|
||||
|
||||
#### Delete an Expense
|
||||
|
||||
@@ -42,9 +42,11 @@ Example: `delete abc123`
|
||||
#### Generate Report
|
||||
|
||||
```
|
||||
report --dateFrom "2025-01-01" --dateTo "2025-01-31" --export true
|
||||
report -from dateFrom -to dateTo -export boolean
|
||||
```
|
||||
|
||||
Example: `report -from "2025-01-01" -to "2025-01-31" -export true`
|
||||
|
||||
Generates and emails an expense report for the specified period. The report includes:
|
||||
|
||||
- Total expenses for the period
|
||||
@@ -56,9 +58,11 @@ The `export` flag is optional - when set to true, a JSON file with the raw data
|
||||
### Day Log
|
||||
|
||||
```
|
||||
daylog --text "Meeting notes or daily summary" --date "2024-01-18"
|
||||
daylog -stars number -text "text" -date "date"
|
||||
```
|
||||
|
||||
Example: `daylog -stars 3 -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
|
||||
@@ -130,23 +134,10 @@ 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=
|
||||
SWEEGO_API_KEY=
|
||||
SWEEGO_FROM=
|
||||
DEFAULT_CATEGORY=
|
||||
```
|
||||
|
||||
### 🗄️ Database Management
|
||||
@@ -194,7 +185,7 @@ Example shortcut configuration:
|
||||
{
|
||||
"command": "expense",
|
||||
"parameters": {
|
||||
"instruction": "add --desc \"Coffee\" --cost 3.50 --cat food"
|
||||
"instruction": "add -desc \"Coffee\" -cost 3.50 -cat food"
|
||||
},
|
||||
"apiKey": "your_api_key_here"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
if (!process.env.API_KEY) {
|
||||
throw new Error('API KEY environment variable is not set');
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
const result = RequestSchema.safeParse(body);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Script from 'next/script';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'DiaryWhisper',
|
||||
description:
|
||||
'Siri-enabled diary tracker for expenses and day logs'
|
||||
description: 'Siri-enabled diary tracker for expenses and day logs'
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -14,6 +14,11 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang='en'>
|
||||
<body>{children}</body>
|
||||
<Script
|
||||
defer
|
||||
src="https://analytics.frompixels.com/script.js"
|
||||
data-website-id="922dfc50-84ea-4e4e-a421-a03a00a4421c"
|
||||
/>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
29
app/page.tsx
29
app/page.tsx
@@ -3,37 +3,38 @@
|
||||
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"'
|
||||
];
|
||||
const commands = [
|
||||
'expense add -desc "Coffee" -cost 3.50 -cat "Food"',
|
||||
'expense report -from "2024-01-01" -to "2024-01-31"',
|
||||
'expense daylog -stars 3 -text "Added team lunch" -date "2024-01-18"'
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<div className="header">
|
||||
<>
|
||||
<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 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">
|
||||
<div className='commands'>
|
||||
<h2>Available Commands:</h2>
|
||||
{commands.map((cmd, i) => (
|
||||
<div key={i} className="command">
|
||||
<span>$ </span>{cmd}
|
||||
<div key={i} className='command'>
|
||||
<span>$ </span>
|
||||
{cmd}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="status">
|
||||
<div className='status'>
|
||||
<span>Status: OPERATIONAL</span>
|
||||
<span>Database: CONNECTED</span>
|
||||
<span>Last Backup: 2024-01-18 14:30 UTC</span>
|
||||
@@ -110,4 +111,4 @@ export default function Home() {
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
7544
package-lock.json
generated
Normal file
7544
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,10 +21,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.6.0",
|
||||
"next": "^14.2.15",
|
||||
"axios": "^1.13.4",
|
||||
"dotenv": "^17.2.3",
|
||||
"next": "^14.2.35",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"resend": "^4.1.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
|
||||
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Expense {
|
||||
|
||||
53
test-request.js
Normal file
53
test-request.js
Normal file
@@ -0,0 +1,53 @@
|
||||
require('dotenv').config();
|
||||
const axios = require('axios');
|
||||
|
||||
const BASE_URL = 'http://localhost:3000/api/command';
|
||||
const API_KEY = process.env.API_KEY;
|
||||
|
||||
async function request(command, parameters) {
|
||||
const { data } = await axios.post(BASE_URL, {
|
||||
command,
|
||||
parameters,
|
||||
apiKey: API_KEY
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const ping = await request('ping');
|
||||
console.log("PING:", ping);
|
||||
|
||||
const add = await request('diary', {
|
||||
instruction: 'add -desc "Test expense" -cost 12.50 -cat food'
|
||||
});
|
||||
console.log("Add:", add);
|
||||
const expenseId = add.data?.id;
|
||||
|
||||
const update = await request('diary', {
|
||||
instruction: `update ${expenseId} -cost 15.00`
|
||||
});
|
||||
console.log("Update:", update);
|
||||
|
||||
const del = await request('diary', {
|
||||
instruction: `delete ${expenseId}`
|
||||
});
|
||||
console.log("Delete:", del);
|
||||
|
||||
const daylog = await request('diary', {
|
||||
instruction: 'daylog -stars 4 -text "Test day log entry"'
|
||||
});
|
||||
console.log("Day log:", daylog);
|
||||
|
||||
await request('diary', {
|
||||
instruction: 'add -desc "Test expense 2" -cost 14.99 -cat travel'
|
||||
});
|
||||
|
||||
const report = await request('diary', {
|
||||
instruction: 'report -from "2025-01-01" -export true'
|
||||
});
|
||||
console.log("Report:", report);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Error:', err.response?.data || err.message);
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ExpenseType, ShortcutsResponse } from '../types';
|
||||
import { CommandParser, diaryCommands } from './commandParser';
|
||||
import { ExpenseType, ShortcutsResponse } from '@utils/types';
|
||||
import { CommandParser, diaryCommands } from './helpers/commandParser';
|
||||
import { Category, Expense } from '@prisma/client';
|
||||
import { ExpenseReporter } from './report';
|
||||
import { createExpense, deleteExpense, updateExpense } from '@utils/expense';
|
||||
import { processDayLog } from '@utils/commands/dayLog';
|
||||
import {
|
||||
createExpense,
|
||||
deleteExpense,
|
||||
updateExpense
|
||||
} from '@utils/commands/helpers/expense';
|
||||
import { processDayLog } from '@utils/commands/helpers/dayLog';
|
||||
|
||||
const formatResponse = (expense: Expense & { category: Category }) => ({
|
||||
id: expense.id,
|
||||
@@ -21,7 +25,7 @@ export async function diaryCommand(
|
||||
if (!parameters || !parameters['instruction']) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Message parameter is missing.'
|
||||
message: 'Instruction parameter is missing.'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,23 +36,23 @@ export async function diaryCommand(
|
||||
|
||||
switch (parsedCommand.command) {
|
||||
case 'add': {
|
||||
if (!parsedCommand.flags.cat) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Category is required'
|
||||
};
|
||||
const categoryName =
|
||||
(parsedCommand.flags.cat as string) || process.env.DEFAULT_CATEGORY;
|
||||
if (!categoryName) {
|
||||
throw new Error('DEFAULT_CATEGORY environment variable is not set');
|
||||
}
|
||||
|
||||
const expense = await createExpense({
|
||||
description: parsedCommand.flags.desc as string,
|
||||
cost: parsedCommand.flags.cost as number,
|
||||
categoryName: parsedCommand.flags.cat as string
|
||||
date: (parsedCommand.flags.date as Date) || new Date(),
|
||||
categoryName
|
||||
});
|
||||
|
||||
const formatted = formatResponse(expense);
|
||||
return {
|
||||
success: true,
|
||||
message: `Added expense: ${formatted.description} (${formatted.cost.toFixed(2)}€) in category ${formatted.category}`,
|
||||
message: `Added expense: ${formatted.description} (${formatted.cost.toFixed(2)}€) in category ${formatted.category} for ${formatted.createdAt.toLocaleDateString('en-GB')}`,
|
||||
data: formatted
|
||||
};
|
||||
}
|
||||
@@ -97,14 +101,14 @@ export async function diaryCommand(
|
||||
case 'report': {
|
||||
try {
|
||||
const reporter = new ExpenseReporter();
|
||||
const from = parsedCommand.flags.dateFrom as Date;
|
||||
const to = parsedCommand.flags.dateTo as Date;
|
||||
const from = parsedCommand.flags.from as Date;
|
||||
const to = (parsedCommand.flags.to as Date) || new Date();
|
||||
const includeJson = (parsedCommand.flags.export as boolean) || false;
|
||||
|
||||
await reporter.sendReport(from, to, includeJson);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString('en-US', {
|
||||
date.toLocaleDateString('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
@@ -126,10 +130,11 @@ export async function diaryCommand(
|
||||
}
|
||||
|
||||
case 'daylog': {
|
||||
const text = parsedCommand.flags.text as string;
|
||||
const date = (parsedCommand.flags.date as Date) || new Date();
|
||||
const stars = parsedCommand.flags.stars as number;
|
||||
const text = parsedCommand.flags.text as string;
|
||||
const date = (parsedCommand.flags.date as Date) || new Date();
|
||||
|
||||
return processDayLog(text, date);
|
||||
return processDayLog(stars, text, date);
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -42,11 +42,11 @@ export class CommandParser {
|
||||
|
||||
while (currentIndex < parts.length) {
|
||||
const flag = parts[currentIndex];
|
||||
if (!flag.startsWith('--')) {
|
||||
if (!flag.startsWith('-')) {
|
||||
throw new Error(`Invalid flag format at: ${flag}`);
|
||||
}
|
||||
|
||||
const flagName = flag.slice(2);
|
||||
const flagName = flag.slice(1);
|
||||
const flagDef = definition.flags.find(
|
||||
f => f.name === flagName || f.alias === flagName
|
||||
);
|
||||
@@ -113,7 +113,8 @@ export const diaryCommands: CommandDefinition[] = [
|
||||
flags: [
|
||||
{ name: 'desc', type: 'string', required: true },
|
||||
{ name: 'cost', type: 'number', required: true },
|
||||
{ name: 'cat', type: 'string', required: false }
|
||||
{ name: 'cat', type: 'string', required: false },
|
||||
{ name: 'date', type: 'date', required: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -133,14 +134,15 @@ export const diaryCommands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'report',
|
||||
flags: [
|
||||
{ name: 'dateFrom', type: 'date', required: true },
|
||||
{ name: 'dateTo', type: 'date', required: true },
|
||||
{ name: 'from', type: 'date', required: true },
|
||||
{ name: 'to', type: 'date', required: false },
|
||||
{ name: 'export', type: 'boolean', required: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'daylog',
|
||||
flags: [
|
||||
{ name: 'stars', type: 'number', required: true },
|
||||
{ name: 'text', type: 'string', required: true },
|
||||
{ name: 'date', type: 'date', required: false }
|
||||
]
|
||||
@@ -3,6 +3,7 @@ import prisma from '@prisma/prisma';
|
||||
import { ShortcutsResponse } from '@utils/types';
|
||||
|
||||
export async function processDayLog(
|
||||
stars: number,
|
||||
text: string,
|
||||
date?: Date
|
||||
): Promise<ShortcutsResponse> {
|
||||
@@ -11,6 +12,7 @@ export async function processDayLog(
|
||||
normalizedDate.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const newComment: Prisma.JsonObject = {
|
||||
stars,
|
||||
text,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
@@ -39,7 +41,7 @@ export async function processDayLog(
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Added comment to existing log for ${normalizedDate.toLocaleDateString()}`,
|
||||
message: `Added comment to existing log for ${normalizedDate.toLocaleDateString('en-GB')}`,
|
||||
data: updatedLog
|
||||
};
|
||||
} else {
|
||||
@@ -52,7 +54,7 @@ export async function processDayLog(
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Created new log for ${normalizedDate.toLocaleDateString()}`,
|
||||
message: `Created new log for ${normalizedDate.toLocaleDateString('en-GB')}`,
|
||||
data: newLog
|
||||
};
|
||||
}
|
||||
@@ -60,7 +62,8 @@ export async function processDayLog(
|
||||
console.error('Error processing daylog:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to process daylog'
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Failed to process daylog'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import prisma from '@prisma/prisma';
|
||||
import { ExpenseType } from './types';
|
||||
import { ExpenseType } from '@utils/types';
|
||||
|
||||
const createOrGetCategory = async (name: string) => {
|
||||
const category = await prisma.category.upsert({
|
||||
@@ -17,6 +17,7 @@ export const createExpense = async (data: ExpenseType) => {
|
||||
data: {
|
||||
description: data.description,
|
||||
cost: data.cost,
|
||||
createdAt: data.date,
|
||||
categoryId: category.id,
|
||||
deleted: false
|
||||
},
|
||||
@@ -1,28 +1,33 @@
|
||||
import { Resend } from 'resend';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import prisma from '@prisma/prisma';
|
||||
import { ReportData } from '@utils/types';
|
||||
import { ReportExpenseData, ReportDayLogsData } from '@utils/types';
|
||||
|
||||
const SWEEGO_API_URL = 'https://api.sweego.io/send';
|
||||
|
||||
export class ExpenseReporter {
|
||||
private resend: Resend;
|
||||
private senderEmail: string;
|
||||
private recipientEmail: string;
|
||||
private sweegoApiKey: string;
|
||||
|
||||
constructor() {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
throw new Error('RESEND_API_KEY environment variable is not set');
|
||||
if (!process.env.SWEEGO_API_KEY) {
|
||||
throw new Error('SWEEGO_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.SWEEGO_FROM) {
|
||||
throw new Error('SWEEGO_FROM 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.sweegoApiKey = process.env.SWEEGO_API_KEY;
|
||||
this.senderEmail = process.env.SWEEGO_FROM;
|
||||
this.recipientEmail = process.env.RECIPIENT_EMAIL;
|
||||
}
|
||||
|
||||
private async generateReport(from: Date, to: Date): Promise<ReportData> {
|
||||
private async generateExpenses(
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<ReportExpenseData> {
|
||||
const startDate = new Date(from);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -77,16 +82,47 @@ export class ExpenseReporter {
|
||||
};
|
||||
}
|
||||
|
||||
private generateHtmlReport(data: ReportData): string {
|
||||
private async generateDayLogs(
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<ReportDayLogsData> {
|
||||
const startDate = new Date(from);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const endDate = new Date(to);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
|
||||
const dayLogs = await prisma.dayLog.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dayLogs,
|
||||
dateRange: { from, to }
|
||||
};
|
||||
}
|
||||
|
||||
private generateHtmlReport(
|
||||
expenses: ReportExpenseData,
|
||||
dayLogs: ReportDayLogsData
|
||||
): string {
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString('en-US', {
|
||||
date.toLocaleDateString('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const formatCurrency = (amount: number) =>
|
||||
amount.toLocaleString('en-US', {
|
||||
amount.toLocaleString('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
@@ -102,15 +138,36 @@ export class ExpenseReporter {
|
||||
th { background-color: #f5f5f5; }
|
||||
.summary { margin: 20px 0; padding: 20px; background: #f9f9f9; border-radius: 5px; }
|
||||
.category-summary { margin-top: 10px; }
|
||||
.rating {
|
||||
--star-size: 20px;
|
||||
--star-background: #ffd700;
|
||||
--star-color: #ddd;
|
||||
--percent: calc(var(--rating) * 20%);
|
||||
display: inline-block;
|
||||
font-size: var(--star-size);
|
||||
font-family: Times; /* Ensures better star symbol rendering */
|
||||
line-height: 1;
|
||||
background: linear-gradient(90deg,
|
||||
var(--star-background) var(--percent),
|
||||
var(--star-color) var(--percent)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.rating::before {
|
||||
content: '★★★★★';
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Expense Report</h1>
|
||||
<p>From ${formatDate(data.dateRange.from)} to ${formatDate(data.dateRange.to)}</p>
|
||||
<h1>Diary Report</h1>
|
||||
<p>From ${formatDate(expenses.dateRange.from)} to ${formatDate(expenses.dateRange.to)}</p>
|
||||
|
||||
<div class="summary">
|
||||
<h2>Summary</h2>
|
||||
<p><strong>Total Expenses:</strong> ${formatCurrency(data.summary.totalExpenses)}</p>
|
||||
<p><strong>Total Expenses:</strong> ${formatCurrency(expenses.summary.totalExpenses)}</p>
|
||||
|
||||
<div class="category-summary">
|
||||
<h3>Expenses by Category</h3>
|
||||
@@ -119,16 +176,14 @@ export class ExpenseReporter {
|
||||
<th>Category</th>
|
||||
<th>Total</th>
|
||||
<th>Count</th>
|
||||
<th>Average</th>
|
||||
</tr>
|
||||
${data.summary.byCategory
|
||||
${expenses.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>
|
||||
`
|
||||
)
|
||||
@@ -140,15 +195,17 @@ export class ExpenseReporter {
|
||||
<h2>Detailed Expenses</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Category</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
${data.expenses
|
||||
${expenses.expenses
|
||||
.map(
|
||||
exp => `
|
||||
<tr>
|
||||
<td>${exp.id}</td>
|
||||
<td>${formatDate(exp.createdAt)}</td>
|
||||
<td>${exp.description}</td>
|
||||
<td>${exp.category.name}</td>
|
||||
@@ -158,6 +215,33 @@ export class ExpenseReporter {
|
||||
)
|
||||
.join('')}
|
||||
</table>
|
||||
|
||||
<h2>Day Logs Report</h2>
|
||||
<p>From ${formatDate(dayLogs.dateRange.from)} to ${formatDate(dayLogs.dateRange.to)}</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date</th>
|
||||
<th>Stars</th>
|
||||
<th>Log</th>
|
||||
</tr>
|
||||
${dayLogs.dayLogs
|
||||
.filter(
|
||||
(dl): dl is typeof dl & { comments: any[] } =>
|
||||
dl.comments !== null && Array.isArray(dl.comments)
|
||||
)
|
||||
.map(
|
||||
dl => `
|
||||
<tr>
|
||||
<td>${dl.id}</td>
|
||||
<td>${formatDate(dl.createdAt)}</td>
|
||||
<td><div class="rating" style="--rating: ${Math.round(dl.comments.reduce((a, c) => a + c.stars, 0) / dl.comments.length)};"></div></td>
|
||||
<td>${dl.comments.map(c => `- ${c.text}`).join('<br>')}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
@@ -168,30 +252,53 @@ export class ExpenseReporter {
|
||||
to: Date,
|
||||
includeJson: boolean = false
|
||||
): Promise<void> {
|
||||
const reportData = await this.generateReport(from, to);
|
||||
const htmlContent = this.generateHtmlReport(reportData);
|
||||
const reportExpenseData = await this.generateExpenses(from, to);
|
||||
const reportDayLogData = await this.generateDayLogs(from, to);
|
||||
const htmlContent = this.generateHtmlReport(
|
||||
reportExpenseData,
|
||||
reportDayLogData
|
||||
);
|
||||
|
||||
const attachments = [];
|
||||
if (includeJson) {
|
||||
const jsonData = JSON.stringify(reportData, null, 2);
|
||||
const jsonExpenseData = JSON.stringify(reportExpenseData, null, 2);
|
||||
attachments.push({
|
||||
filename: 'expense-report.json',
|
||||
content: Buffer.from(jsonData).toString('base64'),
|
||||
contentType: 'application/json' // Added MIME type
|
||||
filename: 'expenses.json',
|
||||
content: Buffer.from(jsonExpenseData).toString('base64'),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
|
||||
const jsonDayLogData = JSON.stringify(reportDayLogData, null, 2);
|
||||
attachments.push({
|
||||
filename: 'day-logs.json',
|
||||
content: Buffer.from(jsonDayLogData).toString('base64'),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.resend.emails.send({
|
||||
from: this.senderEmail,
|
||||
to: this.recipientEmail,
|
||||
subject: `Expense Report: ${from.toLocaleDateString()} - ${to.toLocaleDateString()}`,
|
||||
html: htmlContent,
|
||||
attachments
|
||||
const response = await fetch(SWEEGO_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Api-Key': this.sweegoApiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: 'email',
|
||||
provider: 'sweego',
|
||||
recipients: [{ email: this.recipientEmail }],
|
||||
from: {
|
||||
email: this.senderEmail
|
||||
},
|
||||
subject: `Diary Report: ${from.toLocaleDateString('en-GB')} - ${to.toLocaleDateString('en-GB')}`,
|
||||
'message-html': htmlContent,
|
||||
...(attachments.length > 0 && { attachments })
|
||||
})
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error('Failed to send email: No id returned from Resend');
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Sweego API error: ${response.status} ${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ShortcutsResponse } from './types';
|
||||
import { pingCommand } from './commands/ping';
|
||||
import { diaryCommand } from './commands/diary';
|
||||
import { CommandParser, diaryCommands } from './commands/commandParser';
|
||||
import { CommandParser, diaryCommands } from './commands/helpers/commandParser';
|
||||
|
||||
type CommandHandler = (
|
||||
parameters?: Record<string, string>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Category, Expense } from '@prisma/client';
|
||||
import { Category, DayLog, Expense } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface Flag {
|
||||
@@ -35,12 +35,13 @@ export interface ShortcutsResponse {
|
||||
const ExpenseSchema = z.object({
|
||||
description: z.string().min(1),
|
||||
cost: z.number().positive(),
|
||||
categoryName: z.string()
|
||||
categoryName: z.string(),
|
||||
date: z.date()
|
||||
});
|
||||
|
||||
export type ExpenseType = z.infer<typeof ExpenseSchema>;
|
||||
|
||||
export interface ReportData {
|
||||
export interface ReportExpenseData {
|
||||
expenses: (Expense & { category: Category })[];
|
||||
summary: {
|
||||
totalExpenses: number;
|
||||
@@ -55,3 +56,11 @@ export interface ReportData {
|
||||
to: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReportDayLogsData {
|
||||
dayLogs: DayLog[];
|
||||
dateRange: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user