Skip to content

Commit 383c718

Browse files
committed
feat: create organization if missing on opportunity draft
1 parent 39de1b4 commit 383c718

File tree

5 files changed

+299
-23
lines changed

5 files changed

+299
-23
lines changed

__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/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 })
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class OrganizationNameUnique1764600895705 implements MigrationInterface {
4+
name = 'OrganizationNameUnique1764600895705';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`CREATE UNIQUE INDEX "IDX_organization_name_unique" ON "organization" ("name") `,
9+
);
10+
}
11+
12+
public async down(queryRunner: QueryRunner): Promise<void> {
13+
await queryRunner.query(
14+
`DROP INDEX "public"."IDX_organization_name_unique"`,
15+
);
16+
}
17+
}

src/schema/opportunity.ts

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ import {
3737
import { OpportunityJob } from '../entity/opportunities/OpportunityJob';
3838
import { OpportunityUserRecruiter } from '../entity/opportunities/user/OpportunityUserRecruiter';
3939
import { ForbiddenError, ValidationError } from 'apollo-server-errors';
40-
import { ConflictError, NotFoundError } from '../errors';
40+
import {
41+
ConflictError,
42+
NotFoundError,
43+
TypeOrmError,
44+
type TypeORMQueryFailedError,
45+
} from '../errors';
4146
import { UserCandidateKeyword } from '../entity/user/UserCandidateKeyword';
4247
import { User } from '../entity/user/User';
4348
import {
@@ -66,7 +71,7 @@ import {
6671
} from '../common/opportunity/accessControl';
6772
import { markdown } from '../common/markdown';
6873
import { QuestionScreening } from '../entity/questions/QuestionScreening';
69-
import { In, Not, type DeepPartial } from 'typeorm';
74+
import { In, Not, QueryFailedError, type DeepPartial } from 'typeorm';
7075
import { Organization } from '../entity/Organization';
7176
import {
7277
OrganizationLinkType,
@@ -207,7 +212,7 @@ export const typeDefs = /* GraphQL */ `
207212
content: OpportunityContent!
208213
meta: OpportunityMeta!
209214
location: [Location]!
210-
organization: Organization!
215+
organization: Organization
211216
recruiters: [User!]!
212217
keywords: [OpportunityKeyword]!
213218
questions: [OpportunityScreeningQuestion]!
@@ -429,6 +434,7 @@ export const typeDefs = /* GraphQL */ `
429434
}
430435
431436
input OrganizationEditInput {
437+
name: String
432438
website: String
433439
description: String
434440
perks: [String!]
@@ -1344,31 +1350,71 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
13441350
select: ['organizationId'],
13451351
});
13461352

1347-
if (opportunityJob?.organizationId) {
1348-
const organizationUpdate: Record<string, unknown> = {
1349-
...organization,
1350-
};
1353+
let organizationId = opportunityJob?.organizationId;
13511354

1352-
// Handle image upload
1353-
if (organizationImage) {
1354-
const { createReadStream } = await organizationImage;
1355-
const stream = createReadStream();
1356-
const { url: imageUrl } = await uploadOrganizationImage(
1357-
opportunityJob.organizationId,
1358-
stream,
1359-
);
1360-
organizationUpdate.image = imageUrl;
1361-
}
1355+
let organizationUpdate: Record<string, unknown> = {
1356+
...organization,
1357+
};
13621358

1363-
if (Object.keys(organizationUpdate).length > 0) {
1364-
await entityManager
1359+
if (organizationId) {
1360+
delete organizationUpdate.name; // prevent name updates on existing organizations
1361+
}
1362+
1363+
if (!organizationId) {
1364+
// create new organization and assign to opportunity here inline
1365+
// TODO: ideally this should be refactored later to separate mutation
1366+
1367+
try {
1368+
const organizationInsertResult = await entityManager
13651369
.getRepository(Organization)
1366-
.update(
1367-
{ id: opportunityJob.organizationId },
1368-
organizationUpdate,
1369-
);
1370+
.insert(organizationUpdate);
1371+
1372+
organizationId = organizationInsertResult.identifiers[0]
1373+
.id as string;
1374+
1375+
await entityManager
1376+
.getRepository(OpportunityJob)
1377+
.update({ id }, { organizationId });
1378+
1379+
// values were applied during insert
1380+
organizationUpdate = {};
1381+
} catch (insertError) {
1382+
if (insertError instanceof QueryFailedError) {
1383+
const queryFailedError = insertError as TypeORMQueryFailedError;
1384+
1385+
if (queryFailedError.code === TypeOrmError.DUPLICATE_ENTRY) {
1386+
if (
1387+
insertError.message.indexOf(
1388+
'IDX_organization_name_unique',
1389+
) > -1
1390+
) {
1391+
throw new ConflictError(
1392+
'Organization with this name already exists',
1393+
);
1394+
}
1395+
}
1396+
}
1397+
1398+
throw insertError;
13701399
}
13711400
}
1401+
1402+
// Handle image upload
1403+
if (organizationImage) {
1404+
const { createReadStream } = await organizationImage;
1405+
const stream = createReadStream();
1406+
const { url: imageUrl } = await uploadOrganizationImage(
1407+
organizationId,
1408+
stream,
1409+
);
1410+
organizationUpdate.image = imageUrl;
1411+
}
1412+
1413+
if (Object.keys(organizationUpdate).length > 0) {
1414+
await entityManager
1415+
.getRepository(Organization)
1416+
.update({ id: organizationId }, organizationUpdate);
1417+
}
13721418
}
13731419

13741420
if (Array.isArray(keywords)) {

0 commit comments

Comments
 (0)