Skip to content

Commit dc4cdd3

Browse files
authored
feat: claim anon opportunities (#3316)
1 parent 8fb81dd commit dc4cdd3

9 files changed

Lines changed: 450 additions & 24 deletions

File tree

__tests__/private.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import { SubscriptionCycles } from '../src/paddle';
2626
import { SubscriptionStatus } from '../src/common/plus';
2727
import { OpportunityJob } from '../src/entity/opportunities/OpportunityJob';
2828
import { OpportunityKeyword } from '../src/entity/OpportunityKeyword';
29+
import { OpportunityState } from '@dailydotdev/schema';
30+
import { generateShortId } from '../src/ids';
31+
import { OpportunityUser } from '../src/entity/opportunities/user';
32+
import { OpportunityUserType } from '../src/entity/opportunities/types';
2933

3034
jest.mock('../src/common/geo', () => ({
3135
...(jest.requireActual('../src/common/geo') as Record<string, unknown>),
@@ -779,6 +783,53 @@ describe('POST /p/newUser', () => {
779783
expect(body.status).toEqual('ok');
780784
expect(body.userId).not.toEqual(deletedUser.id);
781785
});
786+
787+
it('should claim opportunities that user created as anonymous', async () => {
788+
const anonUserId = await generateShortId();
789+
790+
const opportunity = await con.getRepository(OpportunityJob).save(
791+
con.getRepository(OpportunityJob).create({
792+
title: 'Test',
793+
tldr: 'Test',
794+
state: OpportunityState.DRAFT,
795+
flags: {
796+
anonUserId,
797+
},
798+
}),
799+
);
800+
801+
const { body } = await request(app.server)
802+
.post('/p/newUser')
803+
.set('Content-type', 'application/json')
804+
.set('authorization', `Service ${process.env.ACCESS_SECRET}`)
805+
.send({
806+
id: opportunity.flags.anonUserId,
807+
name: anonUserId,
808+
image: usersFixture[0].image,
809+
username: anonUserId,
810+
email: `test+${anonUserId}@gmail.com`,
811+
experienceLevel: 'LESS_THAN_1_YEAR',
812+
})
813+
.expect(200);
814+
815+
expect(body.status).toEqual('ok');
816+
expect(body.userId).toEqual(anonUserId);
817+
818+
const updatedOpportunity = await con
819+
.getRepository(OpportunityJob)
820+
.findOneBy({ id: opportunity.id });
821+
expect(updatedOpportunity!.flags.anonUserId).toBeNull();
822+
823+
const opportunityUser = await con.getRepository(OpportunityUser).findOneBy({
824+
opportunityId: opportunity.id,
825+
userId: anonUserId,
826+
});
827+
expect(opportunityUser).toEqual({
828+
opportunityId: opportunity.id,
829+
userId: anonUserId,
830+
type: OpportunityUserType.Recruiter,
831+
});
832+
});
782833
});
783834

784835
describe('POST /p/checkUsername', () => {

__tests__/schema/opportunity.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import * as gondulModule from '../../src/common/gondul';
7373
import type { ServiceClient } from '../../src/types';
7474
import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob';
7575
import * as brokkrCommon from '../../src/common/brokkr';
76+
import { randomUUID } from 'node:crypto';
7677

7778
const deleteFileFromBucket = jest.spyOn(googleCloud, 'deleteFileFromBucket');
7879
const uploadEmploymentAgreementFromBuffer = jest.spyOn(
@@ -4319,6 +4320,216 @@ describe('mutation editOpportunity', () => {
43194320
expect(userAfter?.title).toBe('Updated Title Only');
43204321
expect(userAfter?.bio).toBe('Initial bio that should remain');
43214322
});
4323+
4324+
it('should create organization for opportunity if missing', async () => {
4325+
loggedUser = '1';
4326+
4327+
const MUTATION_WITH_ORG = /* GraphQL */ `
4328+
mutation EditOpportunityWithOrg(
4329+
$id: ID!
4330+
$payload: OpportunityEditInput!
4331+
) {
4332+
editOpportunity(id: $id, payload: $payload) {
4333+
id
4334+
organization {
4335+
id
4336+
name
4337+
website
4338+
description
4339+
perks
4340+
founded
4341+
location
4342+
category
4343+
size
4344+
stage
4345+
}
4346+
}
4347+
}
4348+
`;
4349+
4350+
const opportunityWithoutOrganization = await con
4351+
.getRepository(OpportunityJob)
4352+
.save({
4353+
...opportunitiesFixture[0],
4354+
id: randomUUID(),
4355+
state: OpportunityState.DRAFT,
4356+
organizationId: null,
4357+
});
4358+
4359+
await con.getRepository(OpportunityUser).save({
4360+
opportunityId: opportunityWithoutOrganization.id,
4361+
userId: loggedUser,
4362+
type: OpportunityUserType.Recruiter,
4363+
});
4364+
4365+
const organizationBefore = await con.getRepository(Organization).findOne({
4366+
where: {
4367+
name: 'Test Corp',
4368+
},
4369+
});
4370+
4371+
expect(organizationBefore).toBeNull();
4372+
4373+
const res = await client.mutate(MUTATION_WITH_ORG, {
4374+
variables: {
4375+
id: opportunityWithoutOrganization.id,
4376+
payload: {
4377+
organization: {
4378+
name: 'Test Corp',
4379+
website: 'https://updated.dev',
4380+
description: 'Updated description',
4381+
perks: ['Remote work', 'Flexible hours'],
4382+
founded: 2021,
4383+
location: 'Berlin, Germany',
4384+
category: 'Technology',
4385+
size: CompanySize.COMPANY_SIZE_51_200,
4386+
stage: CompanyStage.SERIES_B,
4387+
},
4388+
},
4389+
},
4390+
});
4391+
4392+
expect(res.errors).toBeFalsy();
4393+
expect(res.data.editOpportunity.organization).toMatchObject({
4394+
name: 'Test Corp',
4395+
website: 'https://updated.dev',
4396+
description: 'Updated description',
4397+
perks: ['Remote work', 'Flexible hours'],
4398+
founded: 2021,
4399+
location: 'Berlin, Germany',
4400+
category: 'Technology',
4401+
size: CompanySize.COMPANY_SIZE_51_200,
4402+
stage: CompanyStage.SERIES_B,
4403+
});
4404+
4405+
// Verify the organization was created in database
4406+
const organization = await con
4407+
.getRepository(Organization)
4408+
.findOneBy({ id: res.data.editOpportunity.organization.id });
4409+
4410+
expect(organization).toMatchObject({
4411+
name: 'Test Corp',
4412+
website: 'https://updated.dev',
4413+
description: 'Updated description',
4414+
perks: ['Remote work', 'Flexible hours'],
4415+
founded: 2021,
4416+
location: 'Berlin, Germany',
4417+
category: 'Technology',
4418+
size: CompanySize.COMPANY_SIZE_51_200,
4419+
stage: CompanyStage.SERIES_B,
4420+
});
4421+
4422+
const opportunityAfter = await con
4423+
.getRepository(OpportunityJob)
4424+
.findOneBy({ id: opportunityWithoutOrganization.id });
4425+
4426+
expect(opportunityAfter!.organizationId).toBe(
4427+
res.data.editOpportunity.organization.id,
4428+
);
4429+
});
4430+
4431+
it('should not update organization name on edit', async () => {
4432+
loggedUser = '1';
4433+
4434+
const MUTATION_WITH_ORG = /* GraphQL */ `
4435+
mutation EditOpportunityWithOrg(
4436+
$id: ID!
4437+
$payload: OpportunityEditInput!
4438+
) {
4439+
editOpportunity(id: $id, payload: $payload) {
4440+
id
4441+
organization {
4442+
id
4443+
name
4444+
}
4445+
}
4446+
}
4447+
`;
4448+
4449+
const res = await client.mutate(MUTATION_WITH_ORG, {
4450+
variables: {
4451+
id: opportunitiesFixture[0].id,
4452+
payload: {
4453+
organization: {
4454+
name: 'Test update name',
4455+
},
4456+
},
4457+
},
4458+
});
4459+
4460+
expect(res.errors).toBeFalsy();
4461+
expect(res.data.editOpportunity.organization.name).toEqual(
4462+
organizationsFixture[0].name,
4463+
);
4464+
4465+
// Verify the organization was updated in database
4466+
const organization = await con
4467+
.getRepository(Organization)
4468+
.findOneBy({ id: organizationsFixture[0].id });
4469+
4470+
expect(organization!.name).toEqual(organizationsFixture[0].name);
4471+
});
4472+
4473+
it('should not allow duplicate organization names', async () => {
4474+
loggedUser = '1';
4475+
4476+
const MUTATION_WITH_ORG = /* GraphQL */ `
4477+
mutation EditOpportunityWithOrg(
4478+
$id: ID!
4479+
$payload: OpportunityEditInput!
4480+
) {
4481+
editOpportunity(id: $id, payload: $payload) {
4482+
id
4483+
organization {
4484+
id
4485+
name
4486+
}
4487+
}
4488+
}
4489+
`;
4490+
4491+
const opportunityWithoutOrganization = await con
4492+
.getRepository(OpportunityJob)
4493+
.save({
4494+
...opportunitiesFixture[0],
4495+
id: randomUUID(),
4496+
state: OpportunityState.DRAFT,
4497+
organizationId: null,
4498+
});
4499+
4500+
await con.getRepository(OpportunityUser).save({
4501+
opportunityId: opportunityWithoutOrganization.id,
4502+
userId: loggedUser,
4503+
type: OpportunityUserType.Recruiter,
4504+
});
4505+
4506+
const organizationBefore = await con.getRepository(Organization).findOne({
4507+
where: {
4508+
name: 'Daily Dev Inc',
4509+
},
4510+
});
4511+
4512+
expect(organizationBefore).not.toBeNull();
4513+
4514+
const res = await client.mutate(MUTATION_WITH_ORG, {
4515+
variables: {
4516+
id: opportunityWithoutOrganization.id,
4517+
payload: {
4518+
organization: {
4519+
name: 'Daily Dev Inc',
4520+
founded: 2021,
4521+
},
4522+
},
4523+
},
4524+
});
4525+
4526+
expect(res.errors).toBeTruthy();
4527+
4528+
expect(res.errors![0].extensions.code).toEqual('CONFLICT');
4529+
expect(res.errors![0].message).toEqual(
4530+
'Organization with this name already exists',
4531+
);
4532+
});
43224533
});
43234534

43244535
describe('mutation clearOrganizationImage', () => {

src/common/opportunity/user.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { IsNull, type EntityManager } from 'typeorm';
2+
import { OpportunityJob } from '../../entity/opportunities/OpportunityJob';
3+
import { updateFlagsStatement } from '../utils';
4+
import type { Opportunity } from '../../entity/opportunities/Opportunity';
5+
import { OpportunityUserRecruiter } from '../../entity/opportunities/user/OpportunityUserRecruiter';
6+
import { logger } from '../../logger';
7+
8+
export const claimAnonOpportunities = async ({
9+
anonUserId,
10+
userId,
11+
con,
12+
}: {
13+
anonUserId: string;
14+
userId: string;
15+
con: EntityManager;
16+
}): Promise<Pick<Opportunity, 'id'>[]> => {
17+
try {
18+
if (!anonUserId || !userId) {
19+
throw new Error('anonUserId and userId are required');
20+
}
21+
22+
const result = await con.transaction(async (entityManager) => {
23+
const opportunityUpdateResult = await entityManager
24+
.getRepository(OpportunityJob)
25+
.createQueryBuilder()
26+
.update()
27+
.set({
28+
flags: updateFlagsStatement<OpportunityJob>({
29+
anonUserId: null,
30+
}),
31+
})
32+
.where("flags->>'anonUserId' = :anonUserId", {
33+
anonUserId,
34+
})
35+
.andWhere({
36+
organizationId: IsNull(), // only claim opportunities not linked to an organization yet
37+
})
38+
.returning(['id'])
39+
.execute();
40+
41+
const opportunities = opportunityUpdateResult.raw as { id: string }[];
42+
43+
const opportunityUserUpsertResult = await entityManager
44+
.getRepository(OpportunityUserRecruiter)
45+
.upsert(
46+
opportunities.map((opportunity) => {
47+
return entityManager
48+
.getRepository(OpportunityUserRecruiter)
49+
.create({
50+
opportunityId: opportunity.id,
51+
userId,
52+
});
53+
}),
54+
{
55+
conflictPaths: ['opportunityId', 'userId'],
56+
},
57+
);
58+
59+
return opportunityUserUpsertResult.identifiers.map((item) => {
60+
return {
61+
id: item.opportunityId,
62+
};
63+
});
64+
});
65+
66+
return result;
67+
} catch (error) {
68+
logger.error(
69+
{
70+
err: error,
71+
anonUserId,
72+
userId,
73+
},
74+
'Error claiming anon opportunities',
75+
);
76+
77+
return [];
78+
}
79+
};

src/common/schema/opportunities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export const opportunityEditSchema = z
183183
.min(1)
184184
.max(3),
185185
organization: z.object({
186+
name: z.string().nonempty().max(60).optional(),
186187
website: z.string().max(500).nullable().optional(),
187188
description: z.string().max(2000).nullable().optional(),
188189
perks: z.array(z.string().max(240)).max(50).nullable().optional(),

src/entity/Organization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class Organization {
3434
updatedAt: Date;
3535

3636
@Column({ type: 'text' })
37+
@Index('IDX_organization_name_unique', { unique: true })
3738
name: string;
3839

3940
@Column({ type: 'text', nullable: true })

src/entity/opportunities/Opportunity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type { QuestionScreening } from '../questions/QuestionScreening';
2121
import type { QuestionFeedback } from '../questions/QuestionFeedback';
2222

2323
export type OpportunityFlags = Partial<{
24-
anonUserId: string;
24+
anonUserId: string | null;
2525
}>;
2626

2727
@Entity()

0 commit comments

Comments
 (0)