diff --git a/__tests__/fixture/opportunity.ts b/__tests__/fixture/opportunity.ts index 08a405812e..d2d666fdc2 100644 --- a/__tests__/fixture/opportunity.ts +++ b/__tests__/fixture/opportunity.ts @@ -19,6 +19,8 @@ import { import type { QuestionScreening } from '../../src/entity/questions/QuestionScreening'; import type { QuestionFeedback } from '../../src/entity/questions/QuestionFeedback'; import { demoCompany } from '../../src/common'; +import type { DatasetLocation } from '../../src/entity/dataset/DatasetLocation'; +import type { OpportunityLocation } from '../../src/entity/opportunities/OpportunityLocation'; export const organizationsFixture: DeepPartial[] = [ { @@ -71,6 +73,64 @@ export const organizationsFixture: DeepPartial[] = [ }, ]; +export const datasetLocationsFixture: DeepPartial[] = [ + { + id: '660e8400-e29b-41d4-a716-446655440001', + country: 'Norway', + city: null, + subdivision: null, + iso2: 'NO', + iso3: 'NOR', + externalId: 'norway-remote', + }, + { + id: '660e8400-e29b-41d4-a716-446655440002', + country: 'USA', + city: null, + subdivision: null, + iso2: 'US', + iso3: 'USA', + externalId: 'usa-hybrid', + }, + { + id: '660e8400-e29b-41d4-a716-446655440003', + country: 'USA', + city: 'San Francisco', + subdivision: 'CA', + iso2: 'US', + iso3: 'USA', + externalId: 'usa-sf-ca', + }, +]; + +export const opportunityLocationsFixture: DeepPartial[] = [ + { + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + locationId: '660e8400-e29b-41d4-a716-446655440001', + type: LocationType.REMOTE, + }, + { + opportunityId: '550e8400-e29b-41d4-a716-446655440002', + locationId: '660e8400-e29b-41d4-a716-446655440002', + type: LocationType.HYBRID, + }, + { + opportunityId: '550e8400-e29b-41d4-a716-446655440003', + locationId: '660e8400-e29b-41d4-a716-446655440001', + type: LocationType.REMOTE, + }, + { + opportunityId: '550e8400-e29b-41d4-a716-446655440004', + locationId: '660e8400-e29b-41d4-a716-446655440002', + type: LocationType.HYBRID, + }, + { + opportunityId: '550e8400-e29b-41d4-a716-446655440005', + locationId: '660e8400-e29b-41d4-a716-446655440001', + type: LocationType.REMOTE, + }, +]; + export const opportunitiesFixture: DeepPartial[] = [ { id: '550e8400-e29b-41d4-a716-446655440001', @@ -100,12 +160,6 @@ export const opportunitiesFixture: DeepPartial[] = [ createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), organizationId: '550e8400-e29b-41d4-a716-446655440000', - location: [ - { - type: LocationType.REMOTE, - country: 'Norway', - }, - ], }, { id: '550e8400-e29b-41d4-a716-446655440002', @@ -134,12 +188,6 @@ export const opportunitiesFixture: DeepPartial[] = [ createdAt: new Date('2023-01-02'), updatedAt: new Date('2023-01-02'), organizationId: '550e8400-e29b-41d4-a716-446655440000', - location: [ - { - type: LocationType.HYBRID, - country: 'USA', - }, - ], }, { id: '550e8400-e29b-41d4-a716-446655440003', @@ -168,12 +216,6 @@ export const opportunitiesFixture: DeepPartial[] = [ createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), organizationId: '550e8400-e29b-41d4-a716-446655440000', - location: [ - { - type: LocationType.REMOTE, - country: 'Norway', - }, - ], }, { id: '550e8400-e29b-41d4-a716-446655440004', @@ -202,12 +244,6 @@ export const opportunitiesFixture: DeepPartial[] = [ createdAt: new Date('2023-01-02'), updatedAt: new Date('2023-01-02'), organizationId: 'ed487a47-6f4d-480f-9712-f48ab29db27c', - location: [ - { - type: LocationType.HYBRID, - country: 'USA', - }, - ], }, { id: '550e8400-e29b-41d4-a716-446655440005', @@ -236,12 +272,6 @@ export const opportunitiesFixture: DeepPartial[] = [ createdAt: new Date('2023-01-03'), updatedAt: new Date('2023-01-03'), organizationId: demoCompany.id, - location: [ - { - type: LocationType.REMOTE, - country: 'Norway', - }, - ], }, ]; diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts index 0cc3d3b2e6..77ba04683a 100644 --- a/__tests__/helpers.ts +++ b/__tests__/helpers.ts @@ -500,6 +500,7 @@ export const createMockBrokkrTransport = () => country: 'USA', city: 'San Francisco', subdivision: 'CA', + iso2: 'US', type: 1, }), ], diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index dc220ba655..d7610f3e4d 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -6,6 +6,8 @@ import { Opportunity } from '../../src/entity/opportunities/Opportunity'; import { OpportunityMatch } from '../../src/entity/OpportunityMatch'; import { Organization } from '../../src/entity/Organization'; import { OpportunityKeyword } from '../../src/entity/OpportunityKeyword'; +import { DatasetLocation } from '../../src/entity/dataset/DatasetLocation'; +import { OpportunityLocation } from '../../src/entity/opportunities/OpportunityLocation'; import createOrGetConnection from '../../src/db'; import { authorizeRequest, @@ -29,6 +31,8 @@ import { opportunityQuestionsFixture, opportunityFeedbackQuestionsFixture, organizationsFixture, + datasetLocationsFixture, + opportunityLocationsFixture, } from '../fixture/opportunity'; import { OpportunityUser, @@ -141,7 +145,9 @@ beforeEach(async () => { await saveFixtures(con, User, usersFixture); await saveFixtures(con, Keyword, keywordsFixture); await saveFixtures(con, Organization, organizationsFixture); + await saveFixtures(con, DatasetLocation, datasetLocationsFixture); await saveFixtures(con, Opportunity, opportunitiesFixture); + await saveFixtures(con, OpportunityLocation, opportunityLocationsFixture); await saveFixtures(con, QuestionScreening, opportunityQuestionsFixture); await saveFixtures( con, @@ -207,9 +213,11 @@ describe('query opportunityById', () => { } equity } - location { - city - country + locations { + location { + city + country + } type } organization { @@ -293,10 +301,12 @@ describe('query opportunityById', () => { }, equity: true, }, - location: [ + locations: [ { - city: null, - country: 'Norway', + location: { + city: null, + country: 'Norway', + }, type: 1, }, ], @@ -1599,9 +1609,12 @@ describe('query userOpportunityMatches', () => { id title state - location { - city - country + locations { + location { + city + country + } + type } organization { id @@ -1632,10 +1645,13 @@ describe('query userOpportunityMatches', () => { id: '550e8400-e29b-41d4-a716-446655440001', title: 'Senior Full Stack Developer', state: 2, // LIVE - location: [ + locations: [ { - city: null, - country: 'Norway', + location: { + city: null, + country: 'Norway', + }, + type: 1, }, ], organization: { @@ -1669,8 +1685,6 @@ describe('query getCandidatePreferences', () => { city country subdivision - continent - type } locationType employmentType @@ -1779,8 +1793,8 @@ describe('query getCandidatePreferences', () => { period: 1, }, location: [ - { country: 'Norway' }, - { city: 'London', country: 'UK', continent: 'Europe' }, + { country: 'Norway', city: null, subdivision: null }, + { city: 'London', country: 'UK', subdivision: null }, ], locationType: [1, 3], employmentType: [1, 2, 3], @@ -3849,10 +3863,12 @@ describe('mutation editOpportunity', () => { period } } - location { - city - country + locations { type + location { + city + country + } } keywords { keyword @@ -3922,7 +3938,6 @@ describe('mutation editOpportunity', () => { payload: { title: 'Updated Senior Full Stack Developer', tldr: 'Updated TLDR', - location: [{ country: 'Germany', type: LocationType.REMOTE }], meta: { employmentType: EmploymentType.INTERNSHIP, teamSize: 100, @@ -3939,7 +3954,15 @@ describe('mutation editOpportunity', () => { id: opportunitiesFixture[0].id, title: 'Updated Senior Full Stack Developer', tldr: 'Updated TLDR', - location: [{ country: 'Germany', city: null, type: 1 }], + locations: [ + { + type: 1, + location: { + city: null, + country: 'Norway', + }, + }, + ], meta: { employmentType: EmploymentType.INTERNSHIP, teamSize: 100, @@ -4892,14 +4915,6 @@ describe('mutation recommendOpportunityScreeningQuestions', () => { title: 'Opportunity without questions', tldr: 'TLDR', state: OpportunityState.DRAFT, - location: [ - { - type: LocationType.HYBRID, - city: 'Varaždin', - subdivision: 'Varaždinska', - country: 'Croatia', - }, - ], meta: { seniorityLevel: SeniorityLevel.SENIOR, employmentType: EmploymentType.PART_TIME, @@ -4910,6 +4925,11 @@ describe('mutation recommendOpportunityScreeningQuestions', () => { }, }), ); + await con.getRepository(OpportunityLocation).save({ + opportunityId: opportunity.id, + locationId: '660e8400-e29b-41d4-a716-446655440001', + type: LocationType.HYBRID, + }); await con.getRepository(OpportunityUser).save({ opportunityId: opportunity.id, @@ -4941,7 +4961,7 @@ describe('mutation recommendOpportunityScreeningQuestions', () => { expect(clientSpy).toHaveBeenCalledTimes(1); expect(clientSpy).toHaveBeenCalledWith({ - jobOpportunity: `**Location:** HYBRID, Varaždin, Varaždinska, Croatia + jobOpportunity: `**Location:** HYBRID, Norway **Job Type:** PART_TIME **Seniority Level:** SENIOR @@ -5213,10 +5233,12 @@ describe('mutation parseOpportunity', () => { period } } - location { - city - country - subdivision + locations { + location { + city + country + subdivision + } type } keywords { @@ -5240,6 +5262,9 @@ describe('mutation parseOpportunity', () => { await deleteKeysByPattern(`${rateLimiterName}:*`); + // Ensure dataset locations are available + await saveFixtures(con, DatasetLocation, datasetLocationsFixture); + const transport = createMockBrokkrTransport(); const serviceClient = { @@ -5332,12 +5357,14 @@ describe('mutation parseOpportunity', () => { html: '

These are the requirements of the mocked opportunity.

\n', }, }, - location: [ + locations: [ { - city: 'San Francisco', - country: 'USA', - subdivision: 'CA', type: LocationType.REMOTE, + location: { + city: null, + country: 'USA', + subdivision: null, + }, }, ], questions: [], @@ -5445,12 +5472,14 @@ describe('mutation parseOpportunity', () => { html: '

These are the requirements of the mocked opportunity.

\n', }, }, - location: [ + locations: [ { - city: 'San Francisco', - country: 'USA', - subdivision: 'CA', type: LocationType.REMOTE, + location: { + city: null, + country: 'USA', + subdivision: null, + }, }, ], questions: [], @@ -5621,12 +5650,14 @@ describe('mutation parseOpportunity', () => { html: '

These are the requirements of the mocked opportunity.

\n', }, }, - location: [ + locations: [ { - city: 'San Francisco', - country: 'USA', - subdivision: 'CA', type: LocationType.REMOTE, + location: { + city: null, + country: 'USA', + subdivision: null, + }, }, ], questions: [], diff --git a/package.json b/package.json index ae8893459f..20c0f4a022 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@connectrpc/connect-fastify": "^1.6.1", "@connectrpc/connect-node": "^1.6.1", "@dailydotdev/graphql-redis-subscriptions": "^2.4.3", - "@dailydotdev/schema": "0.2.58", + "@dailydotdev/schema": "0.2.59", "@dailydotdev/ts-ioredis-pool": "^1.0.2", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af9de8bc2f..c9db4f3b20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,8 +35,8 @@ importers: specifier: ^2.4.3 version: 2.4.3(graphql-subscriptions@3.0.0(graphql@16.11.0)) '@dailydotdev/schema': - specifier: 0.2.58 - version: 0.2.58(@bufbuild/protobuf@1.10.0) + specifier: 0.2.59 + version: 0.2.59(@bufbuild/protobuf@1.10.0) '@dailydotdev/ts-ioredis-pool': specifier: ^1.0.2 version: 1.0.2 @@ -705,8 +705,8 @@ packages: peerDependencies: graphql-subscriptions: ^1.0.0 || ^2.0.0 - '@dailydotdev/schema@0.2.58': - resolution: {integrity: sha512-B/JPZYtMPk08awGAqdOAmO8TI/IeBsVNEaA7gYr79uqxdbSJlFoUvlG34K/mhFhltCwFSF4GV+M4B6CXNvmo/w==} + '@dailydotdev/schema@0.2.59': + resolution: {integrity: sha512-dSCw1UG7c+g5iE6NVFpv5A5MzkTGmAC9Q370W/qeQyGXD1Bx46SEiup0MEewCPJUoY2oNA+vogkr53h6AXsBhA==} peerDependencies: '@bufbuild/protobuf': 1.x @@ -5187,7 +5187,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@dailydotdev/schema@0.2.58(@bufbuild/protobuf@1.10.0)': + '@dailydotdev/schema@0.2.59(@bufbuild/protobuf@1.10.0)': dependencies: '@bufbuild/protobuf': 1.10.0 diff --git a/src/common/opportunity/prompt.ts b/src/common/opportunity/prompt.ts index 36d5cf616e..2bf548e876 100644 --- a/src/common/opportunity/prompt.ts +++ b/src/common/opportunity/prompt.ts @@ -6,19 +6,20 @@ import { import type { OpportunityJob } from '../../entity/opportunities/OpportunityJob'; import { textFromEnumValue } from '../protobuf'; -export const createOpportunityPrompt = ({ +export const createOpportunityPrompt = async ({ opportunity, }: { opportunity: OpportunityJob; }) => { + const locations = await opportunity.locations; + const firstLocation = locations?.[0]; + const locationData = firstLocation ? await firstLocation.location : null; + const promptData = { - locationType: textFromEnumValue( - LocationType, - opportunity.location?.[0]?.type, - ), - city: opportunity.location?.[0]?.city, - subdivision: opportunity.location?.[0]?.subdivision, - country: opportunity.location?.[0]?.country, + locationType: textFromEnumValue(LocationType, firstLocation?.type), + city: locationData?.city, + subdivision: locationData?.subdivision, + country: locationData?.country, jobType: textFromEnumValue( EmploymentType, opportunity.meta?.employmentType, diff --git a/src/common/opportunity/pubsub.ts b/src/common/opportunity/pubsub.ts index c2ae1e00b2..42d214f76d 100644 --- a/src/common/opportunity/pubsub.ts +++ b/src/common/opportunity/pubsub.ts @@ -18,7 +18,7 @@ import { } from '../../common'; import { getSecondsTimestamp } from '../date'; import { UserCandidatePreference } from '../../entity/user/UserCandidatePreference'; -import { ChangeObject } from '../../types'; +import { ChangeObject, continentMap } from '../../types'; import { OpportunityMatch } from '../../entity/OpportunityMatch'; import { OpportunityJob } from '../../entity/opportunities/OpportunityJob'; import { UserCandidateKeyword } from '../../entity/user/UserCandidateKeyword'; @@ -338,9 +338,8 @@ export const notifyJobOpportunity = async ({ logger: FastifyBaseLogger; opportunityId: string; }) => { - const [opportunity, organization, keywords, users] = await queryReadReplica( - con, - async ({ queryRunner }) => { + const [opportunity, organization, keywords, users, locations] = + await queryReadReplica(con, async ({ queryRunner }) => { const opportunity = await queryRunner.manager .getRepository(OpportunityJob) .findOneOrFail({ @@ -349,18 +348,19 @@ export const notifyJobOpportunity = async ({ organization: true, keywords: true, users: true, + locations: true, }, }); - const [organization, keywords, users] = await Promise.all([ + const [organization, keywords, users, locations] = await Promise.all([ opportunity.organization, opportunity.keywords, opportunity.users, + opportunity.locations, ]); - return [opportunity, organization, keywords, users]; - }, - ); + return [opportunity, organization, keywords, users, locations]; + }); if (!organization) { logger.warn( @@ -412,12 +412,29 @@ export const notifyJobOpportunity = async ({ ...users.map((u) => u.userId), ]); + // Check if the location country is a continent and return only continent code + const locationData = locations?.[0]; + const datasetLocation = locationData ? await locationData.location : null; + const locationCountry = datasetLocation?.country; + const continentCode = locationCountry ? continentMap[locationCountry] : null; + + const locationPayload = continentCode + ? { continent: continentCode } + : { + ...datasetLocation, + // Convert null values to undefined for protobuf compatibility + subdivision: datasetLocation?.subdivision ?? undefined, + city: datasetLocation?.city ?? undefined, + type: locationData?.type, + }; + const message = new OpportunityMessage({ opportunity: { ...opportunity, createdAt: getSecondsTimestamp(opportunity.createdAt), updatedAt: getSecondsTimestamp(opportunity.updatedAt), keywords: keywords.map((k) => k.keyword), + location: [locationPayload], }, organization: { ...organization, diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index df05afd2c7..18797054c3 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -61,6 +61,7 @@ export const opportunityCreateSchema = z.object({ city: z.string().nonempty().max(240).optional(), subdivision: z.string().nonempty().max(240).optional(), type: z.coerce.number().min(1), + iso2: z.string().nonempty().max(2).optional(), }), ) .optional(), @@ -133,36 +134,11 @@ export const opportunityEditSchema = z ) .min(1) .max(100), - location: z.array( - z - .object({ - country: z.string().max(240), - city: z.string().max(240).optional(), - subdivision: z.string().max(240).optional(), - continent: z - .union([z.literal('Europe'), z.literal(''), z.undefined()]) - .optional(), - type: z.coerce.number().min(1), - }) - .superRefine((val, ctx) => { - const present = [ - val.country && val.country.trim() !== '', - val.city && val.city.trim() !== '', - val.subdivision && val.subdivision.trim() !== '', - // continent counts only if it is "Europe" (empty string should not count) - val.continent === 'Europe', - ].some(Boolean); - - if (!present) { - ctx.addIssue({ - code: 'custom', - message: - 'At least one of country, city, subdivision, or continent must be provided.', - path: [''], // form-level error - }); - } - }), + externalLocationId: z.preprocess( + (val) => (val === '' ? null : val), + z.string().nullish().default(null), ), + locationType: z.coerce.number().nullish().default(null), meta: z.object({ employmentType: z.coerce.number().min(1), teamSize: z.number().int().nonnegative().min(1).max(1_000_000), @@ -188,18 +164,20 @@ 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(), - founded: z.number().int().min(1800).max(2100).nullable().optional(), - location: z.string().max(500).nullable().optional(), - category: z.string().max(240).nullable().optional(), - size: z.number().int().nullable().optional(), - stage: z.number().int().nullable().optional(), - links: z.array(organizationLinksSchema).max(50).optional(), - }), + 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(), + founded: z.number().int().min(1800).max(2100).nullable().optional(), + location: z.string().max(500).nullable().optional(), + category: z.string().max(240).nullable().optional(), + size: z.number().int().nullable().optional(), + stage: z.number().int().nullable().optional(), + links: z.array(organizationLinksSchema).max(50).optional(), + }) + .nullish(), recruiter: z.object({ userId: z.string(), title: z.string().max(240).optional(), diff --git a/src/entity/dataset/utils.ts b/src/entity/dataset/utils.ts index a5513e6eb7..0f040d801a 100644 --- a/src/entity/dataset/utils.ts +++ b/src/entity/dataset/utils.ts @@ -20,3 +20,28 @@ export const createLocationFromMapbox = async ( iso3: properties.context?.country?.country_code_alpha_3?.toUpperCase(), }); }; + +/** + * Find an existing location in the dataset_location table based on iso2 country code. + */ +export const findDatasetLocation = async ( + con: DataSource, + locationData: { + iso2?: string | null; + }, +): Promise => { + const { iso2 } = locationData; + + if (!iso2) { + return null; + } + + const repo = con.manager.getRepository(DatasetLocation); + + // Find location by iso2 country code + const location = await repo.findOne({ + where: { iso2: iso2.toUpperCase() }, + }); + + return location; +}; diff --git a/src/entity/opportunities/Opportunity.ts b/src/entity/opportunities/Opportunity.ts index b53abc335e..5bd1477a4b 100644 --- a/src/entity/opportunities/Opportunity.ts +++ b/src/entity/opportunities/Opportunity.ts @@ -19,6 +19,7 @@ import type { OpportunityKeyword } from '../OpportunityKeyword'; import type { OpportunityMatch } from '../OpportunityMatch'; import type { QuestionScreening } from '../questions/QuestionScreening'; import type { QuestionFeedback } from '../questions/QuestionFeedback'; +import type { OpportunityLocation } from './OpportunityLocation'; import type { opportunitySubscriptionFlagsSchema } from '../../common/schema/opportunities'; import type z from 'zod'; @@ -107,6 +108,13 @@ export class Opportunity { ) feedbackQuestions: Promise; + @OneToMany( + 'OpportunityLocation', + (location: OpportunityLocation) => location.opportunity, + { lazy: true }, + ) + locations: Promise; + @Column({ type: 'jsonb', default: {} }) flags: OpportunityFlags; diff --git a/src/entity/opportunities/OpportunityJob.ts b/src/entity/opportunities/OpportunityJob.ts index 5f40a94d57..a253cedd08 100644 --- a/src/entity/opportunities/OpportunityJob.ts +++ b/src/entity/opportunities/OpportunityJob.ts @@ -1,5 +1,5 @@ import { ChildEntity, Column, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { OpportunityType, type Location } from '@dailydotdev/schema'; +import { OpportunityType } from '@dailydotdev/schema'; import { Opportunity } from './Opportunity'; import type { Organization } from '../Organization'; @@ -9,13 +9,6 @@ export class OpportunityJob extends Opportunity { @Index('IDX_opportunity_organization_id') organizationId: string | null; - @Column({ - type: 'jsonb', - default: [], - comment: 'Location from protobuf schema', - }) - location: Location[]; - @ManyToOne('Organization', { lazy: true, onDelete: 'CASCADE' }) @JoinColumn({ name: 'organizationId', diff --git a/src/entity/opportunities/OpportunityLocation.ts b/src/entity/opportunities/OpportunityLocation.ts new file mode 100644 index 0000000000..18bad5a247 --- /dev/null +++ b/src/entity/opportunities/OpportunityLocation.ts @@ -0,0 +1,49 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { LocationType } from '@dailydotdev/schema'; +import type { Opportunity } from './Opportunity'; +import type { DatasetLocation } from '../dataset/DatasetLocation'; + +@Entity() +export class OpportunityLocation { + @PrimaryGeneratedColumn('uuid', { + primaryKeyConstraintName: 'PK_opportunity_location_id', + }) + id: string; + + @Column({ type: 'text' }) + @Index('IDX_opportunity_location_opportunityId') + opportunityId: string; + + @ManyToOne('Opportunity', { lazy: true, onDelete: 'CASCADE' }) + @JoinColumn({ + name: 'opportunityId', + foreignKeyConstraintName: + 'FK_opportunity_location_opportunity_opportunityId', + }) + opportunity: Promise; + + @Column({ type: 'text' }) + @Index('IDX_opportunity_location_locationId') + locationId: string; + + @ManyToOne('DatasetLocation', { lazy: true, onDelete: 'CASCADE' }) + @JoinColumn({ + name: 'locationId', + foreignKeyConstraintName: + 'FK_opportunity_location_dataset_location_locationId', + }) + location: Promise; + + @Column({ + type: 'integer', + comment: 'LocationType from protobuf schema', + }) + type: LocationType; +} diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index b052aea0f1..d30c63d3a1 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1533,9 +1533,6 @@ const obj = new GraphORM({ meta: { jsonType: true, }, - location: { - jsonType: true, - }, recruiters: { relation: { isMany: true, @@ -1605,6 +1602,18 @@ const obj = new GraphORM({ }, }, }, + OpportunityLocation: { + requiredColumns: ['id', 'type'], + fields: { + location: { + relation: { + isMany: false, + childColumn: 'id', + parentColumn: 'locationId', + }, + }, + }, + }, OpportunityScreeningQuestion: { from: 'QuestionScreening', requiredColumns: ['questionOrder'], @@ -1726,7 +1735,6 @@ const obj = new GraphORM({ ) `, transform: (value: unknown) => { - console.log(value); if (isNullOrUndefined(value)) return []; if (Array.isArray(value)) return value; return [value]; diff --git a/src/migration/1765793807971-AddOpportunityLocationTable.ts b/src/migration/1765793807971-AddOpportunityLocationTable.ts new file mode 100644 index 0000000000..b2581ed4e8 --- /dev/null +++ b/src/migration/1765793807971-AddOpportunityLocationTable.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOpportunityLocationTable1765793807971 + implements MigrationInterface +{ + name = 'AddOpportunityLocationTable1765793807971'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + CREATE TABLE "opportunity_location" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "opportunityId" uuid NOT NULL, + "locationId" uuid NOT NULL, + "type" integer NOT NULL, + CONSTRAINT "PK_opportunity_location_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_opportunity_location_opportunity_opportunityId" + FOREIGN KEY ("opportunityId") + REFERENCES "opportunity"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION, + CONSTRAINT "FK_opportunity_location_dataset_location_locationId" + FOREIGN KEY ("locationId") + REFERENCES "dataset_location"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION + ) + `); + + await queryRunner.query(/* sql */ ` + COMMENT ON COLUMN "opportunity_location"."type" IS 'LocationType from protobuf schema' + `); + + await queryRunner.query(/* sql */ ` + CREATE INDEX IF NOT EXISTS "IDX_opportunity_location_opportunityId" + ON "opportunity_location" ("opportunityId") + `); + + await queryRunner.query(/* sql */ ` + CREATE INDEX IF NOT EXISTS "IDX_opportunity_location_locationId" + ON "opportunity_location" ("locationId") + `); + + await queryRunner.query(/* sql */ ` + ALTER TABLE "opportunity" + DROP COLUMN "location" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "opportunity" + ADD "location" jsonb DEFAULT '[]' + `); + + await queryRunner.query(/* sql */ ` + DROP TABLE "opportunity_location" + `); + } +} diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 3624a9b188..b256d6b9c4 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -8,6 +8,7 @@ import { AuthContext, BaseContext, type Context } from '../Context'; import graphorm from '../graphorm'; import { BrokkrParseRequest, + LocationType, OpportunityContent, OpportunityState, ScreeningQuestionsRequest, @@ -25,8 +26,6 @@ import { OpportunityUserType, } from '../entity/opportunities/types'; import { UserCandidatePreference } from '../entity/user/UserCandidatePreference'; -import { DatasetLocation } from '../entity/dataset/DatasetLocation'; -import { createLocationFromMapbox } from '../entity/dataset/utils'; import type { GQLEmptyResponse } from './common'; import { candidatePreferenceSchema, @@ -84,12 +83,19 @@ import { QueryFailedError, type DeepPartial, JsonContains, + EntityManager, } from 'typeorm'; import { Organization } from '../entity/Organization'; import { OrganizationLinkType, SocialMediaType, } from '../common/schema/organizations'; +import { DatasetLocation } from '../entity/dataset/DatasetLocation'; +import { + createLocationFromMapbox, + findDatasetLocation, +} from '../entity/dataset/utils'; +import { OpportunityLocation } from '../entity/opportunities/OpportunityLocation'; import { getGondulClient } from '../common/gondul'; import { createOpportunityPrompt } from '../common/opportunity/prompt'; import { queryPaginatedByDate } from '../common/datePageGenerator'; @@ -239,8 +245,12 @@ export const typeDefs = /* GraphQL */ ` city: String country: String subdivision: String - continent: String - type: ProtoEnumValue + } + + type OpportunityLocation { + id: ID! + location: Location! + type: ProtoEnumValue! } type OpportunityMeta { @@ -301,7 +311,7 @@ export const typeDefs = /* GraphQL */ ` tldr: String content: OpportunityContent! meta: OpportunityMeta! - location: [Location]! + locations: [OpportunityLocation]! organization: Organization recruiters: [User!]! keywords: [OpportunityKeyword]! @@ -735,6 +745,8 @@ export const typeDefs = /* GraphQL */ ` tldr: String meta: OpportunityMetaInput location: [LocationInput] + externalLocationId: String + locationType: ProtoEnumValue keywords: [OpportunityKeywordInput] content: OpportunityContentInput questions: [OpportunityScreeningQuestionInput!] @@ -1030,6 +1042,276 @@ async function updateCandidateMatchStatus( }); } +/** + * Renders markdown content for opportunity fields + * Converts markdown strings to HTML for storage + */ +function renderOpportunityContent( + content: Record | undefined, +): OpportunityContent { + const renderedContent: Record = {}; + + Object.entries(content || {}).forEach(([key, value]) => { + if (typeof value.content !== 'string') { + return; + } + + renderedContent[key] = { + content: value.content, + html: markdown.render(value.content), + }; + }); + + return new OpportunityContent(renderedContent); +} + +/** + * Handles opportunity location updates + * Creates or updates locations based on externalLocationId and locationType + */ +async function handleOpportunityLocationUpdate( + entityManager: EntityManager, + opportunityId: string, + externalLocationId: string | null | undefined, + locationType: number | undefined | null, + ctx: AuthContext, +): Promise { + if (externalLocationId !== undefined) { + // If externalLocationId is provided, replace all locations with the new one + await entityManager.getRepository(OpportunityLocation).delete({ + opportunityId, + }); + + if (externalLocationId) { + let location = await entityManager + .getRepository(DatasetLocation) + .findOne({ + where: { externalId: externalLocationId }, + }); + if (!location) { + location = await createLocationFromMapbox(ctx.con, externalLocationId); + } + + // Create new OpportunityLocation relationship + if (location) { + await entityManager.getRepository(OpportunityLocation).insert({ + opportunityId, + locationId: location.id, + type: locationType || 1, + }); + } + } + } else if (locationType !== undefined && locationType !== null) { + // If only locationType is provided (no externalLocationId), update existing locations + await entityManager + .getRepository(OpportunityLocation) + .update({ opportunityId }, { type: locationType }); + } +} + +/** + * Handles organization creation, updates, and image uploads for an opportunity + * Creates new organizations or updates existing ones, with support for image uploads + */ +async function handleOpportunityOrganizationUpdate( + entityManager: EntityManager, + opportunityId: string, + organization: Record | null | undefined, + organizationImage: Promise | undefined, +): Promise { + if (!organization && !organizationImage) { + return; + } + + const opportunityJob = await entityManager + .getRepository(OpportunityJob) + .findOne({ + where: { id: opportunityId }, + select: ['organizationId'], + }); + + let organizationId = opportunityJob?.organizationId; + + let organizationUpdate: Record = { + ...organization, + }; + + if (organizationId) { + delete organizationUpdate.name; // prevent name updates on existing organizations + } + + if (!organizationId) { + // create new organization and assign to opportunity here inline + // TODO: ideally this should be refactored later to separate mutation + + try { + const organizationInsertResult = await entityManager + .getRepository(Organization) + .insert(organizationUpdate); + + organizationId = organizationInsertResult.identifiers[0].id as string; + + await entityManager + .getRepository(OpportunityJob) + .update({ id: opportunityId }, { organizationId }); + + // values were applied during insert + organizationUpdate = {}; + } catch (insertError) { + if (insertError instanceof QueryFailedError) { + const queryFailedError = insertError as TypeORMQueryFailedError; + + if (queryFailedError.code === TypeOrmError.DUPLICATE_ENTRY) { + if ( + insertError.message.indexOf('IDX_organization_name_unique') > -1 + ) { + throw new ConflictError( + 'Organization with this name already exists', + ); + } + } + } + + throw insertError; + } + } + + // Handle image upload + if (organizationImage) { + const { createReadStream } = await organizationImage; + const stream = createReadStream(); + const { url: imageUrl } = await uploadOrganizationImage( + organizationId, + stream, + ); + organizationUpdate.image = imageUrl; + } + + if (Object.keys(organizationUpdate).length > 0) { + await entityManager + .getRepository(Organization) + .update({ id: organizationId }, organizationUpdate); + } +} + +/** + * Handles opportunity keywords updates + * Replaces all existing keywords with the new set + */ +async function handleOpportunityKeywordsUpdate( + entityManager: EntityManager, + opportunityId: string, + keywords: Array<{ keyword: string }> | undefined, +): Promise { + if (!Array.isArray(keywords)) { + return; + } + + await entityManager.getRepository(OpportunityKeyword).delete({ + opportunityId, + }); + + await entityManager.getRepository(OpportunityKeyword).insert( + keywords.map((keyword) => ({ + opportunityId, + keyword: keyword.keyword, + })), + ); +} + +/** + * Handles opportunity screening questions updates + * Validates questions ownership and upserts them with proper ordering + */ +async function handleOpportunityScreeningQuestionsUpdate( + entityManager: EntityManager, + opportunityId: string, + questions: + | Array<{ id?: string; title: string; placeholder?: string | null }> + | undefined, +): Promise { + if (!Array.isArray(questions)) { + return; + } + + const questionIds = questions.map((item) => item.id).filter(Boolean); + + const hasQuestionsFromOtherOpportunity = await entityManager + .getRepository(QuestionScreening) + .exists({ + where: { id: In(questionIds), opportunityId: Not(opportunityId) }, + }); + + if (hasQuestionsFromOtherOpportunity) { + throw new ConflictError('Not allowed to edit some questions!'); + } + + await entityManager.getRepository(QuestionScreening).delete({ + id: Not(In(questionIds)), + opportunityId, + }); + + await entityManager.getRepository(QuestionScreening).upsert( + questions.map((question, index) => { + return entityManager.getRepository(QuestionScreening).create({ + id: question.id, + opportunityId, + title: question.title, + placeholder: question.placeholder ?? undefined, + questionOrder: index, + }); + }), + { conflictPaths: ['id'] }, + ); +} + +/** + * Handles recruiter information updates for an opportunity + * Validates recruiter is assigned to the opportunity and updates their profile + */ +async function handleOpportunityRecruiterUpdate( + entityManager: EntityManager, + opportunityId: string, + recruiter: { userId: string; title?: string; bio?: string } | undefined, + ctx: AuthContext, +): Promise { + if (!recruiter) { + return; + } + + // Check if the recruiter is part of the recruiters for this opportunity + const existingRecruiter = await entityManager + .getRepository(OpportunityUserRecruiter) + .findOne({ + where: { + opportunityId, + userId: recruiter.userId, + type: OpportunityUserType.Recruiter, + }, + }); + + if (!existingRecruiter) { + ctx.log.error( + { opportunityId, userId: recruiter.userId }, + 'Recruiter is not part of this opportunity', + ); + throw new ForbiddenError( + 'Access denied! Recruiter is not part of this opportunity', + ); + } + + // Update the recruiter's title and bio on the User entity + await entityManager.getRepository(User).update( + { + id: recruiter.userId, + }, + { + title: recruiter.title, + bio: recruiter.bio, + }, + ); +} + export const resolvers: IResolvers = traceResolvers< unknown, BaseContext @@ -1319,13 +1601,33 @@ export const resolvers: IResolvers = traceResolvers< opportunity.content[opportunityKey] || {}; }); + // Fetch locations from OpportunityLocation table + const opportunityLocations = await ctx.con + .getRepository(OpportunityLocation) + .find({ + where: { opportunityId: opportunity.id }, + relations: ['location'], + }); + + const locations = await Promise.all( + opportunityLocations.map(async (ol) => { + const datasetLocation = await ol.location; + return { + country: datasetLocation.country, + city: datasetLocation.city, + subdivision: datasetLocation.subdivision, + type: ol.type, + }; + }), + ); + const validatedPayload = { opportunity: { title: opportunity.title, tldr: opportunity.tldr, content: opportunityContent, meta: opportunity.meta, - location: opportunity.location, + location: locations, state: opportunity.state, type: opportunity.type, keywords: keywords.map((k) => k.keyword), @@ -1883,26 +2185,12 @@ export const resolvers: IResolvers = traceResolvers< questions, organization, recruiter, + externalLocationId, + locationType, ...opportunityUpdate } = opportunity; - const renderedContent: Record< - string, - { content: string; html: string } - > = {}; - - Object.entries(content || {}).forEach(([key, value]) => { - if (typeof value.content !== 'string') { - return; - } - - renderedContent[key] = { - content: value.content, - html: markdown.render(value.content), - }; - }); - - const opportunityContent = new OpportunityContent(renderedContent); + const opportunityContent = renderOpportunityContent(content); await entityManager .getRepository(OpportunityJob) @@ -1917,166 +2205,46 @@ export const resolvers: IResolvers = traceResolvers< .setParameter('metaJson', JSON.stringify(opportunity.meta || {})) .execute(); - if (organization || organizationImage) { - const opportunityJob = await entityManager - .getRepository(OpportunityJob) - .findOne({ - where: { id }, - select: ['organizationId'], - }); - - let organizationId = opportunityJob?.organizationId; - - let organizationUpdate: Record = { - ...organization, - }; - - if (organizationId) { - delete organizationUpdate.name; // prevent name updates on existing organizations - } - - if (!organizationId) { - // create new organization and assign to opportunity here inline - // TODO: ideally this should be refactored later to separate mutation - - try { - const organizationInsertResult = await entityManager - .getRepository(Organization) - .insert(organizationUpdate); - - organizationId = organizationInsertResult.identifiers[0] - .id as string; - - await entityManager - .getRepository(OpportunityJob) - .update({ id }, { organizationId }); - - // values were applied during insert - organizationUpdate = {}; - } catch (insertError) { - if (insertError instanceof QueryFailedError) { - const queryFailedError = insertError as TypeORMQueryFailedError; - - if (queryFailedError.code === TypeOrmError.DUPLICATE_ENTRY) { - if ( - insertError.message.indexOf( - 'IDX_organization_name_unique', - ) > -1 - ) { - throw new ConflictError( - 'Organization with this name already exists', - ); - } - } - } - - throw insertError; - } - } - - // Handle image upload - if (organizationImage) { - const { createReadStream } = await organizationImage; - const stream = createReadStream(); - const { url: imageUrl } = await uploadOrganizationImage( - organizationId, - stream, - ); - organizationUpdate.image = imageUrl; - } - - if (Object.keys(organizationUpdate).length > 0) { - await entityManager - .getRepository(Organization) - .update({ id: organizationId }, organizationUpdate); - } - } - - if (Array.isArray(keywords)) { - await entityManager.getRepository(OpportunityKeyword).delete({ - opportunityId: id, - }); - - await entityManager.getRepository(OpportunityKeyword).insert( - keywords.map((keyword) => ({ - opportunityId: id, - keyword: keyword.keyword, - })), - ); - } - - if (Array.isArray(questions)) { - const questionIds = questions.map((item) => item.id).filter(Boolean); - - const hasQuestionsFromOtherOpportunity = await entityManager - .getRepository(QuestionScreening) - .exists({ - where: { id: In(questionIds), opportunityId: Not(id) }, - }); - - if (hasQuestionsFromOtherOpportunity) { - throw new ConflictError('Not allowed to edit some questions!'); - } + await handleOpportunityLocationUpdate( + entityManager, + id, + externalLocationId, + locationType, + ctx, + ); - await entityManager.getRepository(QuestionScreening).delete({ - id: Not(In(questionIds)), - opportunityId: id, - }); + await handleOpportunityOrganizationUpdate( + entityManager, + id, + organization, + organizationImage, + ); - await entityManager.getRepository(QuestionScreening).upsert( - questions.map((question, index) => { - return entityManager.getRepository(QuestionScreening).create({ - id: question.id, - opportunityId: id, - title: question.title, - placeholder: question.placeholder, - questionOrder: index, - }); - }), - { conflictPaths: ['id'] }, - ); - } + await handleOpportunityKeywordsUpdate(entityManager, id, keywords); - if (recruiter) { - // Check if the recruiter is part of the recruiters for this opportunity - const existingRecruiter = await entityManager - .getRepository(OpportunityUserRecruiter) - .findOne({ - where: { - opportunityId: id, - userId: recruiter.userId, - type: OpportunityUserType.Recruiter, - }, - }); - - if (!existingRecruiter) { - ctx.log.error( - { opportunityId: id, userId: recruiter.userId }, - 'Recruiter is not part of this opportunity', - ); - throw new ForbiddenError( - 'Access denied! Recruiter is not part of this opportunity', - ); - } + await handleOpportunityScreeningQuestionsUpdate( + entityManager, + id, + questions, + ); - // Update the recruiter's title and bio on the User entity - await entityManager.getRepository(User).update( - { - id: recruiter.userId, - }, - { - title: recruiter.title, - bio: recruiter.bio, - }, - ); - } + await handleOpportunityRecruiterUpdate( + entityManager, + id, + recruiter, + ctx, + ); }); - return graphorm.queryOneOrFail(ctx, info, (builder) => { - builder.queryBuilder.where({ id }); + return await graphorm.queryOneOrFail( + ctx, + info, + (builder) => { + builder.queryBuilder.where({ id }); - return builder; - }); + return builder; + }, + ); }, clearOrganizationImage: async ( _, @@ -2137,6 +2305,9 @@ export const resolvers: IResolvers = traceResolvers< where: { id }, relations: { organization: true, + locations: { + location: true, + }, }, }); @@ -2149,7 +2320,7 @@ export const resolvers: IResolvers = traceResolvers< const result = await gondulClient.garmr.execute(async () => { return await gondulClient.instance.screeningQuestions( new ScreeningQuestionsRequest({ - jobOpportunity: createOpportunityPrompt({ opportunity }), + jobOpportunity: await createOpportunityPrompt({ opportunity }), }), ); }); @@ -2386,6 +2557,12 @@ export const resolvers: IResolvers = traceResolvers< const opportunityContent = new OpportunityContent(renderedContent); + // Extract and process locations + const locationData = (parsedOpportunity.location || []) as Array<{ + iso2?: string; + type?: number; + }>; + const opportunityResult = await ctx.con.transaction( async (entityManager) => { const flags: Opportunity['flags'] = {}; @@ -2396,17 +2573,36 @@ export const resolvers: IResolvers = traceResolvers< flags.batchSize = opportunityMatchBatchSize; + // Remove location from parsedOpportunity as it's now relational + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { location, ...opportunityData } = parsedOpportunity; + const opportunity = await entityManager .getRepository(OpportunityJob) .save( entityManager.getRepository(OpportunityJob).create({ - ...parsedOpportunity, + ...opportunityData, state: OpportunityState.DRAFT, content: opportunityContent, flags, } as DeepPartial), ); + // Create OpportunityLocation entries for each location + for (const loc of locationData) { + const datasetLocation = await findDatasetLocation(ctx.con, { + iso2: loc.iso2, + }); + + if (datasetLocation) { + await entityManager.getRepository(OpportunityLocation).save({ + opportunityId: opportunity.id, + locationId: datasetLocation.id, + type: loc.type || LocationType.REMOTE, + }); + } + } + await addOpportunityDefaultQuestionFeedback({ entityManager, opportunityId: opportunity.id, @@ -2434,11 +2630,15 @@ export const resolvers: IResolvers = traceResolvers< }, ); - return graphorm.queryOneOrFail(ctx, info, (builder) => { - builder.queryBuilder.where({ id: opportunityResult.id }); + return await graphorm.queryOneOrFail( + ctx, + info, + (builder) => { + builder.queryBuilder.where({ id: opportunityResult.id }); - return builder; - }); + return builder; + }, + ); } catch (error) { throw error; } finally { diff --git a/src/types.ts b/src/types.ts index 1e24642e6a..327a50ae4a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -319,3 +319,14 @@ export const acceptedOpportunityFileTypes = acceptedResumeFileTypes; export const acceptedOpportunityExtensions = acceptedResumeExtensions; export const opportunityMatchBatchSize = 50; + +// Map continent names to their codes +export const continentMap: Record = { + Africa: 'AF', + Antarctica: 'AN', + Asia: 'AS', + Europe: 'EU', + 'North America': 'NA', + 'South America': 'SA', + Oceania: 'OC', +};