Compare commits

20 Commits

Author SHA1 Message Date
87dc07488b chore: add test file
Some checks failed
Deploy / lint-build-deploy (push) Failing after 1m1s
2026-02-01 14:24:14 +01:00
e4c9b0d2ec feat: switch to sweego
Some checks failed
Deploy / lint-build-deploy (push) Failing after 58s
2026-02-01 14:06:33 +01:00
36102810ac chore: gitea changes
All checks were successful
Deploy / lint-build-deploy (push) Successful in 2m8s
2026-01-22 19:46:31 +01:00
266d6c162f Merge pull request #8 from RiccardoSenica/dependabot/npm_and_yarn/npm_and_yarn-c61ea41056
chore(deps): bump next from 14.2.32 to 14.2.35 in the npm_and_yarn group across 1 directory
2025-12-29 07:22:07 +01:00
dependabot[bot]
bb2fc5548a chore(deps): bump next in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 14.2.32 to 14.2.35
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.32...v14.2.35)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 14.2.35
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-13 14:24:55 +00:00
ee8415f500 Merge pull request #7 from RiccardoSenica/dependabot/npm_and_yarn/npm_and_yarn-3c67cbb9cd
chore(deps): bump js-yaml from 4.1.0 to 4.1.1 in the npm_and_yarn group across 1 directory
2025-11-16 20:44:30 +01:00
dependabot[bot]
126dc37eb7 chore(deps): bump js-yaml in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 4.1.0 to 4.1.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-16 08:48:29 +00:00
f8ca5261f7 Merge pull request #6 from RiccardoSenica/dependabot/npm_and_yarn/npm_and_yarn-e6a81f5c20
chore(deps): bump next from 14.2.30 to 14.2.32 in the npm_and_yarn group across 1 directory
2025-09-14 20:41:56 +08:00
dependabot[bot]
48060795d0 chore(deps): bump next in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 14.2.30 to 14.2.32
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.30...v14.2.32)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 14.2.32
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 00:36:00 +00:00
6e8286e624 Merge pull request #5 from RiccardoSenica/dependabot/npm_and_yarn/npm_and_yarn-3bd7c2c787
chore(deps): bump next from 14.2.22 to 14.2.30 in the npm_and_yarn group across 1 directory
2025-06-18 22:47:35 +02:00
dependabot[bot]
d7521302de chore(deps): bump next in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 14.2.22 to 14.2.30
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.22...v14.2.30)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 14.2.30
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-15 03:34:26 +00:00
Riccardo Senica
c3dae18818 chore: change date format 2025-01-25 10:17:57 +00:00
Riccardo Senica
cdef3ec86a docs: update texts 2025-01-23 20:37:22 +00:00
Riccardo Senica
fc75d5da15 feat: add -date flag 2025-01-23 20:29:42 +00:00
Riccardo Senica
6589e8a815 feat: use more default values 2025-01-21 05:36:43 +00:00
Riccardo Senica
26cfc526d1 style: tweak report layout 2025-01-20 05:52:50 +00:00
Riccardo Senica
7c1e3989a5 feat: add stars field to comments 2025-01-19 21:06:15 +00:00
Riccardo Senica
89a20c214b feat: add day logs to report 2025-01-19 20:50:08 +00:00
8c60f400ea chore: report tweaks 2025-01-19 11:33:52 +01:00
3bd243778c chore: some refactor and semplification 2025-01-19 11:25:31 +01:00
19 changed files with 8631 additions and 1070 deletions

View File

@@ -1,19 +1,6 @@
API_KEY= API_KEY=
DATABASE_URL= 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= RECIPIENT_EMAIL=
RESEND_API_KEY= SWEEGO_API_KEY=
SENDER_EMAIL= SWEEGO_FROM=
DEFAULT_CATEGORY=

View 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
View 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"]

View File

