Skip to content

Commit

Permalink
Add basic activity log (#141)
Browse files Browse the repository at this point in the history
* Add basic activity log

* Add database migration

* Fix layout

* Fix types

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
  • Loading branch information
dcbr and scastiel authored May 30, 2024
1 parent 10e13d1 commit e619c1a
Show file tree
Hide file tree
Showing 17 changed files with 400 additions and 29 deletions.
18 changes: 18 additions & 0 deletions prisma/migrations/20240414135355_add_activity_log/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 19 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ model Group {
currency String @default("$")
participants Participant[]
expenses Expense[]
activities Activity[]
createdAt DateTime @default(now())
}

Expand Down Expand Up @@ -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
}
102 changes: 102 additions & 0 deletions src/app/groups/[groupId]/activity/activity-item.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getGroupExpenses>>[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 <strong>{participant}</strong>
</>
)
} else if (activity.activityType == ActivityType.CREATE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> created by{' '}
<strong>{participant}</strong>.
</>
)
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> updated by{' '}
<strong>{participant}</strong>.
</>
)
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> deleted by{' '}
<strong>{participant}</strong>.
</>
)
}
}

export function ActivityItem({
groupId,
activity,
participant,
expense,
dateStyle,
}: Props) {
const router = useRouter()

const expenseExists = expense !== undefined
const summary = getSummary(activity, participant?.name)

return (
<div
className={cn(
'flex justify-between sm:rounded-lg px-2 sm:pr-1 sm:pl-2 py-2 text-sm hover:bg-accent gap-1 items-stretch',
expenseExists && 'cursor-pointer',
)}
onClick={() => {
if (expenseExists) {
router.push(`/groups/${groupId}/expenses/${activity.expenseId}/edit`)
}
}}
>
<div className="flex flex-col justify-between items-start">
{dateStyle !== undefined && (
<div className="mt-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { dateStyle })}
</div>
)}
<div className="my-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { timeStyle: 'short' })}
</div>
</div>
<div className="flex-1">
<div className="m-1">{summary}</div>
</div>
{expenseExists && (
<Button
size="icon"
variant="link"
className="self-center hidden sm:flex w-5 h-5"
asChild
>
<Link href={`/groups/${groupId}/expenses/${activity.expenseId}/edit`}>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
)}
</div>
)
}
112 changes: 112 additions & 0 deletions src/app/groups/[groupId]/activity/activity-list.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getGroupExpenses>>
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 (
<div key={dateGroup}>
<div
className={
'text-muted-foreground text-xs py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
}
>
{dateGroup}
</div>
{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 (
<ActivityItem
key={activity.id}
{...{ groupId, activity, participant, expense, dateStyle }}
/>
)
})}
</div>
)
})}
</>
) : (
<p className="px-6 text-sm py-6">
There is not yet any activity in your group.
</p>
)
}
51 changes: 51 additions & 0 deletions src/app/groups/[groupId]/activity/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>
Overview of all activity in this group.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<ActivityList
{...{
groupId,
participants: group.participants,
expenses,
activities,
}}
/>
</CardContent>
</Card>
</>
)
}
4 changes: 2 additions & 2 deletions src/app/groups/[groupId]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
}

Expand Down
8 changes: 4 additions & 4 deletions src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -212,9 +212,9 @@ export function CreateFromReceiptButton({
<div>
{receiptInfo ? (
receiptInfo.date ? (
formatExpenseDate(
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
)
formatDate(new Date(`${receiptInfo?.date}T12:00:00.000Z`), {
dateStyle: 'medium',
})
) : (
<Unknown />
)
Expand Down
4 changes: 2 additions & 2 deletions src/app/groups/[groupId]/expenses/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
}

Expand Down
Loading

0 comments on commit e619c1a

Please sign in to comment.