Browse Source

HSB-445 feature: storing user last login timestamp (#4074)

* feat: lastLoggedOn added in schema and service function

* feat: add lastLoggedOn logic for magic link

* test: update test cases

* feat: add lastLoggedOn in gql model

* fix: nullable allowed in model attribute

* fix: resolve feedback

* feat: user last login interceptor added
Mir Arif Hasan 9 months ago
parent
commit
f4f3fdf2d5

+ 2 - 0
packages/hoppscotch-backend/prisma/migrations/20240519093155_add_last_logged_on_to_user/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN     "lastLoggedOn" TIMESTAMP(3);

+ 12 - 11
packages/hoppscotch-backend/prisma/schema.prisma

@@ -41,31 +41,31 @@ model TeamInvitation {
 }
 
 model TeamCollection {
-  id         String                   @id @default(cuid())
+  id         String           @id @default(cuid())
   parentID   String?
   data       Json?
-  parent     TeamCollection?          @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
-  children   TeamCollection[]         @relation("TeamCollectionChildParent")
+  parent     TeamCollection?  @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
+  children   TeamCollection[] @relation("TeamCollectionChildParent")
   requests   TeamRequest[]
   teamID     String
-  team       Team                     @relation(fields: [teamID], references: [id], onDelete: Cascade)
+  team       Team             @relation(fields: [teamID], references: [id], onDelete: Cascade)
   title      String
   orderIndex Int
-  createdOn  DateTime                 @default(now()) @db.Timestamp(3)
-  updatedOn  DateTime                 @updatedAt @db.Timestamp(3)
+  createdOn  DateTime         @default(now()) @db.Timestamp(3)
+  updatedOn  DateTime         @updatedAt @db.Timestamp(3)
 }
 
 model TeamRequest {
-  id           String                   @id @default(cuid())
+  id           String         @id @default(cuid())
   collectionID String
-  collection   TeamCollection           @relation(fields: [collectionID], references: [id], onDelete: Cascade)
+  collection   TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
   teamID       String
-  team         Team                     @relation(fields: [teamID], references: [id], onDelete: Cascade)
+  team         Team           @relation(fields: [teamID], references: [id], onDelete: Cascade)
   title        String
   request      Json
   orderIndex   Int
-  createdOn    DateTime                 @default(now()) @db.Timestamp(3)
-  updatedOn    DateTime                 @updatedAt @db.Timestamp(3)
+  createdOn    DateTime       @default(now()) @db.Timestamp(3)
+  updatedOn    DateTime       @updatedAt @db.Timestamp(3)
 }
 
 model Shortcode {
@@ -104,6 +104,7 @@ model User {
   userRequests       UserRequest[]
   currentRESTSession Json?
   currentGQLSession  Json?
+  lastLoggedOn       DateTime?
   createdOn          DateTime            @default(now()) @db.Timestamp(3)
   invitedUsers       InvitedUsers[]
   shortcodes         Shortcode[]

+ 2 - 11
packages/hoppscotch-backend/src/admin/admin.service.spec.ts

@@ -74,6 +74,7 @@ const dbAdminUsers: DbUser[] = [
     refreshToken: 'refreshToken',
     currentRESTSession: '',
     currentGQLSession: '',
+    lastLoggedOn: new Date(),
     createdOn: new Date(),
   },
   {
@@ -85,20 +86,10 @@ const dbAdminUsers: DbUser[] = [
     refreshToken: 'refreshToken',
     currentRESTSession: '',
     currentGQLSession: '',
+    lastLoggedOn: new Date(),
     createdOn: new Date(),
   },
 ];
-const dbNonAminUser: DbUser = {
-  uid: 'uid 3',
-  displayName: 'displayName',
-  email: 'email@email.com',
-  photoURL: 'photoURL',
-  isAdmin: false,
-  refreshToken: 'refreshToken',
-  currentRESTSession: '',
-  currentGQLSession: '',
-  createdOn: new Date(),
-};
 
 describe('AdminService', () => {
   describe('fetchInvitedUsers', () => {

+ 5 - 0
packages/hoppscotch-backend/src/auth/auth.controller.ts

@@ -7,6 +7,7 @@ import {
   Request,
   Res,
   UseGuards,
+  UseInterceptors,
 } from '@nestjs/common';
 import { AuthService } from './auth.service';
 import { SignInMagicDto } from './dto/signin-magic.dto';
@@ -27,6 +28,7 @@ import { SkipThrottle } from '@nestjs/throttler';
 import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
 import { ConfigService } from '@nestjs/config';
 import { throwHTTPErr } from 'src/utils';
+import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor';
 
 @UseGuards(ThrottlerBehindProxyGuard)
 @Controller({ path: 'auth', version: '1' })
@@ -110,6 +112,7 @@ export class AuthController {
   @Get('google/callback')
   @SkipThrottle()
   @UseGuards(GoogleSSOGuard)
+  @UseInterceptors(UserLastLoginInterceptor)
   async googleAuthRedirect(@Request() req, @Res() res) {
     const authTokens = await this.authService.generateAuthTokens(req.user.uid);
     if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
@@ -135,6 +138,7 @@ export class AuthController {
   @Get('github/callback')
   @SkipThrottle()
   @UseGuards(GithubSSOGuard)
+  @UseInterceptors(UserLastLoginInterceptor)
   async githubAuthRedirect(@Request() req, @Res() res) {
     const authTokens = await this.authService.generateAuthTokens(req.user.uid);
     if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
@@ -160,6 +164,7 @@ export class AuthController {
   @Get('microsoft/callback')
   @SkipThrottle()
   @UseGuards(MicrosoftSSOGuard)
+  @UseInterceptors(UserLastLoginInterceptor)
   async microsoftAuthRedirect(@Request() req, @Res() res) {
     const authTokens = await this.authService.generateAuthTokens(req.user.uid);
     if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);

+ 13 - 8
packages/hoppscotch-backend/src/auth/auth.service.spec.ts

@@ -51,6 +51,7 @@ const user: AuthUser = {
   photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
   isAdmin: false,
   refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
+  lastLoggedOn: currentTime,
   createdOn: currentTime,
   currentGQLSession: {},
   currentRESTSession: {},
@@ -172,9 +173,11 @@ describe('verifyMagicLinkTokens', () => {
     // generateAuthTokens
     mockJWT.sign.mockReturnValue(user.refreshToken);
     // UpdateUserRefreshToken
-    mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
+    mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
     // deletePasswordlessVerificationToken
     mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
+    // usersService.updateUserLastLoggedOn
+    mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
 
     const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
     expect(result).toEqualRight({
@@ -197,9 +200,11 @@ describe('verifyMagicLinkTokens', () => {
     // generateAuthTokens
     mockJWT.sign.mockReturnValue(user.refreshToken);
     // UpdateUserRefreshToken
-    mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
+    mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
     // deletePasswordlessVerificationToken
     mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
+    // usersService.updateUserLastLoggedOn
+    mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
 
     const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
     expect(result).toEqualRight({
@@ -239,7 +244,7 @@ describe('verifyMagicLinkTokens', () => {
     // generateAuthTokens
     mockJWT.sign.mockReturnValue(user.refreshToken);
     // UpdateUserRefreshToken
-    mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
+    mockUser.updateUserRefreshToken.mockResolvedValueOnce(
       E.left(USER_NOT_FOUND),
     );
 
@@ -264,7 +269,7 @@ describe('verifyMagicLinkTokens', () => {
     // generateAuthTokens
     mockJWT.sign.mockReturnValue(user.refreshToken);
     // UpdateUserRefreshToken
-    mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
+    mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
     // deletePasswordlessVerificationToken
     mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
 
@@ -280,7 +285,7 @@ describe('generateAuthTokens', () => {
   test('Should successfully generate tokens with valid inputs', async () => {
     mockJWT.sign.mockReturnValue(user.refreshToken);
     // UpdateUserRefreshToken
-    mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
+    mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
 
     const result = await authService.generateAuthTokens(user.uid);
     expect(result).toEqualRight({
@@ -292,7 +297,7 @@ describe('generateAuthTokens', () => {
   test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
     mockJWT.sign.mockReturnValue(user.refreshToken);
     // UpdateUserRefreshToken
-    mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
+    mockUser.updateUserRefreshToken.mockResolvedValueOnce(
       E.left(USER_NOT_FOUND),
     );
 
@@ -319,7 +324,7 @@ describe('refreshAuthTokens', () => {
     // generateAuthTokens
     mockJWT.sign.mockReturnValue(user.refreshToken);
     // UpdateUserRefreshToken
-    mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
+    mockUser.updateUserRefreshToken.mockResolvedValueOnce(
       E.left(USER_NOT_FOUND),
     );
 
@@ -348,7 +353,7 @@ describe('refreshAuthTokens', () => {
     // generateAuthTokens
     mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
     // UpdateUserRefreshToken
-    mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
+    mockUser.updateUserRefreshToken.mockResolvedValueOnce(
       E.right({
         ...user,
         refreshToken: 'sdhjcbjsdhcbshjdcb',

+ 3 - 1
packages/hoppscotch-backend/src/auth/auth.service.ts

@@ -112,7 +112,7 @@ export class AuthService {
 
     const refreshTokenHash = await argon2.hash(refreshToken);
 
-    const updatedUser = await this.usersService.UpdateUserRefreshToken(
+    const updatedUser = await this.usersService.updateUserRefreshToken(
       refreshTokenHash,
       userUid,
     );
@@ -320,6 +320,8 @@ export class AuthService {
         statusCode: HttpStatus.NOT_FOUND,
       });
 
+    this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid);
+
     return E.right(tokens.right);
   }
 

+ 26 - 0
packages/hoppscotch-backend/src/interceptors/user-last-login.interceptor.ts

@@ -0,0 +1,26 @@
+import {
+  Injectable,
+  NestInterceptor,
+  ExecutionContext,
+  CallHandler,
+} from '@nestjs/common';
+import { Observable } from 'rxjs';
+import { tap } from 'rxjs/operators';
+import { AuthUser } from 'src/types/AuthUser';
+import { UserService } from 'src/user/user.service';
+
+@Injectable()
+export class UserLastLoginInterceptor implements NestInterceptor {
+  constructor(private userService: UserService) {}
+
+  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
+    const user: AuthUser = context.switchToHttp().getRequest().user;
+
+    const now = Date.now();
+    return next.handle().pipe(
+      tap(() => {
+        this.userService.updateUserLastLoggedOn(user.uid);
+      }),
+    );
+  }
+}

+ 1 - 0
packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts

@@ -48,6 +48,7 @@ const user: AuthUser = {
   photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
   isAdmin: false,
   refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
+  lastLoggedOn: createdOn,
   createdOn: createdOn,
   currentGQLSession: {},
   currentRESTSession: {},

+ 1 - 0
packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts

@@ -39,6 +39,7 @@ const user: AuthUser = {
   photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
   isAdmin: false,
   refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
+  lastLoggedOn: currentTime,
   createdOn: currentTime,
   currentGQLSession: {},
   currentRESTSession: {},

+ 1 - 0
packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts

@@ -38,6 +38,7 @@ const user: AuthUser = {
   photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
   isAdmin: false,
   refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
+  lastLoggedOn: currentTime,
   createdOn: currentTime,
   currentGQLSession: {},
   currentRESTSession: {},

Some files were not shown because too many files changed in this diff