@@ -17,19 +17,19 @@ A personal expenses and day log tracking API that works with Siri Shortcuts. Usi
#### Add an Expense #### 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 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. 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 #### Delete an Expense
@@ -42,9 +42,11 @@ Example: `delete abc123`
#### Generate Report #### 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: Generates and emails an expense report for the specified period. The report includes:
- Total expenses for the period - 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 ### 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. 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 Logs are stored with UTC midnight timestamps for consistent date handling
@@ -130,23 +134,10 @@ Create a `.env` file with:
``` ```
API_KEY= API_KEY=
DATABASE_URL= 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= RECIPIENT_EMAIL=
RESEND_API_KEY= SWEEGO_API_KEY=
SENDER_EMAIL= SWEEGO_FROM=
DEFAULT_CATEGORY=
``` ```
### 🗄️ Database Management ### 🗄️ Database Management
@@ -194,7 +185,7 @@ Example shortcut configuration:
{ {
"command": "expense", "command": "expense",
"parameters": { "parameters": {
"instruction": "add --desc \"Coffee\" --cost 3.50 --cat food" "instruction": "add -desc \"Coffee\" -cost 3.50 -cat food"
}, },
"apiKey": "your_api_key_here" "apiKey": "your_api_key_here"
} }

View File

@@ -4,6 +4,10 @@ import { NextResponse } from 'next/server';
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
if (!process.env.API_KEY) {
throw new Error('API KEY environment variable is not set');
}
const body = await req.json(); const body = await req.json();
const result = RequestSchema.safeParse(body); const result = RequestSchema.safeParse(body);

View File

