Skip to content
Merged
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
51 changes: 51 additions & 0 deletions __tests__/private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import { SubscriptionCycles } from '../src/paddle';
import { SubscriptionStatus } from '../src/common/plus';
import { OpportunityJob } from '../src/entity/opportunities/OpportunityJob';
import { OpportunityKeyword } from '../src/entity/OpportunityKeyword';
import { OpportunityState } from '@dailydotdev/schema';
import { generateShortId } from '../src/ids';
import { OpportunityUser } from '../src/entity/opportunities/user';
import { OpportunityUserType } from '../src/entity/opportunities/types';

jest.mock('../src/common/geo', () => ({
...(jest.requireActual('../src/common/geo') as Record<string, unknown>),
Expand Down Expand Up @@ -779,6 +783,53 @@ describe('POST /p/newUser', () => {
expect(body.status).toEqual('ok');
expect(body.userId).not.toEqual(deletedUser.id);
});

it('should claim opportunities that user created as anonymous', async () => {
const anonUserId = await generateShortId();

const opportunity = await con.getRepository(OpportunityJob).save(
con.getRepository(OpportunityJob).create({
title: 'Test',
tldr: 'Test',
state: OpportunityState.DRAFT,
flags: {
anonUserId,
},
}),
);

const { body } = await request(app.server)
.post('/p/newUser')
.set('Content-type', 'application/json')
.set('authorization', `Service ${process.env.ACCESS_SECRET}`)
.send({
id: opportunity.flags.anonUserId,
name: anonUserId,
image: usersFixture[0].image,
username: anonUserId,
email: `test+${anonUserId}@gmail.com`,
experienceLevel: 'LESS_THAN_1_YEAR',
})
.expect(200);

expect(body.status).toEqual('ok');
expect(body.userId).toEqual(anonUserId);

const updatedOpportunity = await con
.getRepository(OpportunityJob)
.findOneBy({ id: opportunity.id });
expect(updatedOpportunity!.flags.anonUserId).toBeNull();

const opportunityUser = await con.getRepository(OpportunityUser).findOneBy({
opportunityId: opportunity.id,
userId: anonUserId,
});
expect(opportunityUser).toEqual({
opportunityId: opportunity.id,
userId: anonUserId,
type: OpportunityUserType.Recruiter,
});
});
});

describe('POST /p/checkUsername', () => {
Expand Down
211 changes: 211 additions & 0 deletions __tests__/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import * as gondulModule from '../../src/common/gondul';
import type { ServiceClient } from '../../src/types';
import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob';
import * as brokkrCommon from '../../src/common/brokkr';
import { randomUUID } from 'node:crypto';

const deleteFileFromBucket = jest.spyOn(googleCloud, 'deleteFileFromBucket');
const uploadEmploymentAgreementFromBuffer = jest.spyOn(
Expand Down Expand Up @@ -4319,6 +4320,216 @@ describe('mutation editOpportunity', () => {
expect(userAfter?.title).toBe('Updated Title Only');
expect(userAfter?.bio).toBe('Initial bio that should remain');
});

it('should create organization for opportunity if missing', async () => {
loggedUser = '1';

const MUTATION_WITH_ORG = /* GraphQL */ `
mutation EditOpportunityWithOrg(
$id: ID!
$payload: OpportunityEditInput!
) {
editOpportunity(id: $id, payload: $payload) {
id
organization {
id
name
website
description
perks
founded
location
category
size
stage
}
}
}
`;

const opportunityWithoutOrganization = await con
.getRepository(OpportunityJob)
.save({
...opportunitiesFixture[0],
id: randomUUID(),
state: OpportunityState.DRAFT,
organizationId: null,
});

await con.getRepository(OpportunityUser).save({
opportunityId: opportunityWithoutOrganization.id,
userId: loggedUser,
type: OpportunityUserType.Recruiter,
});

const organizationBefore = await con.getRepository(Organization).findOne({
where: {
name: 'Test Corp',
},
});

expect(organizationBefore).toBeNull();

const res = await client.mutate(MUTATION_WITH_ORG, {
variables: {
id: opportunityWithoutOrganization.id,
payload: {
organization: {
name: 'Test Corp',
website: 'https://updated.dev',
description: 'Updated description',
perks: ['Remote work', 'Flexible hours'],
founded: 2021,
location: 'Berlin, Germany',
category: 'Technology',
size: CompanySize.COMPANY_SIZE_51_200,
stage: CompanyStage.SERIES_B,
},
},
},
});

expect(res.errors).toBeFalsy();
expect(res.data.editOpportunity.organization).toMatchObject({
name: 'Test Corp',
website: 'https://updated.dev',
description: 'Updated description',
perks: ['Remote work', 'Flexible hours'],
founded: 2021,
location: 'Berlin, Germany',
category: 'Technology',
size: CompanySize.COMPANY_SIZE_51_200,
stage: CompanyStage.SERIES_B,
});

// Verify the organization was created in database
const organization = await con
.getRepository(Organization)
.findOneBy({ id: res.data.editOpportunity.organization.id });

expect(organization).toMatchObject({
name: 'Test Corp',
website: 'https://updated.dev',
description: 'Updated description',
perks: ['Remote work', 'Flexible hours'],
founded: 2021,
location: 'Berlin, Germany',
category: 'Technology',
size: CompanySize.COMPANY_SIZE_51_200,
stage: CompanyStage.SERIES_B,
});

const opportunityAfter = await con
.getRepository(OpportunityJob)
.findOneBy({ id: opportunityWithoutOrganization.id });

expect(opportunityAfter!.organizationId).toBe(
res.data.editOpportunity.organization.id,
);
});

it('should not update organization name on edit', async () => {
loggedUser = '1';

const MUTATION_WITH_ORG = /* GraphQL */ `
mutation EditOpportunityWithOrg(
$id: ID!
$payload: OpportunityEditInput!
) {
editOpportunity(id: $id, payload: $payload) {
id
organization {
id
name
}
}
}
`;

const res = await client.mutate(MUTATION_WITH_ORG, {
variables: {
id: opportunitiesFixture[0].id,
payload: {
organization: {
name: 'Test update name',
},
},
},
});

expect(res.errors).toBeFalsy();
expect(res.data.editOpportunity.organization.name).toEqual(
organizationsFixture[0].name,
);

// Verify the organization was updated in database
const organization = await con
.getRepository(Organization)
.findOneBy({ id: organizationsFixture[0].id });

expect(organization!.name).toEqual(organizationsFixture[0].name);
});

it('should not allow duplicate organization names', async () => {
loggedUser = '1';

const MUTATION_WITH_ORG = /* GraphQL */ `
mutation EditOpportunityWithOrg(
$id: ID!
$payload: OpportunityEditInput!
) {
editOpportunity(id: $id, payload: $payload) {
id
organization {
id
name
}
}
}
`;

const opportunityWithoutOrganization = await con
.getRepository(OpportunityJob)
.save({
...opportunitiesFixture[0],
id: randomUUID(),
state: OpportunityState.DRAFT,
organizationId: null,
});

await con.getRepository(OpportunityUser).save({
opportunityId: opportunityWithoutOrganization.id,
userId: loggedUser,
type: OpportunityUserType.Recruiter,
});

const organizationBefore = await con.getRepository(Organization).findOne({
where: {
name: 'Daily Dev Inc',
},
});

expect(organizationBefore).not.toBeNull();

const res = await client.mutate(MUTATION_WITH_ORG, {
variables: {
id: opportunityWithoutOrganization.id,
payload: {
organization: {
name: 'Daily Dev Inc',
founded: 2021,
},
},
},
});

expect(res.errors).toBeTruthy();

expect(res.errors![0].extensions.code).toEqual('CONFLICT');
expect(res.errors![0].message).toEqual(
'Organization with this name already exists',
);
});
});

describe('mutation clearOrganizationImage', () => {
Expand Down
79 changes: 79 additions & 0 deletions src/common/opportunity/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { IsNull, type EntityManager } from 'typeorm';
import { OpportunityJob } from '../../entity/opportunities/OpportunityJob';
import { updateFlagsStatement } from '../utils';
import type { Opportunity } from '../../entity/opportunities/Opportunity';
import { OpportunityUserRecruiter } from '../../entity/opportunities/user/OpportunityUserRecruiter';
import { logger } from '../../logger';

export const claimAnonOpportunities = async ({
anonUserId,
userId,
con,
}: {
anonUserId: string;
userId: string;
con: EntityManager;
}): Promise<Pick<Opportunity, 'id'>[]> => {
try {
if (!anonUserId || !userId) {
throw new Error('anonUserId and userId are required');
}

const result = await con.transaction(async (entityManager) => {
const opportunityUpdateResult = await entityManager
.getRepository(OpportunityJob)
.createQueryBuilder()
.update()
.set({
flags: updateFlagsStatement<OpportunityJob>({
anonUserId: null,
}),
})
.where("flags->>'anonUserId' = :anonUserId", {
anonUserId,
})
.andWhere({
organizationId: IsNull(), // only claim opportunities not linked to an organization yet
})
.returning(['id'])
.execute();

const opportunities = opportunityUpdateResult.raw as { id: string }[];

const opportunityUserUpsertResult = await entityManager
.getRepository(OpportunityUserRecruiter)
.upsert(
opportunities.map((opportunity) => {
return entityManager
.getRepository(OpportunityUserRecruiter)
.create({
opportunityId: opportunity.id,
userId,
});
}),
{
conflictPaths: ['opportunityId', 'userId'],
},
);

return opportunityUserUpsertResult.identifiers.map((item) => {
return {
id: item.opportunityId,
};
});
});

return result;
} catch (error) {
logger.error(
{
err: error,
anonUserId,
userId,
},
'Error claiming anon opportunities',
);

return [];
}
};
1 change: 1 addition & 0 deletions src/common/schema/opportunities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export const opportunityEditSchema = z
.min(1)
.max(3),
organization: z.object({
name: z.string().nonempty().max(60).optional(),
website: z.string().max(500).nullable().optional(),
description: z.string().max(2000).nullable().optional(),
perks: z.array(z.string().max(240)).max(50).nullable().optional(),
Expand Down
1 change: 1 addition & 0 deletions src/entity/Organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class Organization {
updatedAt: Date;

@Column({ type: 'text' })
@Index('IDX_organization_name_unique', { unique: true })
Copy link
Copy Markdown
Contributor Author

@capJavert capJavert Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added so that organization name is unique to avoid people adding well known organizations that were already created.

Still anyone can add any organization so we need to be careful and monitor what gets added. Since we will be doing some manual approval before self serve goes live I am counting on that.

name: string;

@Column({ type: 'text', nullable: true })
Expand Down
2 changes: 1 addition & 1 deletion src/entity/opportunities/Opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { QuestionScreening } from '../questions/QuestionScreening';
import type { QuestionFeedback } from '../questions/QuestionFeedback';

export type OpportunityFlags = Partial<{
anonUserId: string;
anonUserId: string | null;
}>;

@Entity()
Expand Down
Loading
Loading