diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0e0e678b..0e3bf880 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -8,6 +8,19 @@ jobs: runs-on: ubuntu-latest env: SKIP_ENV_VALIDATION: true + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres" + services: + postgres: + image: ossapps/postgres + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 steps: - name: Checkout @@ -27,6 +40,11 @@ jobs: - name: Install dependencies run: pnpm install + - name: generate typedSQL types + run: | + pnpm db:dev + pnpm generate --sql + - name: Apply prettier formatting run: pnpm prettier --check . diff --git a/.gitignore b/.gitignore index c8bd9af1..4c874e2b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ # database /prisma/db.sqlite /prisma/db.sqlite-journal - +/src/prisma/client # next.js /.next/ /out/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 54f2236f..b8a735a0 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -51,5 +51,5 @@ } } ], - "ignorePatterns": ["prisma/seed.ts", "src/components/ui/*"] + "ignorePatterns": ["prisma/seed.ts", "src/components/ui/*", "prisma/largeSeed.ts"] } diff --git a/prisma/largeSeed.ts b/prisma/largeSeed.ts new file mode 100644 index 00000000..172f1562 --- /dev/null +++ b/prisma/largeSeed.ts @@ -0,0 +1,127 @@ +import { PrismaClient, SplitType, User, Group } from '@prisma/client'; +import { randomInt } from 'crypto'; + +const prisma = new PrismaClient(); + +async function createUsers() { + const data = Array.from({ length: 1000 }, (value, index) => index).map((i) => { + return { + name: `user${i}`, + email: `user${i}@example.com`, + currency: 'USD', + }; + }); + const users = await prisma.user.createMany({ + data: data, + }); + return prisma.user.findMany({ + orderBy: { + id: 'asc', + }, + }); +} + +async function createGroups(users: User[]) { + if (users.length) { + for (let i = 0; i < 100; i++) { + const s = i * 10; + const e = (i + 1) * 10 - 1; + + // group of 10 + const group = await prisma.group.create({ + data: { + name: `Group_10_${i}`, + publicId: `Group-10-${i}`, + defaultCurrency: 'EUR', + userId: users[s].id, + }, + }); + + await prisma.groupUser.createMany({ + data: users.slice(s, e).map((u) => { + return { + groupId: group.id, + userId: u.id, + }; + }), + }); + } + } + + const group = await prisma.group.create({ + data: { + name: `Group_30`, + publicId: `Group-30`, + defaultCurrency: 'EUR', + userId: users[0].id, + }, + }); + + await prisma.groupUser.createMany({ + data: users.slice(0, 29).map((u) => { + return { + groupId: group.id, + userId: u.id, + }; + }), + }); + + return prisma.group.findMany({ + include: { + groupUsers: true, + }, + }); +} + +async function createExpenses(groups: Group[]) { + const currencies = ['EUR', 'USD']; + for (const gid in groups) { + const group = groups[gid]; + for (let i = 0; i < 10000; i++) { + const c = randomInt(0, 2); + const amount = BigInt(randomInt(1000, 10000)); + + const expense = await prisma.expense.create({ + data: { + name: `Expense Group ${group.id} ${i}`, + paidBy: group.groupUsers[0].userId, + addedBy: group.groupUsers[0].userId, + category: 'general', + currency: currencies[c], + amount: amount, + groupId: group.id, + splitType: SplitType.EQUAL, + }, + }); + + await prisma.expenseParticipant.createMany({ + data: [ + { expenseId: expense.id, userId: group.groupUsers[0].userId, amount: amount }, + ...group.groupUsers.slice(1).map((u) => { + return { + expenseId: expense.id, + userId: u.userId, + amount: -amount / BigInt(group.groupUsers.length), + }; + }), + ], + }); + } + } +} + +async function main() { + const users = await createUsers(); + const groups = await createGroups(users); + await createExpenses(groups); + console.log('Seeded db with users, groups and expenses'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => { + prisma.$disconnect().catch(console.log); + }); diff --git a/prisma/migrations/20250618113146_add_expense_participant_userid_index/migration.sql b/prisma/migrations/20250618113146_add_expense_participant_userid_index/migration.sql new file mode 100644 index 00000000..39f57ea4 --- /dev/null +++ b/prisma/migrations/20250618113146_add_expense_participant_userid_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "ExpenseParticipant_userId_idx" ON "ExpenseParticipant" USING HASH ("userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a5e68d40..905c4aae 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,6 @@ generator client { provider = "prisma-client-js" - previewFeatures = ["relationJoins"] + previewFeatures = ["relationJoins", "typedSQL"] } datasource db { @@ -208,6 +208,7 @@ model ExpenseParticipant { expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) @@id([expenseId, userId]) + @@index([userId], type: Hash) @@schema("public") } diff --git a/prisma/sql/getAllBalancesForGroup.sql b/prisma/sql/getAllBalancesForGroup.sql new file mode 100644 index 00000000..1c2e726c --- /dev/null +++ b/prisma/sql/getAllBalancesForGroup.sql @@ -0,0 +1,21 @@ +-- @param {Int} $1:id of the group +SELECT + "groupId", + "userId" AS "borrowedBy", + "paidBy", + "currency", + CAST(Coalesce(-1 * sum("ExpenseParticipant".amount), 0) AS BIGINT) AS amount +FROM + "Expense" + JOIN "ExpenseParticipant" ON "ExpenseParticipant"."expenseId" = "Expense".id +WHERE + "groupId" = $1 + AND "userId" != "paidBy" + AND "Expense"."deletedAt" IS NULL +GROUP BY + "userId", + "paidBy", + "currency", + "groupId" +ORDER BY + "currency" diff --git a/prisma/sql/getGroupsWithBalances.sql b/prisma/sql/getGroupsWithBalances.sql new file mode 100644 index 00000000..38bb5e60 --- /dev/null +++ b/prisma/sql/getGroupsWithBalances.sql @@ -0,0 +1,24 @@ +-- @param {Int} $1:id of the user +SELECT + "Group"."id", + "Group".name, + CAST(Coalesce(sum("ExpenseParticipant".amount), 0) AS BIGINT) AS balance, + Coalesce("Expense".currency, "Group"."defaultCurrency") AS currency, + "Group"."archivedAt" +FROM + "GroupUser" + JOIN "Group" ON "GroupUser"."groupId" = "Group".id + LEFT JOIN "Expense" ON "Expense"."groupId" = "Group".id + LEFT JOIN "ExpenseParticipant" ON "Expense".id = "ExpenseParticipant"."expenseId" +WHERE + "GroupUser"."userId" = $1 + AND "deletedAt" IS NULL + AND ("ExpenseParticipant"."userId" = $1 + OR "Expense".id IS NULL) +GROUP BY + "Group".id, + "Group".name, + "Expense".currency +ORDER BY + "Group"."createdAt" DESC, + balance DESC diff --git a/src/components/Expense/BalanceList.tsx b/src/components/Expense/BalanceList.tsx index 9ce731c7..12f128ac 100644 --- a/src/components/Expense/BalanceList.tsx +++ b/src/components/Expense/BalanceList.tsx @@ -1,4 +1,7 @@ -import type { GroupBalance, User } from '@prisma/client'; +import type { User } from '@prisma/client'; +import { type getAllBalancesForGroup } from '@prisma/client/sql'; +import { Info } from 'lucide-react'; + import { clsx } from 'clsx'; import { type ComponentProps, Fragment, useCallback, useMemo } from 'react'; import { EntityAvatar } from '~/components/ui/avatar'; @@ -19,9 +22,10 @@ interface UserWithBalance { } export const BalanceList: React.FC<{ - groupBalances?: GroupBalance[]; - users?: User[]; -}> = ({ groupBalances = [], users = [] }) => { + groupId: number; + groupBalances: getAllBalancesForGroup.Result[]; + users: User[]; +}> = ({ groupId, groupBalances, users }) => { const { displayName, t } = useTranslationWithUtils(); const userQuery = api.user.me.useQuery(); @@ -34,16 +38,17 @@ export const BalanceList: React.FC<{ return acc; }, {}); groupBalances - .filter(({ amount }) => 0 < BigMath.abs(amount)) + .filter(({ amount }) => amount != null && BigMath.abs(amount) > 0) .forEach((balance) => { - if (!res[balance.userId]!.balances[balance.firendId]) { - res[balance.userId]!.balances[balance.firendId] = {}; + if (!res[balance.paidBy]!.balances[balance.borrowedBy]) { + res[balance.paidBy]!.balances[balance.borrowedBy] = {}; } - const friendBalance = res[balance.userId]!.balances[balance.firendId]!; - friendBalance[balance.currency] = (friendBalance[balance.currency] ?? 0n) + balance.amount; + const friendBalance = res[balance.paidBy]!.balances[balance.borrowedBy]!; + friendBalance[balance.currency] = + (friendBalance[balance.currency] ?? 0n) + (balance.amount ?? 0n); - res[balance.userId]!.total[balance.currency] = - (res[balance.userId]!.total[balance.currency] ?? 0n) + balance.amount; + res[balance.paidBy]!.total[balance.currency] = + (res[balance.paidBy]!.total[balance.currency] ?? 0n) + (balance.amount ?? 0n); }); return res; @@ -164,7 +169,7 @@ export const BalanceList: React.FC<{ user={user} amount={amount} currency={currency} - groupId={groupBalances[0]!.groupId} + groupId={groupId} >