diff --git a/.env b/.env index 7e671be9b6..52801f67b3 100644 --- a/.env +++ b/.env @@ -92,3 +92,4 @@ CLICKHOUSE_USER=default CLICKHOUSE_PASSWORD=changeme RESUME_BUCKET_NAME=bucket-name EMPLOYMENT_AGREEMENT_BUCKET_NAME=other-bucket-name +HMAC_SECRET=supersecretkey diff --git a/.infra/Pulumi.adhoc.yaml b/.infra/Pulumi.adhoc.yaml index 01f24b1625..770772b669 100644 --- a/.infra/Pulumi.adhoc.yaml +++ b/.infra/Pulumi.adhoc.yaml @@ -96,6 +96,7 @@ config: resumeBucketName: adhoc-daily-api employmentAgreementBucketName: adhoc-daily-api brokkrOrigin: http://brokkr-grpc:50051 + hmacSecret: topsecret api:k8s: namespace: local api:temporal: diff --git a/.infra/Pulumi.prod.yaml b/.infra/Pulumi.prod.yaml index 0512740d53..012517828e 100644 --- a/.infra/Pulumi.prod.yaml +++ b/.infra/Pulumi.prod.yaml @@ -173,6 +173,8 @@ config: secure: AAABANvfb9iJDOmbRPpMpkPzBLpeKTHsLCsFkNfU38yn53ZKUmeP0PA34uVGPZp+y4luHmEFXRE/jy/4rFwr9ZoVtufE59aa7J5AtVJcYY1VlumIY6P5olg= brokkrOrigin: secure: AAABABvk5Zi2sL2p8Gjl4vwVS+Ge+P2S3Jo8yDBOTNMZ6FZ1WquX+DqQkvE1YBTCvSMy9kmd+TCsRzeV3LrTC0Xbnlh7PxLog7fseJS37GUJ + hmacSecret: + secure: AAABANgm4eu/hvsByErzHqU7b/52HNIxF3AvvorTo5aEKPaQHKUrXOj98jYSN9eC/amgPg2UuJ8AN/rTcs0= api:k8s: host: subs.daily.dev namespace: daily diff --git a/.infra/application.properties b/.infra/application.properties index e4eff0e4af..a4d44d53ba 100644 --- a/.infra/application.properties +++ b/.infra/application.properties @@ -8,7 +8,7 @@ debezium.source.database.user=%database_user% debezium.source.database.password=%database_pass% debezium.source.database.dbname=%database_dbname% debezium.source.database.server.name=api -debezium.source.table.include.list=public.comment,public.user_comment,public.comment_mention,public.source_request,public.post,public.user,public.post_report,public.source_feed,public.settings,public.reputation_event,public.submission,public.user_state,public.notification_v2,public.source_member,public.feature,public.source,public.post_mention,public.content_image,public.comment_report,public.user_post,public.banner,public.post_relation,public.marketing_cta,public.squad_public_request,public.user_streak,public.bookmark,public.user_company,public.source_report,public.user_top_reader,public.source_post_moderation,public.user_report,public.user_transaction,public.content_preference,public.campaign,public.opportunity_match,public.opportunity,public.organization,public.user_candidate_preference,public.user_experience +debezium.source.table.include.list=public.comment,public.user_comment,public.comment_mention,public.source_request,public.post,public.user,public.post_report,public.source_feed,public.settings,public.reputation_event,public.submission,public.user_state,public.notification_v2,public.source_member,public.feature,public.source,public.post_mention,public.content_image,public.comment_report,public.user_post,public.banner,public.post_relation,public.marketing_cta,public.squad_public_request,public.user_streak,public.bookmark,public.user_company,public.source_report,public.user_top_reader,public.source_post_moderation,public.user_report,public.user_transaction,public.content_preference,public.campaign,public.opportunity_match,public.opportunity,public.organization,public.user_candidate_preference,public.user_experience,public.user_referral debezium.source.column.exclude.list=public.post.tsv,public.post.placeholder,public.source.flags,public.user_top_reader.image debezium.source.skip.messages.without.change=true debezium.source.plugin.name=pgoutput diff --git a/__tests__/redirector.ts b/__tests__/redirector.ts index e0d3701785..d4483fa90d 100644 --- a/__tests__/redirector.ts +++ b/__tests__/redirector.ts @@ -1,14 +1,19 @@ import appFunc from '../src'; import { FastifyInstance } from 'fastify'; -import { saveFixtures, TEST_UA } from './helpers'; +import { authorizeRequest, saveFixtures, TEST_UA } from './helpers'; import { ArticlePost, Source, User, YouTubePost } from '../src/entity'; import { sourcesFixture } from './fixture/source'; import request from 'supertest'; import { postsFixture, videoPostsFixture } from './fixture/post'; -import { notifyView } from '../src/common'; +import { hmacHashIP, notifyView } from '../src/common'; import { DataSource } from 'typeorm'; import createOrGetConnection from '../src/db'; import { fallbackImages } from '../src/config'; +import { usersFixture } from './fixture/user'; +import { UserReferralLinkedin } from '../src/entity/user/referral/UserReferralLinkedin'; +import { logger } from '../src/logger'; +import { UserReferralStatus } from '../src/entity/user/referral/UserReferral'; +import { BASE_RECRUITER_URL } from '../src/routes/r/recruiter'; jest.mock('../src/common', () => ({ ...(jest.requireActual('../src/common') as Record), @@ -134,3 +139,216 @@ describe('GET /:id/profile-image', () => { .expect('Location', fallbackImages.avatar); }); }); + +describe('GET /r/recruiter/:id', () => { + const spyLogger = jest.fn(); + + const saveReferral = async (override?: Partial) => { + return con.getRepository(UserReferralLinkedin).save({ + userId: usersFixture[0].id, + externalUserId: 'ext-0', + flags: { hashedRequestIP: hmacHashIP('198.51.100.1') }, + ...override, + }); + }; + + const getReferral = async (id: string) => { + return con.getRepository(UserReferralLinkedin).findOne({ where: { id } }); + }; + + beforeEach(async () => { + await saveFixtures(con, User, usersFixture); + }); + + it('should redirect to recruiter landing', async () => { + const r = await saveReferral(); + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe(`${BASE_RECRUITER_URL}&utm_content=1`); + }); + + it('should redirect to recruiter landing even with invalid UUID', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const res = await request(app.server) + .get(`/r/recruiter/invalid-uuid`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe(BASE_RECRUITER_URL); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(2); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: 'invalid-uuid' }, + 'Invalid referral id provided, skipping recruiter redirector', + ); + }); + + it('should redirect to recruiter landing without marking visited if user is logged in', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral(); + + const res = await authorizeRequest( + request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'), + ); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(`${BASE_RECRUITER_URL}&utm_content=1`); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id }, + 'User is logged in, skipping recruiter redirector', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(false); + }); + + it('should redirect to recruiter landing without marking visited if no referrer', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral(); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('X-Forwarded-For', '203.0.113.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(`${BASE_RECRUITER_URL}&utm_content=1`); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id }, + 'No referrer provided, skipping recruiter redirector', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(false); + }); + + it('should redirect to recruiter landing without marking visited if referrer is not linkedin', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral(); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://daily.dev/') + .set('X-Forwarded-For', '203.0.113.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(`${BASE_RECRUITER_URL}&utm_content=1`); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { + referralId: r.id, + referrer: 'https://daily.dev/', + }, + 'Referrer is not linkedin, skipping recruiter redirector', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(false); + }); + + it('should mark referral as visited when all conditions met', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral(); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(`${BASE_RECRUITER_URL}&utm_content=1`); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id }, + 'Marked referral as visited', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(true); + }); + + it('should not mark referral as visited if visitor is the requester', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral(); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '198.51.100.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(`${BASE_RECRUITER_URL}&utm_content=1`); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id }, + 'No referral found or referral already marked as visited', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(false); + }); + + it('should not do anything if already visited', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral({ visited: true }); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(`${BASE_RECRUITER_URL}&utm_content=1`); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id, status: 'pending', visited: true }, + 'Referral is not pending or has been visited, skipping recruiter redirector', + ); + }); + + it('should not do anything if referral status is not pending', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral({ status: UserReferralStatus.Rejected }); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(`${BASE_RECRUITER_URL}&utm_content=1`); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id, status: 'rejected', visited: false }, + 'Referral is not pending or has been visited, skipping recruiter redirector', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(false); + }); +}); diff --git a/__tests__/schema/profile.ts b/__tests__/schema/profile.ts index 11f3c3acb5..e3fa5d3a4a 100644 --- a/__tests__/schema/profile.ts +++ b/__tests__/schema/profile.ts @@ -15,6 +15,17 @@ import { UserExperience } from '../../src/entity/user/experiences/UserExperience import { UserExperienceType } from '../../src/entity/user/experiences/types'; import { Company } from '../../src/entity/Company'; import { UserExperienceSkill } from '../../src/entity/user/experiences/UserExperienceSkill'; +import { UserReferralLinkedin } from '../../src/entity/user/referral/UserReferralLinkedin'; +import { getShortUrl, hmacHashIP } from '../../src/common'; + +jest.mock('../../src/common', () => ({ + ...jest.requireActual('../../src/common'), + getShortUrl: jest.fn(), + hmacHashIP: jest.fn(), +})); + +const mockGetShortUrl = getShortUrl as jest.MockedFunction; +const mockHmacHashIP = hmacHashIP as jest.MockedFunction; let con: DataSource; let state: GraphQLTestingState; @@ -118,6 +129,13 @@ const userExperiencesFixture: DeepPartial[] = [ beforeEach(async () => { loggedUser = null; + mockGetShortUrl.mockClear(); + mockGetShortUrl.mockImplementation( + async (url: string): Promise => + Promise.resolve(`https://diy.dev/${url.split('/').pop()}`), + ); + mockHmacHashIP.mockClear(); + mockHmacHashIP.mockReturnValue('hashed-ip-address'); await saveFixtures(con, User, usersFixture); await saveFixtures(con, Company, companiesFixture); await saveFixtures(con, UserExperience, userExperiencesFixture); @@ -1688,3 +1706,183 @@ describe('mutation removeUserExperience', () => { expect(res.errors).toBeFalsy(); }); }); + +describe('query userReferralRecruiter', () => { + const USER_REFERRAL_RECRUITER_QUERY = /* GraphQL */ ` + query UserReferralRecruiter($toReferExternalId: String!) { + userReferralRecruiter(toReferExternalId: $toReferExternalId) { + url + } + } + `; + + it('should require authentication', async () => { + loggedUser = null; + + await testQueryErrorCode( + client, + { + query: USER_REFERRAL_RECRUITER_QUERY, + variables: { toReferExternalId: 'john-doe' }, + }, + 'UNAUTHENTICATED', + ); + }); + + it('should create a new referral when one does not exist', async () => { + loggedUser = '1'; + + const res = await client.query(USER_REFERRAL_RECRUITER_QUERY, { + variables: { toReferExternalId: 'john-doe' }, + }); + + expect(res.errors).toBeFalsy(); + + // Verify the referral was created in the database + const referral = await con + .getRepository(UserReferralLinkedin) + .findOne({ where: { userId: '1', externalUserId: 'john-doe' } }); + + expect(referral).toMatchObject({ + userId: '1', + externalUserId: 'john-doe', + flags: { + linkedinProfileUrl: 'https://www.linkedin.com/in/john-doe', + hashedRequestIP: 'hashed-ip-address', + }, + }); + + // Verify getShortUrl was called with the correct URL + expect(mockGetShortUrl).toHaveBeenCalledTimes(1); + const callArgs = mockGetShortUrl.mock.calls[0]; + expect(callArgs[0]).toMatch(/\/r\/recruiter\//); + + expect(res.data.userReferralRecruiter).toMatchObject({ + url: `https://diy.dev/${referral?.id}`, + }); + }); + + it('should return existing referral when one already exists', async () => { + loggedUser = '1'; + + // Create an existing referral + const existingReferral = con.getRepository(UserReferralLinkedin).create({ + id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + userId: '1', + externalUserId: 'jane-smith', + flags: { + linkedinProfileUrl: 'https://www.linkedin.com/in/jane-smith', + hashedRequestIP: 'some-hash', + }, + }); + await con.getRepository(UserReferralLinkedin).save(existingReferral); + + const res = await client.query(USER_REFERRAL_RECRUITER_QUERY, { + variables: { toReferExternalId: 'jane-smith' }, + }); + + expect(res.errors).toBeFalsy(); + + // Verify getShortUrl was called with the existing referral ID + expect(mockGetShortUrl).toHaveBeenCalledTimes(1); + const callArgs = mockGetShortUrl.mock.calls[0]; + expect(callArgs[0]).toBe( + `${process.env.URL_PREFIX}/r/recruiter/f47ac10b-58cc-4372-a567-0e02b2c3d479`, + ); + + // Verify only one referral exists (no duplicate created) + const referrals = await con + .getRepository(UserReferralLinkedin) + .find({ where: { userId: '1', externalUserId: 'jane-smith' } }); + expect(referrals).toHaveLength(1); + + expect(res.data.userReferralRecruiter).toMatchObject({ + url: `https://diy.dev/${referrals[0]?.id}`, + }); + }); + + it('should create different referrals for different external user IDs', async () => { + loggedUser = '1'; + + // Create referral for first external user + const res1 = await client.query(USER_REFERRAL_RECRUITER_QUERY, { + variables: { toReferExternalId: 'user-one' }, + }); + + expect(res1.errors).toBeFalsy(); + + // Create referral for second external user + const res2 = await client.query(USER_REFERRAL_RECRUITER_QUERY, { + variables: { toReferExternalId: 'user-two' }, + }); + + expect(res2.errors).toBeFalsy(); + + // Verify two separate referrals were created + const referrals = await con + .getRepository(UserReferralLinkedin) + .find({ where: { userId: '1' } }); + + expect(referrals).toHaveLength(2); + expect(referrals).toMatchObject([ + { userId: '1', externalUserId: 'user-one' }, + { userId: '1', externalUserId: 'user-two' }, + ]); + expect(res1.data.userReferralRecruiter).toMatchObject({ + url: `https://diy.dev/${referrals[0]?.id}`, + }); + expect(res2.data.userReferralRecruiter).toMatchObject({ + url: `https://diy.dev/${referrals[1]?.id}`, + }); + }); + + it('should allow different users to refer the same external user', async () => { + loggedUser = '1'; + + // User 1 creates a referral + const res1 = await client.query(USER_REFERRAL_RECRUITER_QUERY, { + variables: { toReferExternalId: 'same-external-user' }, + }); + + expect(res1.errors).toBeFalsy(); + + // Switch to user 2 + loggedUser = '2'; + + // User 2 creates a referral for the same external user + const res2 = await client.query(USER_REFERRAL_RECRUITER_QUERY, { + variables: { toReferExternalId: 'same-external-user' }, + }); + + expect(res2.errors).toBeFalsy(); + + // Verify two separate referrals were created (one per user) + const referralsUser1 = await con + .getRepository(UserReferralLinkedin) + .find({ where: { userId: '1', externalUserId: 'same-external-user' } }); + const referralsUser2 = await con + .getRepository(UserReferralLinkedin) + .find({ where: { userId: '2', externalUserId: 'same-external-user' } }); + + expect(referralsUser1).toHaveLength(1); + expect(referralsUser1).toMatchObject([ + { userId: '1', externalUserId: 'same-external-user' }, + ]); + + expect(referralsUser2).toHaveLength(1); + expect(referralsUser2).toMatchObject([ + { userId: '2', externalUserId: 'same-external-user' }, + ]); + }); + + it('should return short URL in correct format', async () => { + loggedUser = '1'; + + const res = await client.query(USER_REFERRAL_RECRUITER_QUERY, { + variables: { toReferExternalId: 'test-user' }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.userReferralRecruiter.url).toMatch(/^https:\/\/diy\.dev\//); + }); +}); diff --git a/src/common/njord.ts b/src/common/njord.ts index c6da184db8..0c6ec0f9bc 100644 --- a/src/common/njord.ts +++ b/src/common/njord.ts @@ -60,6 +60,7 @@ import { Message } from '@bufbuild/protobuf'; import { ensureSourcePermissions } from '../schema/sources'; import { SourceMemberRoles } from '../roles'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { usdToCores } from './number'; const transport = createGrpcTransport({ baseUrl: process.env.NJORD_ORIGIN, @@ -1056,7 +1057,7 @@ export const throwUserTransactionError = async ({ error, transaction, }: { - ctx: AuthContext; + ctx: Pick; entityManager: EntityManager; error: TransferError; transaction: UserTransaction; @@ -1096,3 +1097,48 @@ export const throwUserTransactionError = async ({ // throw error for client after saving the transaction in error state throw userTransactionError; }; + +export const awardReferral = async ({ + id, + ctx, +}: { + id: string; + ctx: Pick; +}) => { + await ctx.con.transaction(async (entityManager) => { + const transaction = await entityManager.getRepository(UserTransaction).save( + entityManager.getRepository(UserTransaction).create({ + id: randomUUID(), + processor: UserTransactionProcessor.Njord, + receiverId: ctx.userId, + status: UserTransactionStatus.Success, + productId: null, + senderId: systemUser.id, + value: usdToCores(10), + valueIncFees: 0, + fee: 0, + flags: { note: 'Linkedin recruiter referral' }, + referenceId: id, + referenceType: UserTransactionType.ReferralLinkedin, + }), + ); + + try { + await transferCores({ + ctx, + transaction, + entityManager, + }); + } catch (error) { + if (error instanceof TransferError) { + await throwUserTransactionError({ + ctx, + transaction, + entityManager, + error, + }); + } + throw error; + } + }); +}; diff --git a/src/common/utils.ts b/src/common/utils.ts index d216b5f8a6..05eeea93fb 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import { startOfISOWeek, endOfISOWeek } from 'date-fns'; import { zonedTimeToUtc } from 'date-fns-tz'; import { snakeCase } from 'lodash'; @@ -318,3 +319,9 @@ export const textToSlug = (text: string): string => locale: 'en', replacement: '-', }).substring(0, 100); + +export const hmacHashIP = (ip: string): string => + crypto + .createHmac('sha256', process.env.HMAC_SECRET) + .update(ip, 'utf-8') + .digest('hex'); diff --git a/src/entity/user/UserTransaction.ts b/src/entity/user/UserTransaction.ts index 196b7d13c9..5bcb4de351 100644 --- a/src/entity/user/UserTransaction.ts +++ b/src/entity/user/UserTransaction.ts @@ -52,6 +52,7 @@ export enum UserTransactionType { Post = 'post', Comment = 'comment', BriefGeneration = 'brief_generation', + ReferralLinkedin = 'referral_linkedin', } @Entity() diff --git a/src/entity/user/referral/UserReferral.ts b/src/entity/user/referral/UserReferral.ts new file mode 100644 index 0000000000..a1b1948656 --- /dev/null +++ b/src/entity/user/referral/UserReferral.ts @@ -0,0 +1,66 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + TableInheritance, + UpdateDateColumn, +} from 'typeorm'; +import type { User } from '../User'; + +export enum UserReferralType { + Linkedin = 'linkedin', +} + +export enum UserReferralStatus { + Pending = 'pending', + Rejected = 'rejected', + Accepted = 'accepted', +} + +export type UserReferralFlags = Partial<{ + linkedinProfileUrl?: string; + hashedRequestIP?: string; // Hashed IP address from which the referral link was requested +}>; + +@Entity() +@TableInheritance({ column: { type: 'text', name: 'type' } }) +@Index('IDX_user_referral_id_type_visited', ['id', 'type', 'visited']) +export class UserReferral { + @PrimaryGeneratedColumn('uuid', { + primaryKeyConstraintName: 'PK_user_referral_id', + }) + id: string; + + @Column({ + type: 'text', + }) + userId: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({ type: 'text' }) + @Index('IDX_user_referral_type') + type: UserReferralType; + + @Column({ type: 'text', default: UserReferralStatus.Pending }) + @Index('IDX_user_referral_status') + status: UserReferralStatus = UserReferralStatus.Pending; + + @Column({ type: 'boolean', default: false }) + visited: boolean = false; + + @Column({ type: 'jsonb', default: {} }) + flags: UserReferralFlags = {}; + + @ManyToOne('User', { lazy: true, onDelete: 'CASCADE' }) + @JoinColumn({ foreignKeyConstraintName: 'FK_user_referral_user_userId' }) + user: Promise; +} diff --git a/src/entity/user/referral/UserReferralLinkedin.ts b/src/entity/user/referral/UserReferralLinkedin.ts new file mode 100644 index 0000000000..05910ab551 --- /dev/null +++ b/src/entity/user/referral/UserReferralLinkedin.ts @@ -0,0 +1,11 @@ +import { ChildEntity, Column, Index } from 'typeorm'; +import { UserReferral, UserReferralType } from './UserReferral'; + +@ChildEntity(UserReferralType.Linkedin) +@Index('IDX_user_referral_userId_externalUserId_unique_nonempty', { + synchronize: false, +}) +export class UserReferralLinkedin extends UserReferral { + @Column({ type: 'text' }) + externalUserId: string; +} diff --git a/src/migration/1761123042645-UserReferral.ts b/src/migration/1761123042645-UserReferral.ts new file mode 100644 index 0000000000..a8f3bdff61 --- /dev/null +++ b/src/migration/1761123042645-UserReferral.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserReferral1761123042645 implements MigrationInterface { + name = "UserReferral1761123042645"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + CREATE TABLE "user_referral" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "userId" character varying NOT NULL, + "externalUserId" text, + "type" text NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + "status" text NOT NULL DEFAULT 'pending', + "visited" boolean NOT NULL DEFAULT false, + "flags" jsonb NOT NULL DEFAULT '{}', + CONSTRAINT "PK_user_referral_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_user_referral_user_userId" + FOREIGN KEY ("userId") + REFERENCES "user"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION + ) + `); + + await queryRunner.query(/* sql */ ` + ALTER TABLE "public"."user_referral" REPLICA IDENTITY FULL + `); + + await queryRunner.query(/* sql */ ` + CREATE INDEX IF NOT EXISTS "IDX_user_referral_type" + ON "user_referral" ("type") + `); + + await queryRunner.query(/* sql */ ` + CREATE INDEX IF NOT EXISTS "IDX_user_referral_status" + ON "user_referral" ("status") + `); + + await queryRunner.query(/* sql */ ` + CREATE INDEX IF NOT EXISTS "IDX_user_referral_id_type_visited" + ON "user_referral" ("id", "type", "visited") + `); + + await queryRunner.query(/* sql */ ` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_user_referral_userId_externalUserId_unique_nonempty" + ON "user_referral" ("userId", "externalUserId") + WHERE "externalUserId" IS NOT NULL + AND "externalUserId" <> '' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + DROP TABLE "user_referral" + `); + } +} diff --git a/src/routes/r/recruiter.ts b/src/routes/r/recruiter.ts new file mode 100644 index 0000000000..8ce830de61 --- /dev/null +++ b/src/routes/r/recruiter.ts @@ -0,0 +1,128 @@ +import { z } from 'zod'; +import type { FastifyInstance } from 'fastify'; +import { UserReferralStatus } from '../../entity/user/referral/UserReferral'; +import { isNullOrUndefined } from '../../common/object'; +import { UserReferralLinkedin } from '../../entity/user/referral/UserReferralLinkedin'; +import { JsonContains, Not } from 'typeorm'; +import { hmacHashIP } from '../../common'; +import createOrGetConnection from '../../db'; +import { logger } from '../../logger'; + +declare module 'fastify' { + interface FastifyRequest { + referral: UserReferralLinkedin | null; + } +} + +export const BASE_RECRUITER_URL = + 'https://recruiter.daily.dev/?utm_source=redirector&utm_medium=linkedin&utm_campaign=referral'; + +export const recruiterRedirector = async ( + fastify: FastifyInstance, +): Promise => { + fastify.decorateRequest('con'); + fastify.decorateRequest('referral'); + + fastify.addHook('onResponse', async (req) => { + if (!req.referral) { + logger.debug( + 'No referral found on request, skipping recruiter redirector', + ); + return; + } + + if ( + req.referral.status !== UserReferralStatus.Pending || + req.referral.visited + ) { + logger.debug( + { + referralId: req.referral.id, + status: req.referral.status, + visited: req.referral.visited, + }, + 'Referral is not pending or has been visited, skipping recruiter redirector', + ); + return; + } + + if (req.userId) { + logger.debug( + { referralId: req.referral.id }, + 'User is logged in, skipping recruiter redirector', + ); + return; + } + + const referrer = req.headers['referer']; + if (isNullOrUndefined(referrer)) { + logger.debug( + { referralId: req.referral.id }, + 'No referrer provided, skipping recruiter redirector', + ); + return; + } + + if (referrer.startsWith('https://www.linkedin.com/') === false) { + logger.debug( + { referralId: req.referral.id, referrer }, + 'Referrer is not linkedin, skipping recruiter redirector', + ); + return; + } + + try { + const result = await req.con?.getRepository(UserReferralLinkedin).update( + { + id: req.referral.id, + status: UserReferralStatus.Pending, + visited: false, + flags: Not(JsonContains({ hashedRequestIP: hmacHashIP(req.ip) })), + }, + { visited: true }, + ); + + if (result?.affected === 0) { + logger.debug( + { referralId: req.referral.id }, + `No referral found or referral already marked as visited`, + ); + return; + } + + logger.debug( + { referralId: req.referral.id }, + 'Marked referral as visited', + ); + } catch (_err) { + const err = _err as Error; + logger.error( + { err, referralId: req.referral.id }, + 'Failed to mark referral as visited', + ); + } + }); + + fastify.addHook<{ Params: { id: string } }>('preHandler', async (req) => { + const { error, data: id } = z.uuidv4().safeParse(req.params.id); + if (error) { + logger.debug( + { referralId: req.params.id }, + 'Invalid referral id provided, skipping recruiter redirector', + ); + return; + } + req.con = await createOrGetConnection(); + req.referral = await req.con.getRepository(UserReferralLinkedin).findOne({ + where: { id: id }, + }); + }); + + fastify.get<{ Params: { id: string } }>('/:id', (req, res) => { + const url = new URL(BASE_RECRUITER_URL); + if (req.referral) { + url.searchParams.append('utm_content', req.referral.userId); + } + return res.redirect(url.toString()); + }); +}; diff --git a/src/routes/redirector.ts b/src/routes/redirector.ts index c261792485..00b667330c 100644 --- a/src/routes/redirector.ts +++ b/src/routes/redirector.ts @@ -3,6 +3,8 @@ import { FastifyInstance } from 'fastify'; import { ArticlePost, Post } from '../entity'; import { getDiscussionLink, notifyView } from '../common'; import createOrGetConnection from '../db'; +import { logger } from '../logger'; +import { recruiterRedirector } from './r/recruiter'; export default async function (fastify: FastifyInstance): Promise { fastify.get<{ Params: { postId: string }; Querystring: { a?: string } }>( @@ -42,7 +44,7 @@ export default async function (fastify: FastifyInstance): Promise { const userId = req.userId || req.trackingId; if (userId) { notifyView( - req.log, + logger, post.id, userId, req.headers['referer'], @@ -63,4 +65,6 @@ export default async function (fastify: FastifyInstance): Promise { ); }, ); + + fastify.register(recruiterRedirector, { prefix: '/recruiter' }); } diff --git a/src/schema/profile.ts b/src/schema/profile.ts index c112f74591..9e51ae8f71 100644 --- a/src/schema/profile.ts +++ b/src/schema/profile.ts @@ -1,6 +1,6 @@ import { traceResolvers } from './trace'; import { type AuthContext } from '../Context'; -import { getLimit, toGQLEnum } from '../common'; +import { getLimit, getShortUrl, hmacHashIP, toGQLEnum } from '../common'; import { UserExperienceType } from '../entity/user/experiences/types'; import type z from 'zod'; import { @@ -26,6 +26,7 @@ import { getNonExistingSkills, insertOrIgnoreUserExperienceSkills, } from '../entity/user/experiences/UserExperienceSkill'; +import { UserReferralLinkedin } from '../entity/user/referral/UserReferralLinkedin'; interface GQLUserExperience { id: string; @@ -101,6 +102,10 @@ export const typeDefs = /* GraphQL */ ` cursor: String! } + type RecruiterReferral { + url: String! + } + extend type Query { userExperiences( userId: ID! @@ -115,6 +120,7 @@ export const typeDefs = /* GraphQL */ ` first: Int ): UserExperienceConnection! userExperienceById(id: ID!): UserExperience + userReferralRecruiter(toReferExternalId: String!): RecruiterReferral! @auth } input UserGeneralExperienceInput { @@ -222,8 +228,53 @@ const getUserExperience = ( true, ); +// These values are something that could come from growthbook for experimentations +const baseRecruiterUrl = `${process.env.URL_PREFIX}/r/recruiter`; + +const getLinkedinProfileUrl = (id: string) => + `https://www.linkedin.com/in/${id}`; + export const resolvers = traceResolvers({ Query: { + userReferralRecruiter: async ( + _, + { toReferExternalId }: { toReferExternalId: string }, + ctx, + ) => { + const referral = await ctx.con + .getRepository(UserReferralLinkedin) + .findOne({ + where: { userId: ctx.userId, externalUserId: toReferExternalId }, + }); + + if (referral) { + const url = `${baseRecruiterUrl}/${referral.id}`; + const result = await getShortUrl(url, ctx.log); + + return { + url: result, + }; + } + + const newReferral = ctx.con.getRepository(UserReferralLinkedin).create({ + userId: ctx.userId, + externalUserId: toReferExternalId, + flags: { + linkedinProfileUrl: getLinkedinProfileUrl(toReferExternalId), + hashedRequestIP: hmacHashIP(ctx.req.ip), + }, + }); + + const saved = await ctx.con + .getRepository(UserReferralLinkedin) + .save(newReferral); + const url = `${baseRecruiterUrl}/${saved.id}`; + const result = await getShortUrl(url, ctx.log); + + return { + url: result, + }; + }, userExperiences: async ( _, args: z.infer, diff --git a/src/types.ts b/src/types.ts index 1ba189c024..1e66bb34b6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ import type { opentelemetry } from './telemetry'; import type { GarmrService } from './integrations/garmr'; import { type Client } from '@connectrpc/connect'; import type { ServiceType } from '@bufbuild/protobuf'; +import type { DataSource } from 'typeorm'; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -70,6 +71,7 @@ declare global { SKADI_ORIGIN: string; SKADI_API_ORIGIN: string; SKADI_API_ORIGIN_V2: string; + HMAC_SECRET: string; APPLE_APP_APPLE_ID: string; APPLE_APP_BUNDLE_ID: string; @@ -85,6 +87,9 @@ declare global { declare module 'fastify' { interface FastifyRequest { + // Generic + con?: DataSource | null; + // Used for auth userId?: string; roles?: Roles[]; diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 38248e6c2a..bdb5659907 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -162,6 +162,11 @@ import { PollPost } from '../../entity/posts/PollPost'; import { UserExperienceWork } from '../../entity/user/experiences/UserExperienceWork'; import { UserExperience } from '../../entity/user/experiences/UserExperience'; import { UserExperienceType } from '../../entity/user/experiences/types'; +import { + UserReferral, + UserReferralStatus, +} from '../../entity/user/referral/UserReferral'; +import { awardReferral } from '../../common/njord'; const isFreeformPostLongEnough = ( freeform: ChangeMessage, @@ -1520,6 +1525,25 @@ const onUserExperienceChange = async ( } }; +const onUserReferralChange = async ( + con: DataSource, + _: FastifyBaseLogger, + data: ChangeMessage, +) => { + if (data.payload.op !== 'u') { + return; + } + + if ( + data.payload.before!.status === UserReferralStatus.Pending && + data.payload.after!.status === UserReferralStatus.Accepted + ) { + const referral = data.payload.after!; + const ctx = { userId: referral.userId, con }; + await awardReferral({ id: referral.id, ctx }); + } +}; + const worker: Worker = { subscription: 'api-cdc', maxMessages: parseInt(process.env.CDC_WORKER_MAX_MESSAGES) || undefined, @@ -1654,6 +1678,9 @@ const worker: Worker = { case getTableName(con, UserExperience): await onUserExperienceChange(con, logger, data); break; + case getTableName(con, UserReferral): + await onUserReferralChange(con, logger, data); + break; } } catch (err) { logger.error(