diff --git a/prisma/migrations/20240414135355_add_activity_log/migration.sql b/prisma/migrations/20240414135355_add_activity_log/migration.sql new file mode 100644 index 0000000..a7d03ba --- /dev/null +++ b/prisma/migrations/20240414135355_add_activity_log/migration.sql @@ -0,0 +1,18 @@ +-- CreateEnum +CREATE TYPE "ActivityType" AS ENUM ('UPDATE_GROUP', 'CREATE_EXPENSE', 'UPDATE_EXPENSE', 'DELETE_EXPENSE'); + +-- CreateTable +CREATE TABLE "Activity" ( + "id" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "activityType" "ActivityType" NOT NULL, + "participantId" TEXT, + "expenseId" TEXT, + "data" TEXT, + + CONSTRAINT "Activity_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Activity" ADD CONSTRAINT "Activity_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f738947..2989f3c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,7 @@ model Group { currency String @default("$") participants Participant[] expenses Expense[] + activities Activity[] createdAt DateTime @default(now()) } @@ -80,3 +81,21 @@ model ExpensePaidFor { @@id([expenseId, participantId]) } + +model Activity { + id String @id + group Group @relation(fields: [groupId], references: [id]) + groupId String + time DateTime @default(now()) + activityType ActivityType + participantId String? + expenseId String? + data String? +} + +enum ActivityType { + UPDATE_GROUP + CREATE_EXPENSE + UPDATE_EXPENSE + DELETE_EXPENSE +} diff --git a/src/app/groups/[groupId]/activity/activity-item.tsx b/src/app/groups/[groupId]/activity/activity-item.tsx new file mode 100644 index 0000000..6fdc469 --- /dev/null +++ b/src/app/groups/[groupId]/activity/activity-item.tsx @@ -0,0 +1,102 @@ +'use client' +import { Button } from '@/components/ui/button' +import { getGroupExpenses } from '@/lib/api' +import { DateTimeStyle, cn, formatDate } from '@/lib/utils' +import { Activity, ActivityType, Participant } from '@prisma/client' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +type Props = { + groupId: string + activity: Activity + participant?: Participant + expense?: Awaited>[number] + dateStyle: DateTimeStyle +} + +function getSummary(activity: Activity, participantName?: string) { + const participant = participantName ?? 'Someone' + const expense = activity.data ?? '' + if (activity.activityType == ActivityType.UPDATE_GROUP) { + return ( + <> + Group settings were modified by {participant} + + ) + } else if (activity.activityType == ActivityType.CREATE_EXPENSE) { + return ( + <> + Expense “{expense}” created by{' '} + {participant}. + + ) + } else if (activity.activityType == ActivityType.UPDATE_EXPENSE) { + return ( + <> + Expense “{expense}” updated by{' '} + {participant}. + + ) + } else if (activity.activityType == ActivityType.DELETE_EXPENSE) { + return ( + <> + Expense “{expense}” deleted by{' '} + {participant}. + + ) + } +} + +export function ActivityItem({ + groupId, + activity, + participant, + expense, + dateStyle, +}: Props) { + const router = useRouter() + + const expenseExists = expense !== undefined + const summary = getSummary(activity, participant?.name) + + return ( +
{ + if (expenseExists) { + router.push(`/groups/${groupId}/expenses/${activity.expenseId}/edit`) + } + }} + > +
+ {dateStyle !== undefined && ( +
+ {formatDate(activity.time, { dateStyle })} +
+ )} +
+ {formatDate(activity.time, { timeStyle: 'short' })} +
+
+
+
{summary}
+
+ {expenseExists && ( + + )} +
+ ) +} diff --git a/src/app/groups/[groupId]/activity/activity-list.tsx b/src/app/groups/[groupId]/activity/activity-list.tsx new file mode 100644 index 0000000..1cde768 --- /dev/null +++ b/src/app/groups/[groupId]/activity/activity-list.tsx @@ -0,0 +1,112 @@ +import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item' +import { getGroupExpenses } from '@/lib/api' +import { Activity, Participant } from '@prisma/client' +import dayjs, { type Dayjs } from 'dayjs' + +type Props = { + groupId: string + participants: Participant[] + expenses: Awaited> + activities: Activity[] +} + +const DATE_GROUPS = { + TODAY: 'Today', + YESTERDAY: 'Yesterday', + EARLIER_THIS_WEEK: 'Earlier this week', + LAST_WEEK: 'Last week', + EARLIER_THIS_MONTH: 'Earlier this month', + LAST_MONTH: 'Last month', + EARLIER_THIS_YEAR: 'Earlier this year', + LAST_YEAR: 'Last year', + OLDER: 'Older', +} + +function getDateGroup(date: Dayjs, today: Dayjs) { + if (today.isSame(date, 'day')) { + return DATE_GROUPS.TODAY + } else if (today.subtract(1, 'day').isSame(date, 'day')) { + return DATE_GROUPS.YESTERDAY + } else if (today.isSame(date, 'week')) { + return DATE_GROUPS.EARLIER_THIS_WEEK + } else if (today.subtract(1, 'week').isSame(date, 'week')) { + return DATE_GROUPS.LAST_WEEK + } else if (today.isSame(date, 'month')) { + return DATE_GROUPS.EARLIER_THIS_MONTH + } else if (today.subtract(1, 'month').isSame(date, 'month')) { + return DATE_GROUPS.LAST_MONTH + } else if (today.isSame(date, 'year')) { + return DATE_GROUPS.EARLIER_THIS_YEAR + } else if (today.subtract(1, 'year').isSame(date, 'year')) { + return DATE_GROUPS.LAST_YEAR + } else { + return DATE_GROUPS.OLDER + } +} + +function getGroupedActivitiesByDate(activities: Activity[]) { + const today = dayjs() + return activities.reduce( + (result: { [key: string]: Activity[] }, activity: Activity) => { + const activityGroup = getDateGroup(dayjs(activity.time), today) + result[activityGroup] = result[activityGroup] ?? [] + result[activityGroup].push(activity) + return result + }, + {}, + ) +} + +export function ActivityList({ + groupId, + participants, + expenses, + activities, +}: Props) { + const groupedActivitiesByDate = getGroupedActivitiesByDate(activities) + + return activities.length > 0 ? ( + <> + {Object.values(DATE_GROUPS).map((dateGroup: string) => { + let groupActivities = groupedActivitiesByDate[dateGroup] + if (!groupActivities || groupActivities.length === 0) return null + const dateStyle = + dateGroup == DATE_GROUPS.TODAY || dateGroup == DATE_GROUPS.YESTERDAY + ? undefined + : 'medium' + + return ( +
+
+ {dateGroup} +
+ {groupActivities.map((activity: Activity) => { + const participant = + activity.participantId !== null + ? participants.find((p) => p.id === activity.participantId) + : undefined + const expense = + activity.expenseId !== null + ? expenses.find((e) => e.id === activity.expenseId) + : undefined + return ( + + ) + })} +
+ ) + })} + + ) : ( +

+ There is not yet any activity in your group. +

+ ) +} diff --git a/src/app/groups/[groupId]/activity/page.tsx b/src/app/groups/[groupId]/activity/page.tsx new file mode 100644 index 0000000..9fbe890 --- /dev/null +++ b/src/app/groups/[groupId]/activity/page.tsx @@ -0,0 +1,51 @@ +import { cached } from '@/app/cached-functions' +import { ActivityList } from '@/app/groups/[groupId]/activity/activity-list' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { getActivities, getGroupExpenses } from '@/lib/api' +import { Metadata } from 'next' +import { notFound } from 'next/navigation' + +export const metadata: Metadata = { + title: 'Activity', +} + +export default async function ActivityPage({ + params: { groupId }, +}: { + params: { groupId: string } +}) { + const group = await cached.getGroup(groupId) + if (!group) notFound() + + const expenses = await getGroupExpenses(groupId) + const activities = await getActivities(groupId) + + return ( + <> + + + Activity + + Overview of all activity in this group. + + + + + + + + ) +} diff --git a/src/app/groups/[groupId]/edit/page.tsx b/src/app/groups/[groupId]/edit/page.tsx index c174c37..ab2318f 100644 --- a/src/app/groups/[groupId]/edit/page.tsx +++ b/src/app/groups/[groupId]/edit/page.tsx @@ -17,10 +17,10 @@ export default async function EditGroupPage({ const group = await cached.getGroup(groupId) if (!group) notFound() - async function updateGroupAction(values: unknown) { + async function updateGroupAction(values: unknown, participantId?: string) { 'use server' const groupFormValues = groupFormSchema.parse(values) - const group = await updateGroup(groupId, groupFormValues) + const group = await updateGroup(groupId, groupFormValues, participantId) redirect(`/groups/${group.id}`) } diff --git a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx index 2514a77..a91de1f 100644 --- a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx +++ b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx @@ -27,16 +27,16 @@ export default async function EditExpensePage({ const expense = await getExpense(groupId, expenseId) if (!expense) notFound() - async function updateExpenseAction(values: unknown) { + async function updateExpenseAction(values: unknown, participantId?: string) { 'use server' const expenseFormValues = expenseFormSchema.parse(values) - await updateExpense(groupId, expenseId, expenseFormValues) + await updateExpense(groupId, expenseId, expenseFormValues, participantId) redirect(`/groups/${groupId}`) } - async function deleteExpenseAction() { + async function deleteExpenseAction(participantId?: string) { 'use server' - await deleteExpense(expenseId) + await deleteExpense(groupId, expenseId, participantId) redirect(`/groups/${groupId}`) } diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx index dab04a1..680fe33 100644 --- a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx +++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx @@ -26,7 +26,7 @@ import { import { ToastAction } from '@/components/ui/toast' import { useToast } from '@/components/ui/use-toast' import { useMediaQuery } from '@/lib/hooks' -import { formatCurrency, formatExpenseDate, formatFileSize } from '@/lib/utils' +import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils' import { Category } from '@prisma/client' import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react' import { getImageData, usePresignedUpload } from 'next-s3-upload' @@ -212,9 +212,9 @@ export function CreateFromReceiptButton({
{receiptInfo ? ( receiptInfo.date ? ( - formatExpenseDate( - new Date(`${receiptInfo?.date}T12:00:00.000Z`), - ) + formatDate(new Date(`${receiptInfo?.date}T12:00:00.000Z`), { + dateStyle: 'medium', + }) ) : ( ) diff --git a/src/app/groups/[groupId]/expenses/create/page.tsx b/src/app/groups/[groupId]/expenses/create/page.tsx index 6398fd1..3ca3b36 100644 --- a/src/app/groups/[groupId]/expenses/create/page.tsx +++ b/src/app/groups/[groupId]/expenses/create/page.tsx @@ -20,10 +20,10 @@ export default async function ExpensePage({ const group = await cached.getGroup(groupId) if (!group) notFound() - async function createExpenseAction(values: unknown) { + async function createExpenseAction(values: unknown, participantId?: string) { 'use server' const expenseFormValues = expenseFormSchema.parse(values) - await createExpense(expenseFormValues, groupId) + await createExpense(expenseFormValues, groupId, participantId) redirect(`/groups/${groupId}`) } diff --git a/src/app/groups/[groupId]/expenses/expense-card.tsx b/src/app/groups/[groupId]/expenses/expense-card.tsx index 8ea6063..f4a5f28 100644 --- a/src/app/groups/[groupId]/expenses/expense-card.tsx +++ b/src/app/groups/[groupId]/expenses/expense-card.tsx @@ -3,7 +3,7 @@ import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-b import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon' import { Button } from '@/components/ui/button' import { getGroupExpenses } from '@/lib/api' -import { cn, formatCurrency, formatExpenseDate } from '@/lib/utils' +import { cn, formatCurrency, formatDate } from '@/lib/utils' import { ChevronRight } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' @@ -60,7 +60,7 @@ export function ExpenseCard({ expense, currency, groupId }: Props) { {formatCurrency(currency, expense.amount)}
- {formatExpenseDate(expense.expenseDate)} + {formatDate(expense.expenseDate, { dateStyle: 'medium' })}
diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index f68dc50..fbc0099 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -35,6 +35,7 @@ import { } from '@/components/ui/select' import { getCategories, getExpense, getGroup, randomId } from '@/lib/api' import { RuntimeFeatureFlags } from '@/lib/featureFlags' +import { useActiveUser } from '@/lib/hooks' import { ExpenseFormValues, SplittingOptions, @@ -56,8 +57,8 @@ export type Props = { group: NonNullable>> expense?: NonNullable>> categories: NonNullable>> - onSubmit: (values: ExpenseFormValues) => Promise - onDelete?: () => Promise + onSubmit: (values: ExpenseFormValues, participantId?: string) => Promise + onDelete?: (participantId?: string) => Promise runtimeFeatureFlags: RuntimeFeatureFlags } @@ -235,10 +236,11 @@ export function ExpenseForm({ }, }) const [isCategoryLoading, setCategoryLoading] = useState(false) + const activeUserId = useActiveUser(group.id) const submit = async (values: ExpenseFormValues) => { await persistDefaultSplittingOptions(group.id, values) - return onSubmit(values) + return onSubmit(values, activeUserId ?? undefined) } return ( @@ -722,7 +724,9 @@ export function ExpenseForm({ {isCreate ? <>Create : <>Save} {!isCreate && onDelete && ( - + onDelete(activeUserId ?? undefined)} + > )}