Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .infra/Pulumi.adhoc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .infra/Pulumi.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .infra/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
222 changes: 220 additions & 2 deletions __tests__/redirector.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>),
Expand Down Expand Up @@ -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<UserReferralLinkedin>) => {
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);
});
});
48 changes: 47 additions & 1 deletion src/common/njord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1056,7 +1057,7 @@ export const throwUserTransactionError = async ({
error,
transaction,
}: {
ctx: AuthContext;
ctx: Pick<AuthContext, 'userId' | 'con'>;
entityManager: EntityManager;
error: TransferError;
transaction: UserTransaction;
Expand Down Expand Up @@ -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<AuthContext, 'userId' | 'con'>;
}) => {
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;
}
});
};
7 changes: 7 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
1 change: 1 addition & 0 deletions src/entity/user/UserTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export enum UserTransactionType {
Post = 'post',
Comment = 'comment',
BriefGeneration = 'brief_generation',
ReferralLinkedin = 'referral_linkedin',
}

@Entity()
Expand Down
Loading