diff --git a/next.config.js b/next.config.js index 57be951..6cd3532 100644 --- a/next.config.js +++ b/next.config.js @@ -44,6 +44,16 @@ const config = { port: '', pathname: '/**', }, + { + hostname: 's3.amazonaws.com', + port: '', + pathname: '/**', + }, + { + hostname: 'splitwise.s3.amazonaws.com', + port: '', + pathname: '/**', + }, { hostname: 'api.producthunt.com', port: '', diff --git a/package.json b/package.json index cbd4853..74bc681 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@prisma/client": "^5.9.1", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab05eaa..ecca20f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ dependencies: '@radix-ui/react-avatar': specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-checkbox': + specifier: ^1.0.4 + version: 1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0) @@ -3127,6 +3130,34 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-checkbox@1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.8 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.48)(react@18.2.0) + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} peerDependencies: @@ -3784,6 +3815,20 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.48)(react@18.2.0): + resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.8 + '@types/react': 18.2.48 + react: 18.2.0 + dev: false + /@radix-ui/react-use-rect@1.0.1(@types/react@18.2.48)(react@18.2.0): resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==} peerDependencies: diff --git a/prisma/migrations/20240330050427_add_imported_from_splitiwise_user/migration.sql b/prisma/migrations/20240330050427_add_imported_from_splitiwise_user/migration.sql new file mode 100644 index 0000000..506d96b --- /dev/null +++ b/prisma/migrations/20240330050427_add_imported_from_splitiwise_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Balance" ADD COLUMN "importedFromSplitwise" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20240330061939_add_splitwise_group_id/migration.sql b/prisma/migrations/20240330061939_add_splitwise_group_id/migration.sql new file mode 100644 index 0000000..f9b2d84 --- /dev/null +++ b/prisma/migrations/20240330061939_add_splitwise_group_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Group" ADD COLUMN "splitwiseGroupId" TEXT; diff --git a/prisma/migrations/20240330064009_add_splitwise_group_id_unique/migration.sql b/prisma/migrations/20240330064009_add_splitwise_group_id_unique/migration.sql new file mode 100644 index 0000000..54f4649 --- /dev/null +++ b/prisma/migrations/20240330064009_add_splitwise_group_id_unique/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[splitwiseGroupId]` on the table `Group` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Group_splitwiseGroupId_key" ON "Group"("splitwiseGroupId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 44134c9..66dc2f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -73,30 +73,32 @@ model VerificationToken { } model Balance { - userId Int - currency String - friendId Int - amount Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(name: "UserBalance", fields: [userId], references: [id], onDelete: Cascade) - friend User @relation(name: "FriendBalance", fields: [friendId], references: [id], onDelete: Cascade) + userId Int + currency String + friendId Int + amount Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + importedFromSplitwise Boolean @default(false) + user User @relation(name: "UserBalance", fields: [userId], references: [id], onDelete: Cascade) + friend User @relation(name: "FriendBalance", fields: [friendId], references: [id], onDelete: Cascade) @@id([userId, currency, friendId]) } model Group { - id Int @id @default(autoincrement()) - publicId String @unique - name String - userId Int - defaultCurrency String @default("USD") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade) - groupUsers GroupUser[] - expenses Expense[] - groupBalances GroupBalance[] + id Int @id @default(autoincrement()) + publicId String @unique + name String + userId Int + defaultCurrency String @default("USD") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + splitwiseGroupId String? @unique + createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade) + groupUsers GroupUser[] + expenses Expense[] + groupBalances GroupBalance[] } model GroupUser { diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..c30db83 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "~/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/pages/account.tsx b/src/pages/account.tsx index 7812093..f6d8847 100644 --- a/src/pages/account.tsx +++ b/src/pages/account.tsx @@ -3,7 +3,7 @@ import MainLayout from '~/components/Layout/MainLayout'; import { Button } from '~/components/ui/button'; import Link from 'next/link'; import { UserAvatar } from '~/components/ui/avatar'; -import { Bell, ChevronRight, Download, FileDown, Github, Star } from 'lucide-react'; +import { Bell, ChevronRight, Download, DownloadCloud, FileDown, Github, Star } from 'lucide-react'; import { signOut } from 'next-auth/react'; import { AppDrawer } from '~/components/ui/drawer'; import { SubmitFeedback } from '~/components/Account/SubmitFeedback'; @@ -170,9 +170,21 @@ const AccountPage: NextPageWithUser = ({ user }) => { )} + + + -
+
+ +
+
Import from splitwise
+
+ +
+
+
+ + +
+ + {uploadedFile ? ( + <> +
Friends ({usersWithBalance.length})
+ {usersWithBalance.length ? ( +
+ {usersWithBalance.map((user, index) => ( +
+
+
+ { + setSelectedUsers({ ...selectedUsers, [user.id]: checked }); + }} + /> +
+

{`${user.first_name}${user.last_name ? ' ' + user.last_name : ''}`}

+
+
+
+ {user.balance.map((b, index) => ( + 0 ? 'text-green-500' : 'text-orange-600'}`} + > + {b.currency_code} {Math.abs(Number(b.amount)).toFixed(2)} + + {index !== user.balance.length - 1 ? ' + ' : ''} + + + ))} +
+
+
+ {index !== usersWithBalance.length - 1 ? : null} +
+
+ ))} +
+ ) : null} +
Groups ({groups.length})
+ {groups.length ? ( +
+ {groups.map((group, index) => ( +
+
+
+ { + setSelectedGroups({ ...selectedGroups, [group.id]: checked }); + }} + /> +
+

{group.name}

+
+
+
+ {group.members.length} members +
+
+ {index !== groups.length - 1 ? : null} +
+ ))} +
+ ) : null} + + ) : ( +
+ Follow this link to export splitwise data + + + +
+ )} + + + + ); +}; + +ImportSpliwisePage.auth = true; + +export default ImportSpliwisePage; diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 3c004ca..65de423 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -7,6 +7,8 @@ import { deleteExpense, getCompleteFriendsDetails, getCompleteGroupDetails, + importGroupFromSplitwise, + importUserBalanceFromSplitWise, } from '../services/splitService'; import { TRPCError } from '@trpc/server'; import { randomUUID } from 'crypto'; @@ -15,6 +17,7 @@ import { FILE_SIZE_LIMIT } from '~/lib/constants'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; import { pushNotification } from '~/server/notification'; import { toFixedNumber, toUIString } from '~/utils/numbers'; +import { SplitwiseGroupSchema, SplitwiseUserSchema } from '~/types'; export const userRouter = createTRPCRouter({ me: protectedProcedure.query(async ({ ctx }) => { @@ -460,4 +463,16 @@ export const userRouter = createTRPCRouter({ return { friends, groups }; }), + + importUsersFromSplitWise: protectedProcedure + .input( + z.object({ + usersWithBalance: z.array(SplitwiseUserSchema), + groups: z.array(SplitwiseGroupSchema), + }), + ) + .mutation(async ({ input, ctx }) => { + await importUserBalanceFromSplitWise(ctx.session.user.id, input.usersWithBalance); + await importGroupFromSplitwise(ctx.session.user.id, input.groups); + }), }); diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index 31075bd..ff12417 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -1,7 +1,9 @@ -import { SplitType } from '@prisma/client'; +import { SplitType, type User } from '@prisma/client'; import exp from 'constants'; +import { nanoid } from 'nanoid'; import { db } from '~/server/db'; import { pushNotification } from '~/server/notification'; +import { type SplitwiseGroup, type SplitwiseUser } from '~/types'; import { toFixedNumber, toInteger } from '~/utils/numbers'; export async function joinGroup(userId: number, publicGroupId: string) { @@ -615,3 +617,204 @@ export async function getCompleteGroupDetails(userId: number) { return groups; } + +export async function importUserBalanceFromSplitWise( + currentUserId: number, + splitWiseUsers: SplitwiseUser[], +) { + const operations = []; + + const users = await createUsersFromSplitwise(splitWiseUsers); + + const userMap = users.reduce( + (acc, user) => { + if (user.email) { + acc[user.email] = user; + } + + return acc; + }, + {} as Record, + ); + + for (const user of splitWiseUsers) { + const dbUser = userMap[user.email]; + if (!dbUser) { + continue; + } + + for (const balance of user.balance) { + const amount = toInteger(parseFloat(balance.amount)); + const currency = balance.currency_code; + const existingBalance = await db.balance.findUnique({ + where: { + userId_currency_friendId: { + userId: currentUserId, + currency, + friendId: dbUser.id, + }, + }, + }); + + if (existingBalance?.importedFromSplitwise) { + continue; + } + + operations.push( + db.balance.upsert({ + where: { + userId_currency_friendId: { + userId: currentUserId, + currency, + friendId: dbUser.id, + }, + }, + update: { + amount: { + increment: amount, + }, + importedFromSplitwise: true, + }, + create: { + userId: currentUserId, + currency, + friendId: dbUser.id, + amount, + importedFromSplitwise: true, + }, + }), + ); + + operations.push( + db.balance.upsert({ + where: { + userId_currency_friendId: { + userId: dbUser.id, + currency, + friendId: currentUserId, + }, + }, + update: { + amount: { + increment: -amount, + }, + importedFromSplitwise: true, + }, + create: { + userId: dbUser.id, + currency, + friendId: currentUserId, + amount: -amount, + importedFromSplitwise: true, + }, + }), + ); + } + } + + await db.$transaction(operations); +} + +async function createUsersFromSplitwise(users: Array) { + const userEmails = users.map((u) => u.email); + + const existingUsers = await db.user.findMany({ + where: { + email: { + in: userEmails, + }, + }, + }); + + const existingUserMap: Record = {}; + + for (const user of existingUsers) { + if (user.email) { + existingUserMap[user.email] = true; + } + } + + const newUsers = users.filter((u) => !existingUserMap[u.email]); + + await db.user.createMany({ + data: newUsers.map((u) => ({ + email: u.email, + name: `${u.first_name}${u.last_name ? ' ' + u.last_name : ''}`, + })), + }); + + return db.user.findMany({ + where: { + email: { + in: userEmails, + }, + }, + }); +} + +export async function importGroupFromSplitwise( + currentUserId: number, + splitWiseGroups: Array, +) { + const splitwiseUserMap: Record = {}; + + for (const group of splitWiseGroups) { + for (const member of group.members) { + splitwiseUserMap[member.id.toString()] = member; + } + } + console.log('splitwiseUserMap', splitwiseUserMap); + + const users = await createUsersFromSplitwise(Object.values(splitwiseUserMap)); + + const userMap = users.reduce( + (acc, user) => { + if (user.email) { + acc[user.email] = user; + } + + return acc; + }, + {} as Record, + ); + + console.log('userMap', userMap, splitWiseGroups); + + const operations = []; + console.log('Hello world'); + + for (const group of splitWiseGroups) { + console.log('group', group); + const dbGroup = await db.group.findUnique({ + where: { + splitwiseGroupId: group.id.toString(), + }, + }); + + if (dbGroup) { + continue; + } + + const groupmembers = group.members.map((member) => ({ + userId: userMap[member.email.toString()]!.id, + })); + + console.log('groupmembers', groupmembers); + + operations.push( + db.group.create({ + data: { + name: group.name, + splitwiseGroupId: group.id.toString(), + publicId: nanoid(), + userId: currentUserId, + groupUsers: { + create: groupmembers, + }, + }, + }), + ); + } + + await db.$transaction(operations); +} diff --git a/src/types.ts b/src/types.ts index a8b8a27..f4325e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,59 @@ import { type NextPage } from 'next'; import { type User } from 'next-auth'; +import { z } from 'zod'; export type NextPageWithUser = NextPage<{ user: User }> & { auth: boolean }; export type PushMessage = { title: string; message: string }; + +export type SplitwisePicture = { + small: string; + medium: string; + large: string; +}; + +export type SplitwiseBalance = { + currency_code: string; + amount: string; +}; + +export type SplitwiseUser = { + id: number; + first_name: string; + last_name: string | null; + email: string; + balance: SplitwiseBalance[]; + picture: SplitwisePicture; +}; + +export type SplitwiseGroup = { + id: number; + name: string; + members: SplitwiseUser[]; +}; + +const SplitwisePictureSchema = z.object({ + small: z.string(), + medium: z.string(), + large: z.string(), +}); + +const SplitwiseBalanceSchema = z.object({ + currency_code: z.string(), + amount: z.string(), +}); + +export const SplitwiseUserSchema = z.object({ + id: z.number(), + first_name: z.string(), + last_name: z.string().nullable(), + email: z.string().email(), + balance: z.array(SplitwiseBalanceSchema), + picture: SplitwisePictureSchema, +}); + +export const SplitwiseGroupSchema = z.object({ + id: z.number(), + name: z.string(), + members: z.array(SplitwiseUserSchema), +});