Skip to content

Commit bb77cee

Browse files
feat: reimport opportunity mutation to easily update an existing opp (#3393)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Ido Shamun <idoshamun@users.noreply.github.com>
1 parent eed0b98 commit bb77cee

6 files changed

Lines changed: 445 additions & 45 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ This file provides guidance to coding agents when working with code in this repo
105105
- `.infra/common.ts` - Worker subscription definitions
106106
- `.infra/index.ts` - Main Pulumi deployment configuration
107107

108+
## Best Practices & Lessons Learned
109+
110+
**Avoiding Code Duplication:**
111+
- **Always check for existing implementations** before creating new helper functions. Use Grep or Glob tools to search for similar function names or logic patterns across the codebase.
112+
- **Prefer extracting to common utilities** when logic needs to be shared. Place shared helpers in appropriate `src/common/` subdirectories (e.g., `src/common/opportunity/` for opportunity-related helpers).
113+
- **Export and import, don't duplicate**: When you need the same logic in multiple places, export the function from its original location and import it where needed. This ensures a single source of truth and prevents maintenance issues.
114+
- **Example lesson**: When implementing `handleOpportunityKeywordsUpdate`, the function was duplicated in both `src/common/opportunity/parse.ts` and `src/schema/opportunity.ts`. This caused lint failures and maintenance burden. The correct approach was to export it from `parse.ts` and import it in `opportunity.ts`.
115+
108116
## Pull Requests
109117

110118
Keep PR descriptions concise and to the point. Reviewers should not be exhausted by lengthy explanations.

__tests__/schema/opportunity.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6399,3 +6399,164 @@ describe('query opportunityStats', () => {
63996399
expect(res.errors[0].extensions.code).toBe('FORBIDDEN');
64006400
});
64016401
});
6402+
6403+
describe('mutation reimportOpportunity', () => {
6404+
const MUTATION = /* GraphQL */ `
6405+
mutation ReimportOpportunity($payload: ReimportOpportunityInput!) {
6406+
reimportOpportunity(payload: $payload) {
6407+
id
6408+
title
6409+
tldr
6410+
content {
6411+
overview {
6412+
content
6413+
}
6414+
requirements {
6415+
content
6416+
}
6417+
responsibilities {
6418+
content
6419+
}
6420+
}
6421+
keywords {
6422+
keyword
6423+
}
6424+
}
6425+
}
6426+
`;
6427+
6428+
beforeEach(async () => {
6429+
jest.resetAllMocks();
6430+
6431+
const transport = createMockBrokkrTransport();
6432+
const serviceClient = {
6433+
instance: createClient(BrokkrService, transport),
6434+
garmr: createGarmrMock(),
6435+
};
6436+
6437+
jest
6438+
.spyOn(brokkrCommon, 'getBrokkrClient')
6439+
.mockImplementation((): ServiceClient<typeof BrokkrService> => {
6440+
return serviceClient;
6441+
});
6442+
});
6443+
6444+
it('should require authentication', async () => {
6445+
loggedUser = null;
6446+
6447+
await testMutationErrorCode(
6448+
client,
6449+
{
6450+
mutation: MUTATION,
6451+
variables: {
6452+
payload: {
6453+
opportunityId: opportunitiesFixture[0].id,
6454+
url: 'https://example.com/job',
6455+
},
6456+
},
6457+
},
6458+
'UNAUTHENTICATED',
6459+
);
6460+
});
6461+
6462+
it('should require recruiter permission', async () => {
6463+
loggedUser = '3'; // User 3 is not a recruiter for opportunity 3
6464+
6465+
await testMutationErrorCode(
6466+
client,
6467+
{
6468+
mutation: MUTATION,
6469+
variables: {
6470+
payload: {
6471+
opportunityId: opportunitiesFixture[3].id,
6472+
url: 'https://example.com/job',
6473+
},
6474+
},
6475+
},
6476+
'FORBIDDEN',
6477+
);
6478+
});
6479+
6480+
it('should fail when neither file nor URL is provided', async () => {
6481+
loggedUser = '2'; // User 2 is a recruiter for opportunity 3
6482+
6483+
const res = await client.mutate(MUTATION, {
6484+
variables: {
6485+
payload: {
6486+
opportunityId: opportunitiesFixture[3].id,
6487+
},
6488+
},
6489+
});
6490+
6491+
expect(res.errors).toBeTruthy();
6492+
});
6493+
6494+
it('should reimport opportunity from URL and update all fields', async () => {
6495+
loggedUser = '2'; // User 2 is a recruiter for opportunity 3 (which is in DRAFT state)
6496+
6497+
const fetchSpy = jest.spyOn(globalThis, 'fetch');
6498+
const pdfResponse = new Response('Mocked PDF content', {
6499+
status: 200,
6500+
headers: { 'Content-Type': 'application/pdf' },
6501+
});
6502+
jest
6503+
.spyOn(pdfResponse, 'arrayBuffer')
6504+
.mockResolvedValue(new ArrayBuffer(0));
6505+
fetchSpy.mockResolvedValueOnce(pdfResponse);
6506+
6507+
fileTypeFromBuffer.mockResolvedValue({
6508+
ext: 'pdf',
6509+
mime: 'application/pdf',
6510+
});
6511+
6512+
const uploadResumeFromBufferSpy = jest.spyOn(
6513+
googleCloud,
6514+
'uploadResumeFromBuffer',
6515+
);
6516+
uploadResumeFromBufferSpy.mockResolvedValue(
6517+
`https://storage.cloud.google.com/${RESUME_BUCKET_NAME}/file`,
6518+
);
6519+
6520+
const deleteFileFromBucketSpy = jest.spyOn(
6521+
googleCloud,
6522+
'deleteFileFromBucket',
6523+
);
6524+
deleteFileFromBucketSpy.mockResolvedValue(true);
6525+
6526+
// Get original opportunity state
6527+
const originalOpportunity = await con
6528+
.getRepository(OpportunityJob)
6529+
.findOneByOrFail({ id: opportunitiesFixture[3].id });
6530+
6531+
const res = await client.mutate(MUTATION, {
6532+
variables: {
6533+
payload: {
6534+
opportunityId: opportunitiesFixture[3].id,
6535+
url: 'https://example.com/updated-job',
6536+
},
6537+
},
6538+
});
6539+
6540+
expect(res.errors).toBeFalsy();
6541+
expect(res.data.reimportOpportunity.id).toBe(opportunitiesFixture[3].id);
6542+
6543+
// Verify fields were updated with mocked Brokkr response
6544+
expect(res.data.reimportOpportunity.title).toBe('Mocked Opportunity Title');
6545+
expect(res.data.reimportOpportunity.tldr).toBe(
6546+
'This is a mocked TL;DR of the opportunity.',
6547+
);
6548+
expect(res.data.reimportOpportunity.keywords).toEqual([
6549+
{ keyword: 'mock' },
6550+
{ keyword: 'opportunity' },
6551+
{ keyword: 'test' },
6552+
]);
6553+
6554+
// Verify opportunity still exists and was updated
6555+
const updatedOpportunity = await con
6556+
.getRepository(OpportunityJob)
6557+
.findOneByOrFail({ id: opportunitiesFixture[3].id });
6558+
6559+
expect(updatedOpportunity.title).toBe('Mocked Opportunity Title');
6560+
expect(updatedOpportunity.state).toBe(originalOpportunity.state); // State should be preserved
6561+
});
6562+
});

