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 ( -
{label}
++ {payload[0].value > 0 ? 'To Receive' : 'To Pay'} + : $ + {Math.abs(payload[0].value).toFixed(2)} +
+| Description | +Amount | +Participants | +Date | +
|---|---|---|---|
| {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' })} + | +
{label}
-- {payload[0].value > 0 ? 'To Receive' : 'To Pay'} - : $ - {Math.abs(payload[0].value).toFixed(2)} -
-