diff --git a/.env.example b/.env.example index ca27c8d..ee33098 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,13 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CALLBACK_URL= -FACEBOOK_CLIENT_ID= -FACEBOOK_CLIENT_SECRET= -FACEBOOK_CALLBACK_URL= +FACEBOOK_OAUTH_CLIENT_ID= +FACEBOOK_OAUTH_CLIENT_SECRET= +FACEBOOK_OAUTH_CALLBACK_URL= + +FACEBOOK_SOCIAL_ACCOUNT_LINK_CLIENT_ID= +FACEBOOK_SOCIAL_ACCOUNT_LINK_CLIENT_SECRET= +FACEBOOK_SOCIAL_ACCOUNT_LINK_CALLBACK_URL= APPLE_CLIENT_ID= APPLE_CALLBACK_URL= @@ -12,13 +16,16 @@ APPLE_TEAM_ID= APPLE_KEY_ID= APPLE_KEY_CONTENTS= -LINKEDIN_CLIENT_ID= -LINKEDIN_CLIENT_SECRET= -LINKEDIN_CALLBACK_URL= +LINKEDIN_OAUTH_CLIENT_ID= +LINKEDIN_OAUTH_CLIENT_SECRET= +LINKEDIN_OAUTH_CALLBACK_URL= + +LINKEDIN_SOCIAL_ACCOUNT_LINK_CLIENT_ID= +LINKEDIN_SOCIAL_ACCOUNT_LINK_CLIENT_SECRET= +LINKEDIN_SOCIAL_ACCOUNT_LINK_CALLBACK_URL= + FLY_ACCESS_TOKEN= DATABASE_URL=postgresql://postgres:password@127.0.0.1:5432/culero -DB_STAGE_URL= -DB_PROD_URL= JWT_SECRET=secret SMTP_HOST= @@ -34,4 +41,7 @@ AWS_REGION= DOMAIN=https://culero.com/ PROFILES_DIRECTORY= -REDIS_URL=redis://localhost:6379 \ No newline at end of file +REDIS_URL=redis://localhost:6379 + +LINKEDIN_PROFILE_FETCHER_API_KEY= +LINKEDIN_PROFILE_FETCHER_HOST= \ No newline at end of file diff --git a/docker-compose-test.yml b/docker-compose-test.yml index be5ad14..243ad07 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -1,6 +1,3 @@ -# Set the version of docker compose to use -version: '3.9' - # The containers that compose the project services: db: diff --git a/docker-compose.yml b/docker-compose.yml index b5d3fbf..8815099 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,3 @@ -# Set the version of docker compose to use -version: '3.9' - # The containers that compose the project services: db: diff --git a/package.json b/package.json index 3b7a8cf..7ff413f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@nestjs/platform-fastify": "^10.3.3", "@nestjs/swagger": "^7.3.0", "@prisma/client": "^5.10.2", + "@types/uuid": "^9.0.8", "aws-sdk": "^2.1577.0", "axios": "^1.6.8", "cheerio": "1.0.0-rc.12", @@ -56,7 +57,8 @@ "passport-linkedin-oauth2": "^2.0.0", "prisma": "^5.10.2", "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 874db7a..5486897 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@prisma/client': specifier: ^5.10.2 version: 5.10.2(prisma@5.10.2) + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 aws-sdk: specifier: ^2.1577.0 version: 2.1577.0 @@ -92,6 +95,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.1 + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@nestjs/cli': specifier: ^10.0.0 @@ -1229,6 +1235,9 @@ packages: '@types/supertest@2.0.16': resolution: {integrity: sha512-6c2ogktZ06tr2ENoZivgm7YnprnhYE4ZoXGMY+oA7IuAf17M8FWvujXZGmxLv8y0PTyts4x5A+erSwVUFA8XSg==} + '@types/uuid@9.0.8': + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/validator@13.11.9': resolution: {integrity: sha512-FCTsikRozryfayPuiI46QzH3fnrOoctTjvOYZkho9BTFLCOZ2rgZJHMOVgCOfttjPJcgOx52EpkY0CMfy87MIw==} @@ -5622,6 +5631,8 @@ snapshots: dependencies: '@types/superagent': 8.1.4 + '@types/uuid@9.0.8': {} + '@types/validator@13.11.9': {} '@types/yargs-parser@21.0.3': {} diff --git a/src/auth/auth.e2e.spec.ts b/src/auth/auth.e2e.spec.ts index 0f6f701..8334029 100644 --- a/src/auth/auth.e2e.spec.ts +++ b/src/auth/auth.e2e.spec.ts @@ -8,7 +8,6 @@ import { Test } from '@nestjs/testing'; import { AppModule } from '../app/app.module'; import { ValidationPipe } from '@nestjs/common'; import { AuthType } from '@prisma/client'; -import { SHA256 } from 'crypto-js'; import { MailService } from '../mail/mail.service'; import { mockDeep } from 'jest-mock-extended'; import { MailModule } from '../mail/mail.module'; @@ -98,7 +97,6 @@ describe('Auth Controller Tests', () => { await prisma.user.create({ data: { email: 'jane@example.com', - password: SHA256('password').toString(), isEmailVerified: true, authType: AuthType.EMAIL, }, diff --git a/src/auth/guard/auth/auth.guard.ts b/src/auth/guard/auth/auth.guard.ts index 67d8902..fa294ca 100644 --- a/src/auth/guard/auth/auth.guard.ts +++ b/src/auth/guard/auth/auth.guard.ts @@ -50,7 +50,7 @@ export class AuthGuard implements CanActivate { }, }); } else { - const token = this.extractTokenFromHeader(request); + const token = this.extractToken(request); if (!token) { throw new ForbiddenException(); } @@ -79,8 +79,22 @@ export class AuthGuard implements CanActivate { return true; } - private extractTokenFromHeader(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; + private extractToken(request: Request): string | undefined { + const tokenFromHeader = request.headers.authorization; + const tokenFromParams = request.query.token; + + let type: string; + let token: string; + + if (tokenFromHeader) { + [type, token] = request.headers.authorization?.split(' ') ?? []; + } else if (tokenFromParams) { + type = 'Bearer'; + token = tokenFromParams as string; + } else { + return undefined; + } + return type === 'Bearer' ? token : undefined; } } diff --git a/src/oauth/factory/apple/apple-strategy.factory.ts b/src/oauth/factory/apple/apple-strategy.factory.ts index f86f8d7..75e7526 100644 --- a/src/oauth/factory/apple/apple-strategy.factory.ts +++ b/src/oauth/factory/apple/apple-strategy.factory.ts @@ -29,6 +29,10 @@ export class AppleOAuthStrategyFactory implements OAuthStrategyFactory { ); } + public isSocialAccountLinkEnabled(): boolean { + return false; + } + public createOAuthStrategy(): AppleStrategy | null { if (this.isOAuthEnabled()) { return new AppleStrategy( @@ -43,4 +47,10 @@ export class AppleOAuthStrategyFactory implements OAuthStrategyFactory { return null; } } + + public createSocialAccountLinkStrategy< + AppleStrategy, + >(): AppleStrategy | null { + return null; + } } diff --git a/src/oauth/factory/facebook/facebook-strategy.factory.spec.ts b/src/oauth/factory/facebook/facebook-strategy.factory.spec.ts index 8d7b1c4..48d155e 100644 --- a/src/oauth/factory/facebook/facebook-strategy.factory.spec.ts +++ b/src/oauth/factory/facebook/facebook-strategy.factory.spec.ts @@ -14,24 +14,35 @@ describe('FacebookOAuthStrategyFactory', () => { configService = moduleRef.get(ConfigService); }); - it('disable when credentials are not present', () => { + it('disable OAuth when credentials are not present', () => { jest.spyOn(configService, 'get').mockReturnValue(''); factory = new FacebookOAuthStrategyFactory(configService); expect(factory.isOAuthEnabled()).toBe(false); }); + it('disable social account link when credentials are not present', () => { + jest.spyOn(configService, 'get').mockReturnValue(''); + factory = new FacebookOAuthStrategyFactory(configService); + expect(factory.isSocialAccountLinkEnabled()).toBe(false); + }); + it('return null when OAuth disabled', () => { const strategy = factory.createOAuthStrategy(); expect(strategy).toBeNull(); }); + it('return null when social account link disabled', () => { + const strategy = factory.createSocialAccountLinkStrategy(); + expect(strategy).toBeNull(); + }); + it('enable OAuth when credentials present', () => { jest .spyOn(configService, 'get') .mockImplementation((key) => - key === 'FACEBOOK_CLIENT_ID' || - key === 'FACEBOOK_CLIENT_SECRET' || - key === 'FACEBOOK_CALLBACK_URL' + key === 'FACEBOOK_OAUTH_CLIENT_ID' || + key === 'FACEBOOK_OAUTH_CLIENT_SECRET' || + key === 'FACEBOOK_OAUTH_CALLBACK_URL' ? 'test' : '', ); @@ -39,8 +50,49 @@ describe('FacebookOAuthStrategyFactory', () => { expect(factory.isOAuthEnabled()).toBe(true); }); + it('enable social account link when credentials present', () => { + jest + .spyOn(configService, 'get') + .mockImplementation((key) => + key === 'FACEBOOK_SOCIAL_ACCOUNT_LINK_CLIENT_ID' || + key === 'FACEBOOK_SOCIAL_ACCOUNT_LINK_CLIENT_SECRET' || + key === 'FACEBOOK_SOCIAL_ACCOUNT_LINK_CALLBACK_URL' + ? 'test' + : '', + ); + factory = new FacebookOAuthStrategyFactory(configService); + expect(factory.isSocialAccountLinkEnabled()).toBe(true); + }); + it('create OAuth strategy when enabled', () => { - const strategy = factory.createOAuthStrategy(); + jest + .spyOn(configService, 'get') + .mockImplementation((key) => + key === 'FACEBOOK_OAUTH_CLIENT_ID' || + key === 'FACEBOOK_OAUTH_CLIENT_SECRET' || + key === 'FACEBOOK_OAUTH_CALLBACK_URL' + ? 'test' + : '', + ); + const strategy = new FacebookOAuthStrategyFactory( + configService, + ).createOAuthStrategy(); + expect(strategy).toBeInstanceOf(FacebookStrategy); + }); + + it('create social account link strategy when enabled', () => { + jest + .spyOn(configService, 'get') + .mockImplementation((key) => + key === 'FACEBOOK_SOCIAL_ACCOUNT_LINK_CLIENT_ID' || + key === 'FACEBOOK_SOCIAL_ACCOUNT_LINK_CLIENT_SECRET' || + key === 'FACEBOOK_SOCIAL_ACCOUNT_LINK_CALLBACK_URL' + ? 'test' + : '', + ); + const strategy = new FacebookOAuthStrategyFactory( + configService, + ).createSocialAccountLinkStrategy(); expect(strategy).toBeInstanceOf(FacebookStrategy); }); }); diff --git a/src/oauth/factory/facebook/facebook-strategy.factory.ts b/src/oauth/factory/facebook/facebook-strategy.factory.ts index 6c52123..e0609d3 100644 --- a/src/oauth/factory/facebook/facebook-strategy.factory.ts +++ b/src/oauth/factory/facebook/facebook-strategy.factory.ts @@ -5,32 +5,75 @@ import { FacebookStrategy } from '../../strategy/facebook/facebook.strategy'; @Injectable() export class FacebookOAuthStrategyFactory implements OAuthStrategyFactory { - private readonly clientID: string; - private readonly clientSecret: string; - private readonly callbackURL: string; + private readonly oAuthClientID: string; + private readonly oAuthClientSecret: string; + private readonly oAuthCallbackURL: string; + private readonly socialAccountLinkClientID: string; + private readonly socialAccountLinkClientSecret: string; + private readonly socialAccountLinkCallbackURL: string; constructor(private readonly configService: ConfigService) { - this.clientID = this.configService.get('FACEBOOK_CLIENT_ID'); - this.clientSecret = this.configService.get( - 'FACEBOOK_CLIENT_SECRET', + this.oAuthClientID = this.configService.get( + 'FACEBOOK_OAUTH_CLIENT_ID', + ); + this.oAuthClientSecret = this.configService.get( + 'FACEBOOK_OAUTH_CLIENT_SECRET', + ); + this.oAuthCallbackURL = this.configService.get( + 'FACEBOOK_OAUTH_CALLBACK_URL', + ); + this.socialAccountLinkClientID = this.configService.get( + 'FACEBOOK_SOCIAL_ACCOUNT_LINK_CLIENT_ID', + ); + this.socialAccountLinkClientSecret = this.configService.get( + 'FACEBOOK_SOCIAL_ACCOUNT_LINK_CLIENT_SECRET', + ); + this.socialAccountLinkCallbackURL = this.configService.get( + 'FACEBOOK_SOCIAL_ACCOUNT_LINK_CALLBACK_URL', ); - this.callbackURL = this.configService.get('FACEBOOK_CALLBACK_URL'); } public isOAuthEnabled(): boolean { - return Boolean(this.clientID && this.clientSecret && this.callbackURL); + return Boolean( + this.oAuthClientID && this.oAuthClientSecret && this.oAuthCallbackURL, + ); + } + + public isSocialAccountLinkEnabled(): boolean { + return Boolean( + this.socialAccountLinkClientID && + this.socialAccountLinkClientSecret && + this.socialAccountLinkCallbackURL, + ); } public createOAuthStrategy(): FacebookStrategy | null { if (this.isOAuthEnabled()) { return new FacebookStrategy( - this.clientID, - this.clientSecret, - this.callbackURL, + this.oAuthClientID, + this.oAuthClientSecret, + this.oAuthCallbackURL, ) as FacebookStrategy; } else { Logger.warn('Facebook Auth is not enabled in this environment.'); return null; } } + + public createSocialAccountLinkStrategy< + FacebookStrategy, + >(): FacebookStrategy | null { + if (this.isSocialAccountLinkEnabled()) { + return new FacebookStrategy( + this.socialAccountLinkClientID, + this.socialAccountLinkClientSecret, + this.socialAccountLinkCallbackURL, + ) as FacebookStrategy; + } else { + Logger.warn( + 'Facebook Social Account Link is not enabled in this environment.', + ); + return null; + } + } } diff --git a/src/oauth/factory/google/google-strategy.factory.ts b/src/oauth/factory/google/google-strategy.factory.ts index add4b97..24a221e 100644 --- a/src/oauth/factory/google/google-strategy.factory.ts +++ b/src/oauth/factory/google/google-strategy.factory.ts @@ -19,6 +19,10 @@ export class GoogleOAuthStrategyFactory implements OAuthStrategyFactory { return Boolean(this.clientID && this.clientSecret && this.callbackURL); } + public isSocialAccountLinkEnabled(): boolean { + return false; + } + public createOAuthStrategy(): GoogleStrategy | null { if (this.isOAuthEnabled()) { return new GoogleStrategy( @@ -31,4 +35,10 @@ export class GoogleOAuthStrategyFactory implements OAuthStrategyFactory { return null; } } + + public createSocialAccountLinkStrategy< + GoogleStrategy, + >(): GoogleStrategy | null { + return null; + } } diff --git a/src/oauth/factory/linkedin/linkedin-strategy.factory.spec.ts b/src/oauth/factory/linkedin/linkedin-strategy.factory.spec.ts index 24d7004..58f3d40 100644 --- a/src/oauth/factory/linkedin/linkedin-strategy.factory.spec.ts +++ b/src/oauth/factory/linkedin/linkedin-strategy.factory.spec.ts @@ -14,24 +14,35 @@ describe('LinkedInOAuthStrategyFactory', () => { configService = moduleRef.get(ConfigService); }); - it('disable when credentials are not present', () => { + it('disable OAuth when credentials are not present', () => { jest.spyOn(configService, 'get').mockReturnValue(''); factory = new LinkedInOAuthStrategyFactory(configService); expect(factory.isOAuthEnabled()).toBe(false); }); + it('disable social account link when credentials are not present', () => { + jest.spyOn(configService, 'get').mockReturnValue(''); + factory = new LinkedInOAuthStrategyFactory(configService); + expect(factory.isSocialAccountLinkEnabled()).toBe(false); + }); + it('return null when OAuth disabled', () => { const strategy = factory.createOAuthStrategy(); expect(strategy).toBeNull(); }); + it('return null when social account link disabled', () => { + const strategy = factory.createSocialAccountLinkStrategy(); + expect(strategy).toBeNull(); + }); + it('enable OAuth when credentials present', () => { jest .spyOn(configService, 'get') .mockImplementation((key) => - key === 'LINKEDIN_CLIENT_ID' || - key === 'LINKEDIN_CLIENT_SECRET' || - key === 'LINKEDIN_CALLBACK_URL' + key === 'LINKEDIN_OAUTH_CLIENT_ID' || + key === 'LINKEDIN_OAUTH_CLIENT_SECRET' || + key === 'LINKEDIN_OAUTH_CALLBACK_URL' ? 'test' : '', ); @@ -39,8 +50,49 @@ describe('LinkedInOAuthStrategyFactory', () => { expect(factory.isOAuthEnabled()).toBe(true); }); + it('enable social account link when credentials present', () => { + jest + .spyOn(configService, 'get') + .mockImplementation((key) => + key === 'LINKEDIN_SOCIAL_ACCOUNT_LINK_CLIENT_ID' || + key === 'LINKEDIN_SOCIAL_ACCOUNT_LINK_CLIENT_SECRET' || + key === 'LINKEDIN_SOCIAL_ACCOUNT_LINK_CALLBACK_URL' + ? 'test' + : '', + ); + factory = new LinkedInOAuthStrategyFactory(configService); + expect(factory.isSocialAccountLinkEnabled()).toBe(true); + }); + it('create OAuth strategy when enabled', () => { - const strategy = factory.createOAuthStrategy(); + jest + .spyOn(configService, 'get') + .mockImplementation((key) => + key === 'LINKEDIN_OAUTH_CLIENT_ID' || + key === 'LINKEDIN_OAUTH_CLIENT_SECRET' || + key === 'LINKEDIN_OAUTH_CALLBACK_URL' + ? 'test' + : '', + ); + const strategy = new LinkedInOAuthStrategyFactory( + configService, + ).createOAuthStrategy(); + expect(strategy).toBeInstanceOf(LinkedInStrategy); + }); + + it('create social account link strategy when enabled', () => { + jest + .spyOn(configService, 'get') + .mockImplementation((key) => + key === 'LINKEDIN_SOCIAL_ACCOUNT_LINK_CLIENT_ID' || + key === 'LINKEDIN_SOCIAL_ACCOUNT_LINK_CLIENT_SECRET' || + key === 'LINKEDIN_SOCIAL_ACCOUNT_LINK_CALLBACK_URL' + ? 'test' + : '', + ); + const strategy = new LinkedInOAuthStrategyFactory( + configService, + ).createSocialAccountLinkStrategy(); expect(strategy).toBeInstanceOf(LinkedInStrategy); }); }); diff --git a/src/oauth/factory/linkedin/linkedin-strategy.factory.ts b/src/oauth/factory/linkedin/linkedin-strategy.factory.ts index fbf4184..8089f48 100644 --- a/src/oauth/factory/linkedin/linkedin-strategy.factory.ts +++ b/src/oauth/factory/linkedin/linkedin-strategy.factory.ts @@ -5,32 +5,75 @@ import { LinkedInStrategy } from '../../strategy/linkedin/linkedin.strategy'; @Injectable() export class LinkedInOAuthStrategyFactory implements OAuthStrategyFactory { - private readonly clientID: string; - private readonly clientSecret: string; - private readonly callbackURL: string; + private readonly oAuthClientID: string; + private readonly oAuthClientSecret: string; + private readonly oAuthCallbackURL: string; + private readonly socialAccountLinkClientID: string; + private readonly socialAccountLinkClientSecret: string; + private readonly socialAccountLinkCallbackURL: string; constructor(private readonly configService: ConfigService) { - this.clientID = this.configService.get('LINKEDIN_CLIENT_ID'); - this.clientSecret = this.configService.get( - 'LINKEDIN_CLIENT_SECRET', + this.oAuthClientID = this.configService.get( + 'LINKEDIN_OAUTH_CLIENT_ID', + ); + this.oAuthClientSecret = this.configService.get( + 'LINKEDIN_OAUTH_CLIENT_SECRET', + ); + this.oAuthCallbackURL = this.configService.get( + 'LINKEDIN_OAUTH_CALLBACK_URL', + ); + this.socialAccountLinkClientID = this.configService.get( + 'LINKEDIN_SOCIAL_ACCOUNT_LINK_CLIENT_ID', + ); + this.socialAccountLinkClientSecret = this.configService.get( + 'LINKEDIN_SOCIAL_ACCOUNT_LINK_CLIENT_SECRET', + ); + this.socialAccountLinkCallbackURL = this.configService.get( + 'LINKEDIN_SOCIAL_ACCOUNT_LINK_CALLBACK_URL', ); - this.callbackURL = this.configService.get('LINKEDIN_CALLBACK_URL'); } public isOAuthEnabled(): boolean { - return Boolean(this.clientID && this.clientSecret && this.callbackURL); + return Boolean( + this.oAuthClientID && this.oAuthClientSecret && this.oAuthCallbackURL, + ); + } + + public isSocialAccountLinkEnabled(): boolean { + return Boolean( + this.socialAccountLinkClientID && + this.socialAccountLinkClientSecret && + this.socialAccountLinkCallbackURL, + ); } public createOAuthStrategy(): LinkedInStrategy | null { if (this.isOAuthEnabled()) { return new LinkedInStrategy( - this.clientID, - this.clientSecret, - this.callbackURL, + this.oAuthClientID, + this.oAuthClientSecret, + this.oAuthCallbackURL, ) as LinkedInStrategy; } else { Logger.warn('LinkedIn Auth is not enabled in this environment.'); return null; } } + + public createSocialAccountLinkStrategy< + LinkedInStrategy, + >(): LinkedInStrategy | null { + if (this.isSocialAccountLinkEnabled()) { + return new LinkedInStrategy( + this.socialAccountLinkClientID, + this.socialAccountLinkClientSecret, + this.socialAccountLinkCallbackURL, + ) as LinkedInStrategy; + } else { + Logger.warn( + 'LinkedIn Social Account Link is not enabled in this environment.', + ); + return null; + } + } } diff --git a/src/oauth/factory/oauth-strategy.factory.ts b/src/oauth/factory/oauth-strategy.factory.ts index 4b9d900..0132588 100644 --- a/src/oauth/factory/oauth-strategy.factory.ts +++ b/src/oauth/factory/oauth-strategy.factory.ts @@ -14,5 +14,11 @@ import { PassportStrategy } from '@nestjs/passport'; export interface OAuthStrategyFactory { createOAuthStrategy(): T | null; + createSocialAccountLinkStrategy< + T extends typeof PassportStrategy, + >(): T | null; + isOAuthEnabled(): boolean; + + isSocialAccountLinkEnabled(): boolean; } diff --git a/src/prisma/migrations/20240520152233_add_social_link/migration.sql b/src/prisma/migrations/20240520152233_add_social_link/migration.sql new file mode 100644 index 0000000..e44a9bb --- /dev/null +++ b/src/prisma/migrations/20240520152233_add_social_link/migration.sql @@ -0,0 +1,56 @@ +/* + Warnings: + + - You are about to drop the `LinkedSocialAccount` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `UserSocialAccount` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- CreateEnum +CREATE TYPE "SocialAccountType" AS ENUM ('LINKEDIN', 'GITHUB', 'TWITTER', 'FACEBOOK', 'GITLAB'); + +-- AlterEnum +ALTER TYPE "AuthType" ADD VALUE 'EXTERNAL'; + +-- DropForeignKey +ALTER TABLE "Connection" DROP CONSTRAINT "Connection_followerId_fkey"; + +-- DropForeignKey +ALTER TABLE "Connection" DROP CONSTRAINT "Connection_followingId_fkey"; + +-- DropForeignKey +ALTER TABLE "LinkedSocialAccount" DROP CONSTRAINT "LinkedSocialAccount_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "UserSocialAccount" DROP CONSTRAINT "UserSocialAccount_userId_fkey"; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL; + +-- DropTable +DROP TABLE "LinkedSocialAccount"; + +-- DropTable +DROP TABLE "UserSocialAccount"; + +-- CreateTable +CREATE TABLE "SocialAccount" ( + "id" SERIAL NOT NULL, + "platform" "SocialAccountType" NOT NULL, + "profileUrl" TEXT NOT NULL, + "addedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + + CONSTRAINT "SocialAccount_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SocialAccount_userId_platform_profileUrl_idx" ON "SocialAccount"("userId", "platform", "profileUrl"); + +-- AddForeignKey +ALTER TABLE "Connection" ADD CONSTRAINT "Connection_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Connection" ADD CONSTRAINT "Connection_followingId_fkey" FOREIGN KEY ("followingId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SocialAccount" ADD CONSTRAINT "SocialAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 1510ffb..7ab64bd 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -13,49 +13,54 @@ enum AuthType { FACEBOOK LINKEDIN EMAIL + EXTERNAL +} + +enum SocialAccountType { + LINKEDIN + GITHUB + TWITTER + FACEBOOK + GITLAB } model User { - id String @id @default(cuid()) - email String @unique - name String? - profilePictureUrl String? - socialAccounts UserSocialAccount[] - authType AuthType - isEmailVerified Boolean @default(false) - headline String? - jobTitle String? - followers Connection[] @relation("followsTheUser") - followings Connection[] @relation("followedByUser") - LinkedSocialAccounts LinkedSocialAccount[] - ratingsPosted Rating[] @relation("ratingsGivenToOtherUsers") - ratingsReceived Rating[] @relation("ratingsRecievedFromOtherUsers") - joinedAt DateTime @default(now()) + id String @id @default(cuid()) + email String? @unique + name String? + profilePictureUrl String? + socialAccounts SocialAccount[] + authType AuthType + isEmailVerified Boolean @default(false) + headline String? + jobTitle String? + followers Connection[] @relation("followsTheUser") + followings Connection[] @relation("followedByUser") + ratingsPosted Rating[] @relation("ratingsGivenToOtherUsers") + ratingsReceived Rating[] @relation("ratingsRecievedFromOtherUsers") + joinedAt DateTime @default(now()) } model Connection { id Int @id @default(autoincrement()) - follower User @relation("followedByUser", fields: [followerId], references: [id]) + follower User @relation("followedByUser", fields: [followerId], references: [id], onDelete: Cascade, onUpdate: Cascade) followerId String - following User @relation("followsTheUser", fields: [followingId], references: [id]) + following User @relation("followsTheUser", fields: [followingId], references: [id], onDelete: Cascade, onUpdate: Cascade) followingId String @@unique([followerId, followingId]) } -model LinkedSocialAccount { - id Int @id @default(autoincrement()) - userId String - user User @relation(fields: [userId], references: [id]) - platform String - isConnected Boolean @default(false) - profileUrl String? - accessToken String? @db.Text - refreshToken String? @db.Text - accessTokenExpiry DateTime? - refreshTokenExpiry DateTime? +model SocialAccount { + id Int @id @default(autoincrement()) + platform SocialAccountType + profileUrl String + addedOn DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String - @@unique([userId, platform]) + @@index([userId, platform, profileUrl]) } model Rating { @@ -74,15 +79,6 @@ model Rating { anonymous Boolean @default(false) } -model UserSocialAccount { - id String @id @default(cuid()) - provider String @unique - accessToken String - - user User @relation(fields: [userId], references: [id]) - userId String -} - model VerificationCode { code String @id email String diff --git a/src/user/controller/user.controller.spec.ts b/src/user/controller/user.controller.spec.ts deleted file mode 100644 index 6da47c2..0000000 --- a/src/user/controller/user.controller.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UserController } from './user.controller'; -import { UserService } from '../service/user.service'; -import { PrismaService } from '../../prisma/prisma.service'; -import { mockDeep } from 'jest-mock-extended'; -import { ProviderModule } from '../../provider/provider.module'; -import { MailService } from '../../mail/mail.service'; -import { REDIS_CLIENT } from '../../provider/redis.provider'; - -describe('UserController', () => { - let controller: UserController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [UserController], - imports: [ProviderModule], - providers: [ - UserService, - PrismaService, - MailService, - { - provide: REDIS_CLIENT, - useValue: {}, - }, - ], - }) - .overrideProvider(MailService) - .useValue(mockDeep()) - .overrideProvider(PrismaService) - .useValue({}) - .compile(); - - controller = module.get(UserController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/user/controller/user.controller.ts b/src/user/controller/user.controller.ts index e36ea4b..91f6015 100644 --- a/src/user/controller/user.controller.ts +++ b/src/user/controller/user.controller.ts @@ -1,18 +1,20 @@ import { + BadRequestException, Body, Controller, Get, Param, Post, Put, - Query, + Req, + Res, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '../../decorators/current-user.decorator'; import { UserService } from '../service/user.service'; -import { User } from '@prisma/client'; +import { SocialAccountType, User } from '@prisma/client'; import { RatingDto } from '../dto/rating.dto'; import { UpdateUserDto } from '../dto/update-user.dto'; import { FileInterceptor } from '@nestjs/platform-express'; @@ -20,14 +22,12 @@ import { ApiBadRequestResponse, ApiBearerAuth, ApiBody, - ApiConflictResponse, ApiConsumes, ApiCreatedResponse, ApiInternalServerErrorResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, - ApiQuery, ApiTags, } from '@nestjs/swagger'; import { userExtraProps, userProperties } from '../../schemas/user.properties'; @@ -38,13 +38,18 @@ import { // eslint-disable-next-line @typescript-eslint/no-unused-vars import { Multer } from 'multer'; import { Public } from '../../decorators/public.decorator'; -import { AuthGuard } from '../../auth/guard/auth/auth.guard'; +import { Request, Response } from 'express'; +import { LinkedInOAuthStrategyFactory } from '../../oauth/factory/linkedin/linkedin-strategy.factory'; +import { AuthGuard } from '@nestjs/passport'; @Controller('user') @ApiBearerAuth() @ApiTags('User Controller') export class UserController { - constructor(private readonly userService: UserService) {} + constructor( + private readonly userService: UserService, + private linkedinOAuthStrategyFactory: LinkedInOAuthStrategyFactory, + ) {} @Get() @ApiOperation({ @@ -192,34 +197,25 @@ export class UserController { return this.userService.getUserRatings(user, false, revieweeUserId); } + @Get('link-social/linkedin/callback') + @UseGuards(AuthGuard('linkedin')) @Public() - @Post('/link-social') - @ApiOperation({ - summary: 'Link social account', - description: 'Link a social account to the currently logged in user', - }) - @ApiBadRequestResponse({ - description: 'Invalid social account', - }) - @ApiConflictResponse({ - description: 'Social account already linked', - }) - @ApiCreatedResponse({ - description: 'Social account linked successfully', - }) - async linkSocialAccount( - @Body() - { - userId, - provider, - accessToken, - }: { - userId: string; - provider: string; - accessToken: string; - }, - ) { - await this.userService.linkSocialAccount(userId, provider, accessToken); + async facebookOAuthCallback(@Req() req: Request) { + return await this.userService.linkSocialAccount( + req, + SocialAccountType.LINKEDIN, + ); + } + + @Get('link-social/linkedin') + async linkedinOAuthLogin(@Res() res: Response) { + if (!this.linkedinOAuthStrategyFactory.isSocialAccountLinkEnabled()) { + throw new BadRequestException( + 'LinkedIn Social Account Link is not enabled in this environment.', + ); + } + + res.status(302).redirect('/api/user/link-social/linkedin/callback'); } @Get('avg-rating/self') @@ -267,7 +263,7 @@ export class UserController { return this.userService.getAvgUserRatings(user, false, userId); } - @Get('search') + @Get('search/:query') @ApiOperation({ summary: 'Search users', description: 'Search for users', @@ -282,15 +278,30 @@ export class UserController { }, }, }) - @ApiQuery({ - name: 'query', - type: 'string', + @Public() + async searchUsers(@Param('query') query: string) { + return this.userService.searchUsers(query); + } + + @Public() + @Get('search-by-external-profile/:profileUrl') + @ApiOperation({ + summary: 'Search users by external profile', + description: 'Search for users by external profile URL', }) - @UseGuards(AuthGuard) - async searchUsers( - @CurrentUser() user: User, - @Query() { query }: { query: string }, + @ApiOkResponse({ + description: 'Users found', + schema: { + type: 'array', + items: { + type: 'object', + properties: userProperties, + }, + }, + }) + async searchUsersByExternalProfile( + @Param('profileUrl') profileUrlBase64: string, ) { - return this.userService.searchUsers(user.id, query); + return this.userService.searchUserByExternalProfile(profileUrlBase64); } } diff --git a/src/user/profile-fetcher/base.profile-fetcher.ts b/src/user/profile-fetcher/base.profile-fetcher.ts new file mode 100644 index 0000000..6cdd7f0 --- /dev/null +++ b/src/user/profile-fetcher/base.profile-fetcher.ts @@ -0,0 +1,15 @@ +import { SocialAccountType } from '@prisma/client'; + +export abstract class ProfileFetcher { + constructor(protected profileUrl: string) {} + + abstract getProfileDetails(): Promise; +} + +export interface ProfileDetails { + profileUrl: string; + socialAccountType: SocialAccountType; + name?: string; + jobTitle?: string; + profilePictureUrl?: string; +} diff --git a/src/user/profile-fetcher/delegator.profile-fetcher.ts b/src/user/profile-fetcher/delegator.profile-fetcher.ts new file mode 100644 index 0000000..d6223cf --- /dev/null +++ b/src/user/profile-fetcher/delegator.profile-fetcher.ts @@ -0,0 +1,16 @@ +import { ProfileFetcher } from './base.profile-fetcher'; +import { ProfileFetcherFactory } from './profile-fetcher.factory'; + +export class ProfileFetcherDelegator { + private readonly profileFetcher: ProfileFetcher; + + constructor(profileUrl: string) { + this.profileFetcher = new ProfileFetcherFactory().generateProfileFetcher( + profileUrl, + ); + } + + async getProfileDetails() { + return this.profileFetcher.getProfileDetails(); + } +} diff --git a/src/user/profile-fetcher/linkedin.profile-fetcher.ts b/src/user/profile-fetcher/linkedin.profile-fetcher.ts new file mode 100644 index 0000000..eb70910 --- /dev/null +++ b/src/user/profile-fetcher/linkedin.profile-fetcher.ts @@ -0,0 +1,35 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ProfileDetails, ProfileFetcher } from './base.profile-fetcher'; +import { SocialAccountType } from '@prisma/client'; + +export class LinkedInProfileFetcher extends ProfileFetcher { + async getProfileDetails(): Promise { + const host = process.env.LINKEDIN_PROFILE_FETCHER_HOST; + const apiKey = process.env.LINKEDIN_PROFILE_FETCHER_API_KEY; + + const response = await fetch( + `https://${host}/get-linkedin-profile?linkedin_url=${this.profileUrl}&include_skills=false`, + { + headers: { + 'X-RapidAPI-Key': apiKey, + 'X-RapidAPI-Host': host, + }, + }, + ); + + if (response.ok) { + const data = await response.json(); + return { + name: data.data.full_name, + socialAccountType: SocialAccountType.LINKEDIN, + profileUrl: this.profileUrl, + jobTitle: data.data.job_title, + profilePictureUrl: data.data.profile_image_url, + }; + } else { + throw new InternalServerErrorException( + 'Failed to fetch LinkedIn profile', + ); + } + } +} diff --git a/src/user/profile-fetcher/profile-fetcher.factory.ts b/src/user/profile-fetcher/profile-fetcher.factory.ts new file mode 100644 index 0000000..93f4f0f --- /dev/null +++ b/src/user/profile-fetcher/profile-fetcher.factory.ts @@ -0,0 +1,11 @@ +import { BadRequestException } from '@nestjs/common'; +import { LinkedInProfileFetcher } from './linkedin.profile-fetcher'; + +export class ProfileFetcherFactory { + generateProfileFetcher(profileUrl: string) { + if (profileUrl.startsWith('https://www.linkedin.com')) { + return new LinkedInProfileFetcher(profileUrl); + } + throw new BadRequestException('Unsupported profile URL'); + } +} diff --git a/src/user/service/user.service.spec.ts b/src/user/service/user.service.spec.ts index 416427b..a300d5c 100644 --- a/src/user/service/user.service.spec.ts +++ b/src/user/service/user.service.spec.ts @@ -5,6 +5,8 @@ import { mockDeep } from 'jest-mock-extended'; import { ProviderModule } from '../../provider/provider.module'; import { MailService } from '../../mail/mail.service'; import { REDIS_CLIENT } from '../../provider/redis.provider'; +import { LinkedInOAuthStrategyFactory } from '../../oauth/factory/linkedin/linkedin-strategy.factory'; +import { ConfigService } from '@nestjs/config'; describe('UserService', () => { let service: UserService; @@ -16,6 +18,8 @@ describe('UserService', () => { UserService, PrismaService, MailService, + ConfigService, + LinkedInOAuthStrategyFactory, { provide: REDIS_CLIENT, useValue: {}, diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index 0238316..ef729d0 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -1,13 +1,12 @@ import { BadRequestException, - ConflictException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException, } from '@nestjs/common'; -import { User } from '@prisma/client'; +import { AuthType, SocialAccountType, User } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { RatingDto } from '../dto/rating.dto'; import { UpdateUserDto } from '../dto/update-user.dto'; @@ -18,6 +17,8 @@ import { S3_CLIENT } from '../../provider/s3.provider'; import { Multer } from 'multer'; import { REDIS_CLIENT } from '../../provider/redis.provider'; import { Redis } from 'ioredis'; +import { ProfileFetcherDelegator } from '../profile-fetcher/delegator.profile-fetcher'; +import { v4 } from 'uuid'; @Injectable() export class UserService { @@ -130,7 +131,7 @@ export class UserService { })); } - async searchUsers(userId: User['id'], searchTerm?: string) { + async searchUsers(searchTerm?: string) { if (!searchTerm) { throw new BadRequestException('Search term is required'); } @@ -149,11 +150,7 @@ export class UserService { isEmailVerified: true, jobTitle: true, profilePictureUrl: true, - followings: { - where: { - followerId: userId, - }, - }, + followings: true, _count: { select: { followings: true, @@ -170,33 +167,106 @@ export class UserService { })); } - async linkSocialAccount( - userId: string, - provider: string, - accessToken: string, - ) { - const user = await this.findUserById(userId); - if (!user) { - throw new NotFoundException('User not found'); - } + /** + * This function aims to either create a social account link for the user. If a social + * account with the same profile URL already exists, it will be linked to the user, and + * all the followers and ratings will be transferred to the current user. The existing + * account will be deleted. + * @param req The request object returned from the passport strategy + * @param socialAccountType The type of social account to link + */ + async linkSocialAccount(req: any, socialAccountType: SocialAccountType) { + const { emails, profileUrl } = req.user; + const email = emails[0].value; - let email: string | undefined; + // Check if the user exists + const currentUser = await this.prisma.user.findUniqueOrThrow({ + where: { email }, + }); - const existingUser = await this.findUserByEmail(email); - if (existingUser && existingUser.id !== user.id) { - throw new ConflictException( - 'Social account already linked to a different user', - ); + // Check if the particular account exists for any other user + const existingAccount = await this.prisma.socialAccount.findFirst({ + where: { + platform: socialAccountType, + profileUrl: profileUrl, + }, + }); + if (existingAccount) { + const socialAccountUser = await this.prisma.user.findUnique({ + where: { id: existingAccount.userId }, + }); + + // TODO: Merge account here + await this.prisma.$transaction([ + // Update all the ratings to the existing account with the current user + this.prisma.rating.updateMany({ + where: { postedToId: socialAccountUser.id }, + data: { postedToId: currentUser.id }, + }), + + // Update all the followers to the existing account with the current user + this.prisma.connection.updateMany({ + where: { followingId: socialAccountUser.id }, + data: { followingId: currentUser.id }, + }), + + // Delete the existing account + this.prisma.socialAccount.delete({ + where: { id: existingAccount.id }, + }), + ]); + } else { + // Add the social account to the user + await this.prisma.socialAccount.create({ + data: { + platform: socialAccountType, + profileUrl, + userId: currentUser.id, + }, + }); } + } - // Add the social account to the user - await this.prisma.linkedSocialAccount.create({ - data: { - platform: provider, - accessToken, - userId, + async searchUserByExternalProfile(profileUrlBase64: string) { + // Check if the profile by url exists + // If the account exists, we just return the user associated with it. + const profileUrl = Buffer.from(profileUrlBase64, 'base64').toString(); + + const socialAccount = await this.prisma.socialAccount.findFirst({ + where: { profileUrl }, + include: { + user: true, }, }); + if (socialAccount) return socialAccount.user; + + // Fetch the profile details + const profileData = await new ProfileFetcherDelegator( + profileUrl, + ).getProfileDetails(); + + // Else, we create a new user, associate the social account with it, and return the user. + + const newUserId = v4(); + const [newUser] = await this.prisma.$transaction([ + this.prisma.user.create({ + data: { + id: newUserId, + name: profileData.name, + authType: AuthType.EXTERNAL, + jobTitle: profileData.jobTitle, + profilePictureUrl: profileData.profilePictureUrl, + }, + }), + this.prisma.socialAccount.create({ + data: { + platform: profileData.socialAccountType, + profileUrl, + userId: newUserId, + }, + }), + ]); + return newUser; } async getAvgUserRatings(user: User, self: boolean, userId?: User['id']) { @@ -221,18 +291,6 @@ export class UserService { return avgRatings; } - private async findUserById(id: string) { - return await this.prisma.user.findUnique({ where: { id } }); - } - - private async findUserByEmail(email: string) { - return await this.prisma.user.findUnique({ - where: { - email, - }, - }); - } - private async calculateAvgRating(userId: User['id']) { const result = await this.prisma.rating.aggregate({ where: { postedToId: userId }, diff --git a/src/user/user.e2e.spec.ts b/src/user/user.e2e.spec.ts index dceadc2..994bdad 100644 --- a/src/user/user.e2e.spec.ts +++ b/src/user/user.e2e.spec.ts @@ -40,7 +40,6 @@ describe('User Controller Tests', () => { data: { id: '1', email: 'johndoe@example.com', - password: 'password', name: 'John Doe', isEmailVerified: true, authType: AuthType.EMAIL, @@ -124,7 +123,6 @@ describe('User Controller Tests', () => { data: { id: '2', email: 'janedoe@example.com', - password: 'password', name: 'Jane Doe', isEmailVerified: true, authType: AuthType.EMAIL, @@ -135,7 +133,7 @@ describe('User Controller Tests', () => { data: [ { postedToId: '2', - raterUserId: '1', + postedById: '1', professionalism: 5, reliability: 5, communication: 5, @@ -143,7 +141,7 @@ describe('User Controller Tests', () => { }, { postedToId: '2', - raterUserId: '1', + postedById: '1', professionalism: 5, reliability: 5, communication: 5, @@ -154,7 +152,7 @@ describe('User Controller Tests', () => { await prisma.rating.create({ data: { postedToId: '1', - raterUserId: '2', + postedById: '2', professionalism: 5, reliability: 5, communication: 5, @@ -296,7 +294,7 @@ describe('User Controller Tests', () => { expect(response.statusCode).toBe(201); expect(response.json().postedToId).toBe('2'); - expect(response.json().raterUserId).toBe('1'); + expect(response.json().postedById).toBe('1'); expect(response.json().professionalism).toBe(5); expect(response.json().reliability).toBe(5); expect(response.json().communication).toBe(5); @@ -308,7 +306,7 @@ describe('User Controller Tests', () => { }); expect(rating).toBeDefined(); expect(rating.postedToId).toBe('2'); - expect(rating.raterUserId).toBe('1'); + expect(rating.postedById).toBe('1'); expect(rating.professionalism).toBe(5); expect(rating.reliability).toBe(5); expect(rating.communication).toBe(5); @@ -331,7 +329,7 @@ describe('User Controller Tests', () => { expect(response.statusCode).toBe(201); expect(response.json().postedToId).toBe('2'); - expect(response.json().raterUserId).toBe(null); + expect(response.json().postedById).toBe(null); expect(response.json().professionalism).toBe(5); expect(response.json().reliability).toBe(5); expect(response.json().communication).toBe(5); @@ -344,7 +342,7 @@ describe('User Controller Tests', () => { }); expect(rating).toBeDefined(); expect(rating.postedToId).toBe('2'); - expect(rating.raterUserId).toBe(null); + expect(rating.postedById).toBe(null); expect(rating.professionalism).toBe(5); expect(rating.reliability).toBe(5); expect(rating.communication).toBe(5); @@ -424,20 +422,9 @@ describe('User Controller Tests', () => { }); it('should be able to search for a user', async () => { - await prisma.user.create({ - data: { - id: '3', - email: 'abc@examil.com', - password: 'password', - name: 'John', - isEmailVerified: true, - authType: AuthType.EMAIL, - }, - }); - const response = await app.inject({ method: 'GET', - url: '/user/search/doe', + url: '/user/search/John', }); expect(response.statusCode).toBe(200); diff --git a/src/user/user.module.ts b/src/user/user.module.ts index aeba126..b2998a0 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,9 +1,23 @@ import { Module } from '@nestjs/common'; import { UserController } from './controller/user.controller'; import { UserService } from './service/user.service'; +import { LinkedInOAuthStrategyFactory } from '../oauth/factory/linkedin/linkedin-strategy.factory'; +import { LinkedInStrategy } from '../oauth/strategy/linkedin/linkedin.strategy'; @Module({ controllers: [UserController], - providers: [UserService], + providers: [ + UserService, + LinkedInOAuthStrategyFactory, + { + provide: LinkedInStrategy, + useFactory: ( + linkedinOAuthStrategyFactory: LinkedInOAuthStrategyFactory, + ) => { + linkedinOAuthStrategyFactory.createOAuthStrategy(); + }, + inject: [LinkedInOAuthStrategyFactory], + }, + ], }) export class UserModule {}