@@ -1,9 +1,9 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Script from 'next/script';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'DiaryWhisper', title: 'DiaryWhisper',
description: description: 'Siri-enabled diary tracker for expenses and day logs'
'Siri-enabled diary tracker for expenses and day logs'
}; };
export default function RootLayout({ export default function RootLayout({
@@ -14,6 +14,11 @@ export default function RootLayout({
return ( return (
<html lang='en'> <html lang='en'>
<body>{children}</body> <body>{children}</body>
<Script
defer
src="https://analytics.frompixels.com/script.js"
data-website-id="922dfc50-84ea-4e4e-a421-a03a00a4421c"
/>
</html> </html>
); );
} }

View File

@@ -4,36 +4,37 @@ import React from 'react';
export default function Home() { export default function Home() {
const commands = [ const commands = [
'expense add --desc "Coffee" --cost 3.50 --cat "Food"', 'expense add -desc "Coffee" -cost 3.50 -cat "Food"',
'expense report --dateFrom "2024-01-01" --dateTo "2024-01-31"', 'expense report -from "2024-01-01" -to "2024-01-31"',
'expense daylog --text "Added team lunch" --date "2024-01-18"' 'expense daylog -stars 3 -text "Added team lunch" -date "2024-01-18"'
]; ];
return ( return (
<> <>
<div className="container"> <div className='container'>
<div className="header"> <div className='header'>
<h1>DiaryWhisper v1.0.0</h1> <h1>DiaryWhisper v1.0.0</h1>
<p>Your expenses and day logs tracked via Siri Shortcuts</p> <p>Your expenses and day logs tracked via Siri Shortcuts</p>
</div> </div>
<div className="terminal"> <div className='terminal'>
<div>Loading system components...</div> <div>Loading system components...</div>
<div>Initializing expense database... OK</div> <div>Initializing expense database... OK</div>
<div>Starting expense tracking daemon... OK</div> <div>Starting expense tracking daemon... OK</div>
<div>System ready_</div> <div>System ready_</div>
</div> </div>
<div className="commands"> <div className='commands'>
<h2>Available Commands:</h2> <h2>Available Commands:</h2>
{commands.map((cmd, i) => ( {commands.map((cmd, i) => (
<div key={i} className="command"> <div key={i} className='command'>
<span>$ </span>{cmd} <span>$ </span>
{cmd}
</div> </div>
))} ))}
</div> </div>
<div className="status"> <div className='status'>
<span>Status: OPERATIONAL</span> <span>Status: OPERATIONAL</span>
<span>Database: CONNECTED</span> <span>Database: CONNECTED</span>
<span>Last Backup: 2024-01-18 14:30 UTC</span> <span>Last Backup: 2024-01-18 14:30 UTC</span>

7544
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,10 +21,11 @@
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.6.0", "@prisma/client": "^5.6.0",
"next": "^14.2.15", "axios": "^1.13.4",
"dotenv": "^17.2.3",
"next": "^14.2.35",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"resend": "^4.1.1",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,11 +1,11 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") // uses connection pooling url = env("DATABASE_URL")
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
} }
model Expense { model Expense {

53
test-request.js Normal file
View 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);
});

View File

@@ -1,9 +1,13 @@
import { ExpenseType, ShortcutsResponse } from '../types'; import { ExpenseType, ShortcutsResponse } from '@utils/types';
import { CommandParser, diaryCommands } from './commandParser'; import { CommandParser, diaryCommands } from './helpers/commandParser';
import { Category, Expense } from '@prisma/client'; import { Category, Expense } from '@prisma/client';
import { ExpenseReporter } from './report'; import { ExpenseReporter } from './report';
import { createExpense, deleteExpense, updateExpense } from '@utils/expense'; import {
import { processDayLog } from '@utils/commands/dayLog'; createExpense,
deleteExpense,
updateExpense
} from '@utils/commands/helpers/expense';
import { processDayLog } from '@utils/commands/helpers/dayLog';
const formatResponse = (expense: Expense & { category: Category }) => ({ const formatResponse = (expense: Expense & { category: Category }) => ({
id: expense.id, id: expense.id,
@@ -21,7 +25,7 @@ export async function diaryCommand(
if (!parameters || !parameters['instruction']) { if (!parameters || !parameters['instruction']) {
return { return {
success: false, success: false,
message: 'Message parameter is missing.' message: 'Instruction parameter is missing.'
}; };
} }
@@ -32,23 +36,23 @@ export async function diaryCommand(
switch (parsedCommand.command) { switch (parsedCommand.command) {
case 'add': { case 'add': {
if (!parsedCommand.flags.cat) { const categoryName =
return { (parsedCommand.flags.cat as string) || process.env.DEFAULT_CATEGORY;
success: false, if (!categoryName) {
message: 'Category is required' throw new Error('DEFAULT_CATEGORY environment variable is not set');
};
} }
const expense = await createExpense({ const expense = await createExpense({
description: parsedCommand.flags.desc as string, description: parsedCommand.flags.desc as string,
cost: parsedCommand.flags.cost as number, 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); const formatted = formatResponse(expense);
return { return {
success: true, 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 data: formatted
}; };
} }
@@ -97,14 +101,14 @@ export async function diaryCommand(
case 'report': { case 'report': {
try { try {
const reporter = new ExpenseReporter(); const reporter = new ExpenseReporter();
const from = parsedCommand.flags.dateFrom as Date; const from = parsedCommand.flags.from as Date;
const to = parsedCommand.flags.dateTo as Date; const to = (parsedCommand.flags.to as Date) || new Date();
const includeJson = (parsedCommand.flags.export as boolean) || false; const includeJson = (parsedCommand.flags.export as boolean) || false;
await reporter.sendReport(from, to, includeJson); await reporter.sendReport(from, to, includeJson);
const formatDate = (date: Date) => const formatDate = (date: Date) =>
date.toLocaleDateString('en-US', { date.toLocaleDateString('en-GB', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric'
@@ -126,10 +130,11 @@ export async function diaryCommand(
} }
case 'daylog': { case 'daylog': {
const stars = parsedCommand.flags.stars as number;
const text = parsedCommand.flags.text as string; const text = parsedCommand.flags.text as string;
const date = (parsedCommand.flags.date as Date) || new Date(); const date = (parsedCommand.flags.date as Date) || new Date();
return processDayLog(text, date); return processDayLog(stars, text, date);
} }
default: default:

View File

@@ -42,11 +42,11 @@ export class CommandParser {
while (currentIndex < parts.length) { while (currentIndex < parts.length) {
const flag = parts[currentIndex]; const flag = parts[currentIndex];
if (!flag.startsWith('--')) { if (!flag.startsWith('-')) {
throw new Error(`Invalid flag format at: ${flag}`); throw new Error(`Invalid flag format at: ${flag}`);
} }
const flagName = flag.slice(2); const flagName = flag.slice(1);
const flagDef = definition.flags.find( const flagDef = definition.flags.find(
f => f.name === flagName || f.alias === flagName f => f.name === flagName || f.alias === flagName
); );
@@ -113,7 +113,8 @@ export const diaryCommands: CommandDefinition[] = [
flags: [ flags: [
{ name: 'desc', type: 'string', required: true }, { name: 'desc', type: 'string', required: true },
{ name: 'cost', type: 'number', 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', name: 'report',
flags: [ flags: [
{ name: 'dateFrom', type: 'date', required: true }, { name: 'from', type: 'date', required: true },
{ name: 'dateTo', type: 'date', required: true }, { name: 'to', type: 'date', required: false },
{ name: 'export', type: 'boolean', required: false } { name: 'export', type: 'boolean', required: false }
] ]
}, },
{ {
name: 'daylog', name: 'daylog',
flags: [ flags: [
{ name: 'stars', type: 'number', required: true },
{ name: 'text', type: 'string', required: true }, { name: 'text', type: 'string', required: true },
{ name: 'date', type: 'date', required: false } { name: 'date', type: 'date', required: false }
] ]

View File

@@ -3,6 +3,7 @@ import prisma from '@prisma/prisma';
import { ShortcutsResponse } from '@utils/types'; import { ShortcutsResponse } from '@utils/types';
export async function processDayLog( export async function processDayLog(
stars: number,
text: string, text: string,
date?: Date date?: Date
): Promise<ShortcutsResponse> { ): Promise<ShortcutsResponse> {
@@ -11,6 +12,7 @@ export async function processDayLog(
normalizedDate.setUTCHours(0, 0, 0, 0); normalizedDate.setUTCHours(0, 0, 0, 0);
const newComment: Prisma.JsonObject = { const newComment: Prisma.JsonObject = {
stars,
text, text,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };
@@ -39,7 +41,7 @@ export async function processDayLog(
return { return {
success: true, success: true,
message: `Added comment to existing log for ${normalizedDate.toLocaleDateString()}`, message: `Added comment to existing log for ${normalizedDate.toLocaleDateString('en-GB')}`,
data: updatedLog data: updatedLog
}; };
} else { } else {
@@ -52,7 +54,7 @@ export async function processDayLog(
return { return {
success: true, success: true,
message: `Created new log for ${normalizedDate.toLocaleDateString()}`, message: `Created new log for ${normalizedDate.toLocaleDateString('en-GB')}`,
data: newLog data: newLog
}; };
} }
@@ -60,7 +62,8 @@ export async function processDayLog(
console.error('Error processing daylog:', error); console.error('Error processing daylog:', error);
return { return {
success: false, success: false,
message: error instanceof Error ? error.message : 'Failed to process daylog' message:
error instanceof Error ? error.message : 'Failed to process daylog'
}; };
} }
} }

View File

@@ -1,5 +1,5 @@
import prisma from '@prisma/prisma'; import prisma from '@prisma/prisma';
import { ExpenseType } from './types'; import { ExpenseType } from '@utils/types';
const createOrGetCategory = async (name: string) => { const createOrGetCategory = async (name: string) => {
const category = await prisma.category.upsert({ const category = await prisma.category.upsert({
@@ -17,6 +17,7 @@ export const createExpense = async (data: ExpenseType) => {
data: { data: {
description: data.description, description: data.description,
cost: data.cost, cost: data.cost,
createdAt: data.date,
categoryId: category.id, categoryId: category.id,
deleted: false deleted: false
}, },

View File

@@ -1,28 +1,33 @@
import { Resend } from 'resend'; import { Prisma } from '@prisma/client';
import prisma from '@prisma/prisma'; 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 { export class ExpenseReporter {
private resend: Resend;
private senderEmail: string; private senderEmail: string;
private recipientEmail: string; private recipientEmail: string;
private sweegoApiKey: string;
constructor() { constructor() {
if (!process.env.RESEND_API_KEY) { if (!process.env.SWEEGO_API_KEY) {
throw new Error('RESEND_API_KEY environment variable is not set'); throw new Error('SWEEGO_API_KEY environment variable is not set');
} }
if (!process.env.SENDER_EMAIL) { if (!process.env.SWEEGO_FROM) {
throw new Error('SENDER_EMAIL environment variable is not set'); throw new Error('SWEEGO_FROM environment variable is not set');
} }
if (!process.env.RECIPIENT_EMAIL) { if (!process.env.RECIPIENT_EMAIL) {
throw new Error('RECIPIENT_EMAIL environment variable is not set'); throw new Error('RECIPIENT_EMAIL environment variable is not set');
} }
this.resend = new Resend(process.env.RESEND_API_KEY); this.sweegoApiKey = process.env.SWEEGO_API_KEY;
this.senderEmail = process.env.SENDER_EMAIL; this.senderEmail = process.env.SWEEGO_FROM;
this.recipientEmail = process.env.RECIPIENT_EMAIL; 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); const startDate = new Date(from);
startDate.setHours(0, 0, 0, 0); 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) => const formatDate = (date: Date) =>
date.toLocaleDateString('en-US', { date.toLocaleDateString('en-GB', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric'
}); });
const formatCurrency = (amount: number) => const formatCurrency = (amount: number) =>
amount.toLocaleString('en-US', { amount.toLocaleString('en-GB', {
style: 'currency', style: 'currency',
currency: 'EUR' currency: 'EUR'
}); });
@@ -102,15 +138,36 @@ export class ExpenseReporter {
th { background-color: #f5f5f5; } th { background-color: #f5f5f5; }
.summary { margin: 20px 0; padding: 20px; background: #f9f9f9; border-radius: 5px; } .summary { margin: 20px 0; padding: 20px; background: #f9f9f9; border-radius: 5px; }
.category-summary { margin-top: 10px; } .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> </style>
</head> </head>
<body> <body>
<h1>Expense Report</h1> <h1>Diary Report</h1>
<p>From ${formatDate(data.dateRange.from)} to ${formatDate(data.dateRange.to)}</p> <p>From ${formatDate(expenses.dateRange.from)} to ${formatDate(expenses.dateRange.to)}</p>
<div class="summary"> <div class="summary">
<h2>Summary</h2> <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"> <div class="category-summary">
<h3>Expenses by Category</h3> <h3>Expenses by Category</h3>
@@ -119,16 +176,14 @@ export class ExpenseReporter {
<th>Category</th> <th>Category</th>
<th>Total</th> <th>Total</th>
<th>Count</th> <th>Count</th>
<th>Average</th>
</tr> </tr>
${data.summary.byCategory ${expenses.summary.byCategory
.map( .map(
cat => ` cat => `
<tr> <tr>
<td>${cat.category}</td> <td>${cat.category}</td>
<td>${formatCurrency(cat.total)}</td> <td>${formatCurrency(cat.total)}</td>
<td>${cat.count}</td> <td>${cat.count}</td>
<td>${formatCurrency(cat.total / cat.count)}</td>
</tr> </tr>
` `
) )
@@ -140,15 +195,17 @@ export class ExpenseReporter {
<h2>Detailed Expenses</h2> <h2>Detailed Expenses</h2>
<table> <table>
<tr> <tr>
<th>ID</th>
<th>Date</th> <th>Date</th>
<th>Description</th> <th>Description</th>
<th>Category</th> <th>Category</th>
<th>Amount</th> <th>Amount</th>
</tr> </tr>
${data.expenses ${expenses.expenses
.map( .map(
exp => ` exp => `
<tr> <tr>
<td>${exp.id}</td>
<td>${formatDate(exp.createdAt)}</td> <td>${formatDate(exp.createdAt)}</td>
<td>${exp.description}</td> <td>${exp.description}</td>
<td>${exp.category.name}</td> <td>${exp.category.name}</td>
@@ -158,6 +215,33 @@ export class ExpenseReporter {
) )
.join('')} .join('')}
</table> </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> </body>
</html> </html>
`; `;
@@ -168,30 +252,53 @@ export class ExpenseReporter {
to: Date, to: Date,
includeJson: boolean = false includeJson: boolean = false
): Promise<void> { ): Promise<void> {
const reportData = await this.generateReport(from, to); const reportExpenseData = await this.generateExpenses(from, to);
const htmlContent = this.generateHtmlReport(reportData); const reportDayLogData = await this.generateDayLogs(from, to);
const htmlContent = this.generateHtmlReport(
reportExpenseData,
reportDayLogData
);
const attachments = []; const attachments = [];
if (includeJson) { if (includeJson) {
const jsonData = JSON.stringify(reportData, null, 2); const jsonExpenseData = JSON.stringify(reportExpenseData, null, 2);
attachments.push({ attachments.push({
filename: 'expense-report.json', filename: 'expenses.json',
content: Buffer.from(jsonData).toString('base64'), content: Buffer.from(jsonExpenseData).toString('base64'),
contentType: 'application/json' // Added MIME type 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 { try {
const response = await this.resend.emails.send({ const response = await fetch(SWEEGO_API_URL, {
from: this.senderEmail, method: 'POST',
to: this.recipientEmail, headers: {
subject: `Expense Report: ${from.toLocaleDateString()} - ${to.toLocaleDateString()}`, 'Content-Type': 'application/json',
html: htmlContent, 'Api-Key': this.sweegoApiKey
attachments },
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) { if (!response.ok) {
throw new Error('Failed to send email: No id returned from Resend'); const error = await response.text();
throw new Error(`Sweego API error: ${response.status} ${error}`);
} }
} catch (error) { } catch (error) {
console.error('Failed to send email:', error); console.error('Failed to send email:', error);

View File

@@ -1,7 +1,7 @@
import { ShortcutsResponse } from './types'; import { ShortcutsResponse } from './types';
import { pingCommand } from './commands/ping'; import { pingCommand } from './commands/ping';
import { diaryCommand } from './commands/diary'; import { diaryCommand } from './commands/diary';
import { CommandParser, diaryCommands } from './commands/commandParser'; import { CommandParser, diaryCommands } from './commands/helpers/commandParser';
type CommandHandler = ( type CommandHandler = (
parameters?: Record<string, string> parameters?: Record<string, string>

View File

@@ -1,4 +1,4 @@
import { Category, Expense } from '@prisma/client'; import { Category, DayLog, Expense } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
interface Flag { interface Flag {
@@ -35,12 +35,13 @@ export interface ShortcutsResponse {
const ExpenseSchema = z.object({ const ExpenseSchema = z.object({
description: z.string().min(1), description: z.string().min(1),
cost: z.number().positive(), cost: z.number().positive(),
categoryName: z.string() categoryName: z.string(),
date: z.date()
}); });
export type ExpenseType = z.infer<typeof ExpenseSchema>; export type ExpenseType = z.infer<typeof ExpenseSchema>;
export interface ReportData { export interface ReportExpenseData {
expenses: (Expense & { category: Category })[]; expenses: (Expense & { category: Category })[];
summary: { summary: {
totalExpenses: number; totalExpenses: number;
@@ -55,3 +56,11 @@ export interface ReportData {
to: Date; to: Date;
}; };
} }
export interface ReportDayLogsData {
dayLogs: DayLog[];
dateRange: {
from: Date;
to: Date;
};
}

1660
yarn.lock

File diff suppressed because it is too large Load Diff