diff --git a/.env b/.env index 02f56a0..55ac285 100644 --- a/.env +++ b/.env @@ -6,6 +6,9 @@ ACCESS_TOKEN_URL= AUTHORIZE_URL= TOKEN_URL= SPLITWISE_API_KEY= +DISCORD_WEBHOOK_URL= +# SPLITWISE_GROUP_ID=79865654 +MONGODB_URI= # If you need to build a SaaS application with Stripe subscription payment with checkout page, customer portal, webhook, etc. # You can check out the Next.js Boilerplate SaaS: https://nextjs-boilerplate.com/pro-saas-starter-kit diff --git a/.env.production b/.env.production index 71b0b85..f8c0a99 100644 --- a/.env.production +++ b/.env.production @@ -6,6 +6,9 @@ ACCESS_TOKEN_URL= AUTHORIZE_URL= TOKEN_URL= SPLITWISE_API_KEY= +DISCORD_WEBHOOK_URL= +# SPLITWISE_GROUP_ID=79865654 +MONGODB_URI= # If you need to build a SaaS application with Stripe subscription payment with checkout page, customer portal, webhook, etc. # You can check out the Next.js Boilerplate SaaS: https://nextjs-boilerplate.com/pro-saas-starter-kit diff --git a/package-lock.json b/package-lock.json index 569f470..9c6b398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "chart.js": "^4.4.9", "daisyui": "^5.0.27", "drizzle-orm": "^0.41.0", + "mongodb": "^6.15.0", + "mongoose": "^8.13.2", "next": "^15.3.0", "next-intl": "^3.26.5", "pg": "^8.14.1", @@ -6486,6 +6488,15 @@ "react": ">=16" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.2.2.tgz", + "integrity": "sha512-EB0O3SCSNRUFk66iRCpI+cXzIjdswfCs7F6nOC3RAGJ7xr5YhaicvsRwJ9eyzYvYRlCSDUO/c7g4yNulxKC1WA==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@msgpack/msgpack": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz", @@ -12257,6 +12268,21 @@ "@types/node": "*" } }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz", @@ -14673,6 +14699,15 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz", + "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -22785,7 +22820,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "jsbn": "1.1.0", @@ -26159,7 +26194,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/jsdoc-type-pratt-parser": { @@ -26480,6 +26515,15 @@ "dev": true, "license": "MIT" }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -28056,6 +28100,12 @@ "map-or-similar": "^1.5.0" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -28905,6 +28955,93 @@ "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==", "license": "MIT" }, + "node_modules/mongodb": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.15.0.tgz", + "integrity": "sha512-ifBhQ0rRzHDzqp9jAQP6OwHSH7dbYIQjD3SbJs9YYk9AikKEettW/9s/tbSFDTpXcRbF+u1aLrhHxDFaYtZpFQ==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.3", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.13.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.13.2.tgz", + "integrity": "sha512-riCBqZmNkYBWjXpM3qWLDQw7QmTKsVZDPhLXFJqC87+OjocEVpvS3dA2BPPUiLAu+m0/QmEj5pSXKhH+/DgerQ==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.3", + "kareem": "2.6.3", + "mongodb": "~6.15.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mqtt": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.10.1.tgz", @@ -28974,6 +29111,18 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -37594,6 +37743,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -37793,7 +37948,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -37840,7 +37995,7 @@ "version": "2.8.4", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ip-address": "^9.0.5", @@ -37951,6 +38106,15 @@ "webidl-conversions": "^4.0.2" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/spawn-error-forwarder": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", @@ -38139,7 +38303,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/stable-hash": { @@ -39366,7 +39530,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.0.tgz", "integrity": "sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==", - "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -41268,7 +41431,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -41499,7 +41661,6 @@ "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, "license": "MIT", "dependencies": { "tr46": "^5.1.0", diff --git a/package.json b/package.json index f921d4d..f9ad051 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "chart.js": "^4.4.9", "daisyui": "^5.0.27", "drizzle-orm": "^0.41.0", + "mongodb": "^6.15.0", + "mongoose": "^8.13.2", "next": "^15.3.0", "next-intl": "^3.26.5", "pg": "^8.14.1", diff --git a/src/app/[locale]/api/splitwise/groups/[groupId]/expenses/route.ts b/src/app/[locale]/api/splitwise/groups/[groupId]/expenses/route.ts new file mode 100644 index 0000000..507e02f --- /dev/null +++ b/src/app/[locale]/api/splitwise/groups/[groupId]/expenses/route.ts @@ -0,0 +1,83 @@ +import sendMessage from '@/lib/discord/discord'; +import { initializeMongoDB } from '@/lib/mongodb/init'; +import { logger } from '@/libs/Logger'; +import Expense from '@/models/Expense'; +import { NextResponse } from 'next/server'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ locale: string; groupId: string }> }, +) { + try { + await initializeMongoDB(); + // Get most recent 10 expenses from DB + const { groupId } = await params; + const groupIdNum = Number.parseInt(groupId); + const expenses = await Expense.find({ group_id: groupIdNum }).sort({ createdAt: -1 }).limit(10); + await updateExpenses(groupIdNum); + return NextResponse.json(expenses, { + headers: { + 'Cache-Control': 'public, max-age=1800, s-maxage=1800', + }, + }); + } catch (error) { + logger.error('Splitwise API Error:', error); + return NextResponse.json( + { error: 'Get Splitwise Expenses Error' }, + { status: 500 }, + ); + } +} + +async function turnExpenseIntoMessage(expense: any) { + const userDetails = expense.users.map((user: any) => { + return `${user.user.first_name} ${user.user.last_name} (已付: ${user.paid_share} CAD, 应付: ${user.owed_share} CAD)`; + }).join('\n'); + + return `新支出: ${expense.description} + 金额: ${expense.cost} ${expense.currency_code} + 详情: ${userDetails} + 查看详情: https://live-split-board.hermanyiqunliang.com/`; +} + +async function updateExpenses(groupId: number) { + const response = await fetch(`https://secure.splitwise.com/api/v3.0/get_expenses?group_id=${groupId}&limit=10`, { + headers: { + 'Authorization': `Bearer ${process.env.SPLITWISE_API_KEY}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Splitwise API Error: ${response.status}`); + } + + const data = await response.json(); + const expenses = data.expenses; + const messages = []; + for (const expense of expenses) { + // check if expense already exists + const existingExpense = await Expense.findOne({ id: expense.id }); + if (existingExpense) { + continue; + } + // if not exists, add to messages + messages.push(await turnExpenseIntoMessage(expense)); + // create new expense + const newExpense = new Expense(expense); + await newExpense.save(); + } + + // send message to discord + if (messages.length > 0) { + try { + const messageContent = messages.join('\n'); + logger.info('Sending Discord message:', { messagesCount: messages.length }); + await sendMessage(messageContent); + } catch (error) { + logger.error('Discord message send error:', error); + } + } else { + logger.info('No new expenses to send to Discord'); + } +} diff --git a/src/app/[locale]/api/splitwise/route.ts b/src/app/[locale]/api/splitwise/groups/info/route.ts similarity index 88% rename from src/app/[locale]/api/splitwise/route.ts rename to src/app/[locale]/api/splitwise/groups/info/route.ts index fe7a0f3..15e1217 100644 --- a/src/app/[locale]/api/splitwise/route.ts +++ b/src/app/[locale]/api/splitwise/groups/info/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; export async function GET() { try { - const response = await fetch('https://secure.splitwise.com/api/v3.0/get_groups', { + const response = await fetch(`https://secure.splitwise.com/api/v3.0/get_groups`, { headers: { 'Authorization': `Bearer ${process.env.SPLITWISE_API_KEY}`, 'Content-Type': 'application/json', diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 602d077..af65ed0 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -28,8 +28,17 @@ export default function Home() { useEffect(() => { const fetchData = async () => { try { - const response = await fetch('/api/splitwise'); + const response = await fetch('/api/splitwise/groups/info'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + console.warn(data); + if (!data.groups || !Array.isArray(data.groups)) { + throw new Error('Invalid data format received from API'); + } + setGroups(data.groups); const firstGroup = data.groups.find((group: Group) => group.id !== 0); if (firstGroup) { @@ -37,13 +46,19 @@ export default function Home() { } } catch (error) { console.error('Error fetching data:', error); - setError('Failed to fetch data'); + setError(error instanceof Error ? error.message : 'Failed to fetch data'); + setGroups([]); + setSelectedGroup(null); } finally { setIsLoading(false); } }; fetchData(); + + const intervalId = setInterval(fetchData, 30 * 60 * 1000); + + return () => clearInterval(intervalId); }, []); if (isLoading) { diff --git a/src/components/CurrentCount.tsx b/src/components/CurrentCount.tsx deleted file mode 100644 index 4e56ada..0000000 --- a/src/components/CurrentCount.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { db } from '@/libs/DB'; -import { logger } from '@/libs/Logger'; -import { counterSchema } from '@/models/Schema'; -import { eq } from 'drizzle-orm'; -import { getTranslations } from 'next-intl/server'; -import { headers } from 'next/headers'; - -export const CurrentCount = async () => { - const t = await getTranslations('CurrentCount'); - - // `x-e2e-random-id` is used for end-to-end testing to make isolated requests - // The default value is 0 when there is no `x-e2e-random-id` header - const id = Number((await headers()).get('x-e2e-random-id')) ?? 0; - const result = await db.query.counterSchema.findMany({ - where: eq(counterSchema.id, id), - }); - const count = result[0]?.count ?? 0; - - logger.info('Counter fetched successfully'); - - return ( -
- {t('count', { count })} -
- ); -}; diff --git a/src/components/GroupChart.tsx b/src/components/GroupChart.tsx new file mode 100644 index 0000000..512283e --- /dev/null +++ b/src/components/GroupChart.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useMemo } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +type Member = { + id: number; + first_name: string; + last_name: string | null; + balance: Array<{ + currency_code: string; + amount: string; + }>; +}; + +type Group = { + id: number; + name: string; + members: Member[]; +}; + +type GroupChartProps = { + group: Group | null; +}; + +export default function GroupChart({ group }: GroupChartProps) { + const processChartData = (group: Group) => { + return group.members.map((member) => { + const balance = member.balance.find(b => b.currency_code === 'CAD'); + return { + id: member.id, + name: member.first_name, + amount: balance ? Number.parseFloat(balance.amount) : 0, + }; + }); + }; + + const chartData = useMemo(() => { + if (!group) { + return []; + } + return processChartData(group); + }, [group]); + + const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+

+ {payload[0].value > 0 ? 'To Receive' : 'To Pay'} + : $ + {Math.abs(payload[0].value).toFixed(2)} +

+
+ ); + } + return null; + }; + + if (!group) { + return
No group selected
; + } + + return ( +
+
+ {chartData.length > 0 + ? ( + + + + + + } /> + + {chartData.map((entry: any) => ( + = 0 ? '#4CAF50' : '#FF5252'} /> + ))} + + + + ) + : ( +
No data available
+ )} +
+
+ ); +} diff --git a/src/components/GroupExpense.tsx b/src/components/GroupExpense.tsx new file mode 100644 index 0000000..50cffa0 --- /dev/null +++ b/src/components/GroupExpense.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type Expense = { + id: number; + description: string; + cost: string; + currency_code: string; + date: string; + created_by: { + id: number; + first_name: string; + last_name: string | null; + }; + users: { + user: { + id: number; + first_name: string; + last_name: string | null; + }; + paid_share: string; + owed_share: string; + }[]; +}; + +type GroupExpenseProps = { + groupId: number | null; +}; + +export default function GroupExpense({ groupId }: GroupExpenseProps) { + const [expenses, setExpenses] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchExpenses = async () => { + if (!groupId) { + return; + } + + setLoading(true); + setError(null); + + try { + const response = await fetch(`/api/splitwise/groups/${groupId}/expenses`); + if (!response.ok) { + throw new Error('Failed to fetch expenses'); + } + const data = await response.json(); + setExpenses(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + } + }; + + fetchExpenses(); + }, [groupId]); + + if (!groupId) { + return
Please select a group
; + } + + if (loading) { + return
Loading...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+
+ + + + + + + + + + + {[...expenses].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).map(expense => ( + + + + + + + ))} + +
DescriptionAmountParticipantsDate
{expense.description} + {expense.cost} + {' '} + {expense.currency_code.charAt(0)} + +
+ {expense.users.map(user => ( + + {user.user.first_name} + + ))} +
+
+ {new Date(expense.date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })} +
+
+
+ ); +} diff --git a/src/components/VisualSplitwise.tsx b/src/components/VisualSplitwise.tsx index 0874ac8..b10e10a 100644 --- a/src/components/VisualSplitwise.tsx +++ b/src/components/VisualSplitwise.tsx @@ -1,16 +1,7 @@ 'use client'; -import { useMemo } from 'react'; -import { - Bar, - BarChart, - CartesianGrid, - Cell, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; +import GroupChart from './GroupChart'; +import GroupExpense from './GroupExpense'; type Member = { id: number; @@ -35,40 +26,6 @@ type VisualSplitwiseProps = { }; export default function VisualSplitwise({ groups, selectedGroup, onGroupChange }: VisualSplitwiseProps) { - const processChartData = (group: Group) => { - return group.members.map((member) => { - const balance = member.balance.find(b => b.currency_code === 'CAD'); - return { - id: member.id, - name: member.first_name, - amount: balance ? Number.parseFloat(balance.amount) : 0, - }; - }); - }; - - const chartData = useMemo(() => { - if (!selectedGroup) { - return []; - } - return processChartData(selectedGroup); - }, [selectedGroup]); - - const CustomTooltip = ({ active, payload, label }: any) => { - if (active && payload && payload.length) { - return ( -
-

{label}

-

- {payload[0].value > 0 ? 'To Receive' : 'To Pay'} - : $ - {Math.abs(payload[0].value).toFixed(2)} -

-
- ); - } - return null; - }; - return (

Visual Splitwise

@@ -93,58 +50,9 @@ export default function VisualSplitwise({ groups, selectedGroup, onGroupChange }
- {selectedGroup && ( -
-
- {chartData.length > 0 - ? ( - - - - - - } /> - - {chartData.map((entry: any) => ( - = 0 ? '#4CAF50' : '#FF5252'} /> - ))} - - - - ) - : ( -
No data available
- )} -
-
- )} + +

Expenses

+ ); } diff --git a/src/lib/discord/discord.ts b/src/lib/discord/discord.ts new file mode 100644 index 0000000..561cbd6 --- /dev/null +++ b/src/lib/discord/discord.ts @@ -0,0 +1,35 @@ +import { logger } from '@/libs/Logger'; + +async function sendMessage(message: string) { + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + if (!webhookUrl) { + throw new Error('DISCORD_WEBHOOK_URL is not set'); + } + + if (!message || message.trim() === '') { + logger.error('Empty message provided to sendMessage, skipping'); + return; + } + + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content: message }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Discord webhook error: ${response.status} - ${errorText}`); + } + logger.info('Message sent to Discord'); + return true; + } catch (error) { + logger.error('Error sending message to Discord:', error); + throw error; + } +} + +export default sendMessage; diff --git a/src/lib/mongodb/database.ts b/src/lib/mongodb/database.ts new file mode 100644 index 0000000..d46c33b --- /dev/null +++ b/src/lib/mongodb/database.ts @@ -0,0 +1,35 @@ +import mongoose from 'mongoose'; + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/expense-tracker'; + +export const connectDB = async () => { + try { + if (!mongoose.connections.length || (mongoose.connections[0] && mongoose.connections[0].readyState === 0)) { + await mongoose.connect(MONGODB_URI); + console.warn('MongoDB connected successfully'); + } + } catch (error) { + console.error('MongoDB connection error:', error); + throw error; + } +}; + +export const disconnectDB = async () => { + try { + await mongoose.disconnect(); + console.warn('MongoDB disconnected successfully'); + } catch (error) { + console.error('MongoDB disconnection error:', error); + throw error; + } +}; + +export const getDB = () => { + if (!mongoose.connection.readyState) { + throw new Error('MongoDB is not connected'); + } + return mongoose.connection.db; +}; + +// 移除事件监听器,因为在Edge Runtime中不可靠 +// 使用isConnected标志来跟踪连接状态 diff --git a/src/lib/mongodb/init.ts b/src/lib/mongodb/init.ts new file mode 100644 index 0000000..7fa5aa8 --- /dev/null +++ b/src/lib/mongodb/init.ts @@ -0,0 +1,19 @@ +import { connectDB } from '@/lib/mongodb/database'; + +let isConnected = false; + +export const initializeMongoDB = async () => { + if (isConnected) { + console.warn('MongoDB is already connected'); + return; + } + + try { + await connectDB(); + isConnected = true; + console.warn('MongoDB initialized successfully'); + } catch (error) { + console.error('Failed to initialize MongoDB:', error); + throw error; + } +}; diff --git a/src/libs/DB.ts b/src/libs/DB.ts deleted file mode 100644 index ef70aeb..0000000 --- a/src/libs/DB.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { PgliteDatabase } from 'drizzle-orm/pglite'; -import path from 'node:path'; -import * as schema from '@/models/Schema'; -import { PGlite } from '@electric-sql/pglite'; -import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; -import { migrate as migratePg } from 'drizzle-orm/node-postgres/migrator'; -import { drizzle as drizzlePglite } from 'drizzle-orm/pglite'; -import { migrate as migratePglite } from 'drizzle-orm/pglite/migrator'; -import { PHASE_PRODUCTION_BUILD } from 'next/dist/shared/lib/constants'; -import { Client } from 'pg'; -import { Env } from './Env'; - -let client; -let drizzle; - -if (process.env.NEXT_PHASE !== PHASE_PRODUCTION_BUILD && Env.DATABASE_URL) { - client = new Client({ - connectionString: Env.DATABASE_URL, - }); - await client.connect(); - - drizzle = drizzlePg(client, { schema }); - await migratePg(drizzle, { - migrationsFolder: path.join(process.cwd(), 'migrations'), - }); -} else { - // Stores the db connection in the global scope to prevent multiple instances due to hot reloading with Next.js - const global = globalThis as unknown as { client: PGlite; drizzle: PgliteDatabase }; - - if (!global.client) { - global.client = new PGlite(); - await global.client.waitReady; - - global.drizzle = drizzlePglite(global.client, { schema }); - } - - drizzle = global.drizzle; - await migratePglite(global.drizzle, { - migrationsFolder: path.join(process.cwd(), 'migrations'), - }); -} - -export const db = drizzle; diff --git a/src/middleware.ts b/src/middleware.ts index 88b342a..88804de 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,24 +1,24 @@ import type { NextFetchEvent, NextRequest } from 'next/server'; import arcjet from '@/libs/Arcjet'; import { detectBot } from '@arcjet/next'; -import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; +// import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; import createMiddleware from 'next-intl/middleware'; import { NextResponse } from 'next/server'; import { routing } from './libs/i18nNavigation'; const intlMiddleware = createMiddleware(routing); -const isProtectedRoute = createRouteMatcher([ - '/dashboard(.*)', - '/:locale/dashboard(.*)', -]); +// const isProtectedRoute = createRouteMatcher([ +// '/dashboard(.*)', +// '/:locale/dashboard(.*)', +// ]); -const isAuthPage = createRouteMatcher([ - '/sign-in(.*)', - '/:locale/sign-in(.*)', - '/sign-up(.*)', - '/:locale/sign-up(.*)', -]); +// const isAuthPage = createRouteMatcher([ +// '/sign-in(.*)', +// '/:locale/sign-in(.*)', +// '/sign-up(.*)', +// '/:locale/sign-up(.*)', +// ]); // Improve security with Arcjet const aj = arcjet.withRule( @@ -36,7 +36,7 @@ const aj = arcjet.withRule( export default async function middleware( request: NextRequest, - event: NextFetchEvent, + _event: NextFetchEvent, ) { // Verify the request with Arcjet // Use `process.env` instead of Env to reduce bundle size in middleware @@ -47,45 +47,17 @@ export default async function middleware( // redirect or show a custom error page if (decision.isDenied()) { if (decision.reason.isBot()) { - throw new Error('No bots allowed'); + return NextResponse.json( + { error: 'Bot detected' }, + { status: 403 }, + ); } - - throw new Error('Access denied'); } } - // Run Clerk middleware only when it's necessary - if ( - isAuthPage(request) || isProtectedRoute(request) - ) { - return clerkMiddleware(async (auth, req) => { - if (isProtectedRoute(req)) { - const locale - = req.nextUrl.pathname.match(/(\/.*)\/dashboard/)?.at(1) ?? ''; - - const signInUrl = new URL(`${locale}/sign-in`, req.url); - - await auth.protect({ - // `unauthenticatedUrl` is needed to avoid error: "Unable to find `next-intl` locale because the middleware didn't run on this request" - unauthenticatedUrl: signInUrl.toString(), - }); - } - - return intlMiddleware(req); - })(request, event); - } - - // Extract the URL pathname from the request - const path = request.nextUrl.pathname; - - // Allow direct access to sitemap.xml and robots.txt without i18n middleware processing - // This ensures these files are properly served for SEO purposes - // Related to GitHub issue: https://github.com/ixartz/Next-js-Boilerplate/issues/356 - if (path === '/sitemap.xml' || path === '/robots.txt') { - return NextResponse.next(); - } - - return intlMiddleware(request); + // Handle internationalization + const response = await intlMiddleware(request); + return response; } export const config = { diff --git a/src/models/Expense.ts b/src/models/Expense.ts new file mode 100644 index 0000000..338bd55 --- /dev/null +++ b/src/models/Expense.ts @@ -0,0 +1,80 @@ +import type { Model } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; + +const ExpenseSchema = new Schema({ + id: { type: Number, required: true, unique: true }, + group_id: { type: Number, required: true }, + expense_bundle_id: { type: Number }, + description: { type: String, required: true }, + repeats: { type: Boolean, default: false }, + repeat_interval: { type: String }, + email_reminder: { type: Boolean, default: false }, + email_reminder_in_advance: { type: Number, default: -1 }, + next_repeat: { type: Date }, + details: { type: String }, + comments_count: { type: Number, default: 0 }, + payment: { type: Boolean, default: false }, + creation_method: { type: String, required: true }, + transaction_method: { type: String, required: true }, + transaction_confirmed: { type: Boolean, default: false }, + transaction_id: { type: String }, + transaction_status: { type: String }, + cost: { type: String, required: true }, + currency_code: { type: String, required: true }, + repayments: [{ + from: { type: Number, required: true }, + to: { type: Number, required: true }, + amount: { type: String, required: true }, + }], + date: { type: Date, required: true }, + created_at: { type: Date, required: true }, + created_by: { + id: { type: Number, required: true }, + first_name: { type: String, required: true }, + last_name: { type: String }, + }, + updated_at: { type: Date, required: true }, + updated_by: { + id: { type: Number }, + first_name: { type: String }, + last_name: { type: String }, + }, + deleted_at: { type: Date }, + deleted_by: { + id: { type: Number }, + first_name: { type: String }, + last_name: { type: String }, + }, + category: { + id: { type: Number, required: true }, + name: { type: String, required: true }, + }, + receipt: { + large: { type: String }, + original: { type: String }, + }, + users: [{ + user: { + id: { type: Number, required: true }, + first_name: { type: String, required: true }, + last_name: { type: String }, + picture: { + medium: { type: String }, + }, + custom_picture: { type: Boolean, default: false }, + }, + user_id: { type: Number, required: true }, + paid_share: { type: String, required: true }, + owed_share: { type: String, required: true }, + net_balance: { type: String, required: true }, + }], +}, { + timestamps: true, +}); + +ExpenseSchema.index({ group_id: 1 }); +ExpenseSchema.index({ date: 1 }); + +const Expense = (mongoose.models.Expense || mongoose.model('Expense', ExpenseSchema)) as Model; + +export default Expense; diff --git a/src/models/Schema.ts b/src/models/Schema.ts deleted file mode 100644 index 830abc8..0000000 --- a/src/models/Schema.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { integer, pgTable, serial, timestamp } from 'drizzle-orm/pg-core'; - -// This file defines the structure of your database tables using the Drizzle ORM. - -// To modify the database schema: -// 1. Update this file with your desired changes. -// 2. Generate a new migration by running: `npm run db:generate` - -// The generated migration file will reflect your schema changes. -// The migration is automatically applied during the next database interaction, -// so there's no need to run it manually or restart the Next.js server. - -export const counterSchema = pgTable('counter', { - id: serial('id').primaryKey(), - count: integer('count').default(0), - updatedAt: timestamp('updated_at', { mode: 'date' }) - .defaultNow() - .$onUpdate(() => new Date()) - .notNull(), - createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(), -}); diff --git a/src/services/splitwise.service.ts b/src/services/splitwise.service.ts new file mode 100644 index 0000000..e69de29