src/common/opportunity/parse.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { OpportunityUserRecruiter } from '../../entity/opportunities/user/Opport
3030
import { findDatasetLocation } from '../../entity/dataset/utils';
3131
import { addOpportunityDefaultQuestionFeedback } from './question';
3232
import type { Opportunity } from '../../entity/opportunities/Opportunity';
33+
import { EntityManager } from 'typeorm';
3334

3435
interface FileUpload {
3536
filename: string;
@@ -357,3 +358,112 @@ export async function createOpportunityFromParsedData(
357358
return opportunity;
358359
});
359360
}
361+
362+
export interface UpdateOpportunityContext {
363+
con: DataSource;
364+
log: FastifyBaseLogger;
365+
}
366+
367+
/**
368+
* Handles opportunity keywords updates
369+
* Replaces all existing keywords with the new set
370+
*/
371+
export async function handleOpportunityKeywordsUpdate(
372+
entityManager: EntityManager,
373+
opportunityId: string,
374+
keywords: Array<{ keyword: string }> | undefined,
375+
): Promise<void> {
376+
if (!Array.isArray(keywords)) {
377+
return;
378+
}
379+
380+
await entityManager.getRepository(OpportunityKeyword).delete({
381+
opportunityId,
382+
});
383+
384+
await entityManager.getRepository(OpportunityKeyword).insert(
385+
keywords.map((keyword) => ({
386+
opportunityId,
387+
keyword: keyword.keyword,
388+
})),
389+
);
390+
}
391+
392+
/**
393+
* Updates an existing opportunity with all parsed data.
394+
*
395+
* @param ctx - Context with database connection and logger
396+
* @param opportunityId - ID of the opportunity to update
397+
* @param parsedData - The parsed opportunity data from Brokkr
398+
* @returns The opportunity ID
399+
*/
400+
export async function updateOpportunityFromParsedData(
401+
ctx: UpdateOpportunityContext,
402+
opportunityId: string,
403+
parsedData: ParsedOpportunityResult,
404+
): Promise<string> {
405+
const { opportunity: parsedOpportunity, content } = parsedData;
406+
407+
return ctx.con.transaction(async (entityManager) => {
408+
// Fetch the existing opportunity
409+
const existingOpportunity = await entityManager
410+
.getRepository(OpportunityJob)
411+
.findOne({
412+
where: { id: opportunityId },
413+
});
414+
415+
if (!existingOpportunity) {
416+
throw new ValidationError('Opportunity not found');
417+
}
418+
419+
// Build update object with all parsed data
420+
const updateData: Partial<OpportunityJob> = {};
421+
422+
if (parsedOpportunity.title) {
423+
updateData.title = parsedOpportunity.title;
424+
}
425+
426+
if (parsedOpportunity.tldr) {
427+
updateData.tldr = parsedOpportunity.tldr;
428+
}
429+
430+
// Update content - merge with existing to preserve any sections not in parsed data
431+
// Explicitly list content block keys to avoid iterating over protobuf methods
432+
const contentBlockKeys = [
433+
'overview',
434+
'responsibilities',
435+
'requirements',
436+
'whatYoullDo',
437+
'interviewProcess',
438+
] as const;
439+
const mergedContent: Partial<OpportunityContent> = {};
440+
for (const key of contentBlockKeys) {
441+
if (content[key]) {
442+
mergedContent[key] = content[key];
443+
}
444+
}
445+
updateData.content = {
446+
...existingOpportunity.content,
447+
...mergedContent,
448+
} as OpportunityContent;
449+
450+
// Update the opportunity
451+
if (Object.keys(updateData).length > 0) {
452+
await entityManager
453+
.getRepository(OpportunityJob)
454+
.update({ id: opportunityId }, updateData);
455+
}
456+
457+
// Update keywords if present in parsed data
458+
if (parsedOpportunity.keywords?.length) {
459+
await handleOpportunityKeywordsUpdate(
460+
entityManager,
461+
opportunityId,
462+
parsedOpportunity.keywords,
463+
);
464+
}
465+
466+
// Return the opportunity ID
467+
return opportunityId;
468+
});
469+
}

src/common/schema/opportunities.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,37 @@ export const parseOpportunitySchema = z
242242
},
243243
);
244244

245+
export const reimportOpportunitySchema = z
246+
.object({
247+
opportunityId: z.uuid(),
248+
url: urlParseSchema.optional(),
249+
file: fileUploadSchema.optional(),
250+
})
251+
.refine(
252+
(data) => {
253+
if (!data.url && !data.file) {
254+
return false;
255+
}
256+
257+
return true;
258+
},
259+
{
260+
error: 'Either url or file must be provided.',
261+
},
262+
)
263+
.refine(
264+
(data) => {
265+
if (data.url && data.file) {
266+
return false;
267+
}
268+
269+
return true;
270+
},
271+
{
272+
error: 'Only one of url or file can be provided.',
273+
},
274+
);
275+
245276
export const createSharedSlackChannelSchema = z.object({
246277
organizationId: z.string().uuid('Organization ID must be a valid UUID'),
247278
email: z.string().email('Email must be a valid email address'),

0 commit comments

Comments
 (0)