diff --git a/apps/web/test/lib/generateCsv.test.ts b/apps/web/test/lib/generateCsv.test.ts index 1d5b8b327f..d6422c0acc 100644 --- a/apps/web/test/lib/generateCsv.test.ts +++ b/apps/web/test/lib/generateCsv.test.ts @@ -67,6 +67,7 @@ describe("generate Csv for Org Users Table @test", () => { completedOnboarding: true, teams: [], attributes: [], + lastActiveAt: "", }; it("should throw if no headers", () => { diff --git a/packages/features/auth/lib/getServerSession.ts b/packages/features/auth/lib/getServerSession.ts index 36bf09d49d..fc0e5d5402 100644 --- a/packages/features/auth/lib/getServerSession.ts +++ b/packages/features/auth/lib/getServerSession.ts @@ -54,10 +54,11 @@ export async function getServerSession(options: { return cachedSession; } - const userFromDb = await prisma.user.findUnique({ + const userFromDb = await prisma.user.update({ where: { email: token.email.toLowerCase(), }, + data: { lastActiveAt: new Date() }, }); if (!userFromDb) { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index eda60308f4..f7955d5556 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -37,6 +37,7 @@ import { } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { getFullName } from "@calcom/features/form-builder/utils"; +import { UsersRepository } from "@calcom/features/users/users.repository"; import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { @@ -986,6 +987,11 @@ async function handler( originalRescheduledBooking, }); + if (booking?.userId) { + const usersRepository = new UsersRepository(); + await usersRepository.updateLastActiveAt(booking.userId); + } + evt.uid = booking?.uid ?? null; evt.oneTimePassword = booking?.oneTimePassword ?? null; diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx index eec5f4edfd..1144b65983 100644 --- a/packages/features/ee/teams/components/MemberList.tsx +++ b/packages/features/ee/teams/components/MemberList.tsx @@ -378,6 +378,11 @@ export default function MemberList(props: Props) { return filterValue.includes(rows.getValue(id)); }, }, + { + id: "lastActiveAt", + header: "Last Active", + cell: ({ row }) =>
{row.original.lastActiveAt}
, + }, { id: "actions", cell: ({ row }) => { diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 55504df7c4..d58cc00007 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -342,6 +342,11 @@ export function UserListTable() { }, }, ...generateAttributeColumns(), + { + id: "lastActiveAt", + header: "Last Active", + cell: ({ row }) =>
{row.original.lastActiveAt}
, + }, { id: "actions", enableHiding: false, diff --git a/packages/features/users/components/UserTable/types.ts b/packages/features/users/components/UserTable/types.ts index dfbff32eb6..ef64f8c16c 100644 --- a/packages/features/users/components/UserTable/types.ts +++ b/packages/features/users/components/UserTable/types.ts @@ -10,6 +10,7 @@ export interface UserTableUser { accepted: boolean; disableImpersonation: boolean; completedOnboarding: boolean; + lastActiveAt: string; teams: { id: number; name: string; diff --git a/packages/features/users/users.repository.interface.ts b/packages/features/users/users.repository.interface.ts new file mode 100644 index 0000000000..9e9f886e37 --- /dev/null +++ b/packages/features/users/users.repository.interface.ts @@ -0,0 +1,5 @@ +import type { User } from "@prisma/client"; + +export interface IUsersRepository { + updateLastActiveAt(userId: number): Promise; +} diff --git a/packages/features/users/users.repository.ts b/packages/features/users/users.repository.ts new file mode 100644 index 0000000000..279c4e55d6 --- /dev/null +++ b/packages/features/users/users.repository.ts @@ -0,0 +1,20 @@ +import { captureException } from "@sentry/nextjs"; + +import db from "@calcom/prisma"; + +import type { IUsersRepository } from "./users.repository.interface"; + +export class UsersRepository implements IUsersRepository { + async updateLastActiveAt(userId: number) { + try { + const user = await db.user.update({ + where: { id: userId }, + data: { lastActiveAt: new Date() }, + }); + return user; + } catch (err) { + captureException(err); + throw err; + } + } +} diff --git a/packages/prisma/migrations/20241023041541_add_user_last_active_at/migration.sql b/packages/prisma/migrations/20241023041541_add_user_last_active_at/migration.sql new file mode 100644 index 0000000000..6b3a05afea --- /dev/null +++ b/packages/prisma/migrations/20241023041541_add_user_last_active_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "lastActiveAt" TIMESTAMP(3); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 509690a268..0d5116740e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -277,6 +277,7 @@ model User { appTheme String? createdDate DateTime @default(now()) @map(name: "created") trialEndsAt DateTime? + lastActiveAt DateTime? eventTypes EventType[] @relation("user_eventtype") credentials Credential[] teams Membership[] diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index 54f0170bac..4a5c7ed797 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -106,6 +106,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { timeZone: true, disableImpersonation: true, completedOnboarding: true, + lastActiveAt: true, teams: { select: { team: { @@ -164,6 +165,13 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { accepted: membership.accepted, disableImpersonation: user.disableImpersonation, completedOnboarding: user.completedOnboarding, + lastActiveAt: membership.user.lastActiveAt + ? new Intl.DateTimeFormat(ctx.user.locale, { + timeZone: ctx.user.timeZone, + }) + .format(membership.user.lastActiveAt) + .toLowerCase() + : null, avatarUrl: user.avatarUrl, teams: user.teams .filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts index f1dd2ae68d..ffa22a4bbb 100644 --- a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts @@ -25,6 +25,7 @@ const userSelect = Prisma.validator()({ id: true, bio: true, disableImpersonation: true, + lastActiveAt: true, }); export const listMembersHandler = async ({ ctx, input }: ListMembersHandlerOptions) => { @@ -92,6 +93,13 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersHandlerOptio disableImpersonation: user.disableImpersonation, bookerUrl: getBookerBaseUrlSync(profile?.organization?.slug || ""), teamId: member.teamId, + lastActiveAt: member.user.lastActiveAt + ? new Intl.DateTimeFormat(ctx.user.locale, { + timeZone: ctx.user.timeZone, + }) + .format(member.user.lastActiveAt) + .toLowerCase() + : null, }; }) );