From 98dee8824d4d9923f780f3d8f177a6b556aa8722 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Mon, 15 Dec 2025 13:56:10 +0200 Subject: [PATCH 01/13] feat: opportunity location links --- package.json | 2 +- pnpm-lock.yaml | 10 +-- src/common/schema/opportunities.ts | 59 +++++---------- src/entity/opportunities/Opportunity.ts | 8 ++ src/entity/opportunities/OpportunityJob.ts | 9 +-- .../opportunities/OpportunityLocation.ts | 49 ++++++++++++ src/graphorm/index.ts | 22 +++++- ...65793807971-AddOpportunityLocationTable.ts | 45 +++++++++++ src/schema/autocompletes.ts | 9 +++ src/schema/opportunity.ts | 75 ++++++++++++++++++- 10 files changed, 226 insertions(+), 62 deletions(-) create mode 100644 src/entity/opportunities/OpportunityLocation.ts create mode 100644 src/migration/1765793807971-AddOpportunityLocationTable.ts 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/schema/opportunities.ts b/src/common/schema/opportunities.ts index df05afd2c7..d269a96b0d 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -133,36 +133,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 +163,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/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 d84efbff94..8f2e531ac8 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1532,9 +1532,6 @@ const obj = new GraphORM({ meta: { jsonType: true, }, - location: { - jsonType: true, - }, recruiters: { relation: { isMany: true, @@ -1558,6 +1555,13 @@ const obj = new GraphORM({ childColumn: 'opportunityId', }, }, + locations: { + relation: { + isMany: true, + parentColumn: 'id', + childColumn: 'opportunityId', + }, + }, questions: { relation: { isMany: true, @@ -1604,6 +1608,18 @@ const obj = new GraphORM({ }, }, }, + OpportunityLocation: { + requiredColumns: ['id', 'type'], + fields: { + location: { + relation: { + isMany: false, + childColumn: 'id', + parentColumn: 'locationId', + }, + }, + }, + }, OpportunityScreeningQuestion: { from: 'QuestionScreening', requiredColumns: ['questionOrder'], diff --git a/src/migration/1765793807971-AddOpportunityLocationTable.ts b/src/migration/1765793807971-AddOpportunityLocationTable.ts new file mode 100644 index 0000000000..1d7dbf4f44 --- /dev/null +++ b/src/migration/1765793807971-AddOpportunityLocationTable.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOpportunityLocationTable1765793807971 + implements MigrationInterface +{ + name = 'AddOpportunityLocationTable1765793807971'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `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")); COMMENT ON COLUMN "opportunity_location"."type" IS 'LocationType from protobuf schema'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_opportunity_location_opportunityId" ON "opportunity_location" ("opportunityId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_opportunity_location_locationId" ON "opportunity_location" ("locationId") `, + ); + await queryRunner.query(`ALTER TABLE "opportunity" DROP COLUMN "location"`); + await queryRunner.query( + `ALTER TABLE "opportunity_location" ADD CONSTRAINT "FK_opportunity_location_opportunity_opportunityId" FOREIGN KEY ("opportunityId") REFERENCES "opportunity"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "opportunity_location" ADD CONSTRAINT "FK_opportunity_location_dataset_location_locationId" FOREIGN KEY ("locationId") REFERENCES "dataset_location"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "opportunity_location" DROP CONSTRAINT "FK_opportunity_location_dataset_location_locationId"`, + ); + await queryRunner.query( + `ALTER TABLE "opportunity_location" DROP CONSTRAINT "FK_opportunity_location_opportunity_opportunityId"`, + ); + await queryRunner.query( + `ALTER TABLE "opportunity" ADD "location" jsonb DEFAULT '[]'`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_opportunity_location_locationId"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_opportunity_location_opportunityId"`, + ); + await queryRunner.query(`DROP TABLE "opportunity_location"`); + } +} diff --git a/src/schema/autocompletes.ts b/src/schema/autocompletes.ts index db575cc22c..080d42ea77 100644 --- a/src/schema/autocompletes.ts +++ b/src/schema/autocompletes.ts @@ -107,6 +107,15 @@ export const resolvers = traceResolvers({ ): Promise => { const { query } = autocompleteBaseSchema.parse(payload); + return [ + { + id: 'custom-123', + country: 'The Netherlands', + city: 'Amsterdam', + subdivision: 'Noord-Holland', + }, + ]; + try { // Use the new Mapbox client with Garmr integration const data = await mapboxClient.autocomplete(query); diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index b58d5ca675..edfa90f94e 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -88,6 +88,9 @@ import { OrganizationLinkType, SocialMediaType, } from '../common/schema/organizations'; +import { DatasetLocation } from '../entity/dataset/DatasetLocation'; +import { createLocationFromMapbox } 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'; @@ -236,8 +239,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 { @@ -298,7 +305,7 @@ export const typeDefs = /* GraphQL */ ` tldr: String content: OpportunityContent! meta: OpportunityMeta! - location: [Location]! + locations: [OpportunityLocation]! organization: Organization recruiters: [User!]! keywords: [OpportunityKeyword]! @@ -732,6 +739,8 @@ export const typeDefs = /* GraphQL */ ` tldr: String meta: OpportunityMetaInput location: [LocationInput] + externalLocationId: String + locationType: ProtoEnumValue keywords: [OpportunityKeywordInput] content: OpportunityContentInput questions: [OpportunityScreeningQuestionInput!] @@ -1314,13 +1323,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), @@ -1856,6 +1885,8 @@ export const resolvers: IResolvers = traceResolvers< questions, organization, recruiter, + externalLocationId, + locationType, ...opportunityUpdate } = opportunity; @@ -1890,6 +1921,42 @@ export const resolvers: IResolvers = traceResolvers< .setParameter('metaJson', JSON.stringify(opportunity.meta || {})) .execute(); + // Handle location updates + if (externalLocationId !== undefined) { + // If externalLocationId is provided, replace all locations with the new one + await entityManager.getRepository(OpportunityLocation).delete({ + opportunityId: id, + }); + + 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: id, + 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: id }, { type: locationType }); + } + if (organization || organizationImage) { const opportunityJob = await entityManager .getRepository(OpportunityJob) From 060de30de6f8bc06859074ffe6b4641910bc77a1 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Mon, 15 Dec 2025 14:06:53 +0200 Subject: [PATCH 02/13] fix: prompting load --- src/common/opportunity/prompt.ts | 17 +++++++++-------- src/schema/opportunity.ts | 5 ++++- 2 files changed, 13 insertions(+), 9 deletions(-) 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/schema/opportunity.ts b/src/schema/opportunity.ts index edfa90f94e..20ca926f8c 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -2177,6 +2177,9 @@ export const resolvers: IResolvers = traceResolvers< where: { id }, relations: { organization: true, + locations: { + location: true, + }, }, }); @@ -2189,7 +2192,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 }), }), ); }); From 789ab543bc69b74033312b7c8c360bb4d500241b Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Mon, 15 Dec 2025 14:40:04 +0200 Subject: [PATCH 03/13] fix: PR feedback --- src/graphorm/index.ts | 8 +- src/schema/opportunity.ts | 499 +++++++++++++++++++++++--------------- 2 files changed, 298 insertions(+), 209 deletions(-) diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 8f2e531ac8..caf96b4179 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1555,13 +1555,7 @@ const obj = new GraphORM({ childColumn: 'opportunityId', }, }, - locations: { - relation: { - isMany: true, - parentColumn: 'id', - childColumn: 'opportunityId', - }, - }, + questions: { relation: { isMany: true, diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 20ca926f8c..971a690281 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -82,6 +82,7 @@ import { QueryFailedError, type DeepPartial, JsonContains, + EntityManager, } from 'typeorm'; import { Organization } from '../entity/Organization'; import { @@ -1035,6 +1036,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 @@ -1890,23 +2161,7 @@ export const resolvers: IResolvers = traceResolvers< ...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) @@ -1921,195 +2176,35 @@ export const resolvers: IResolvers = traceResolvers< .setParameter('metaJson', JSON.stringify(opportunity.meta || {})) .execute(); - // Handle location updates - if (externalLocationId !== undefined) { - // If externalLocationId is provided, replace all locations with the new one - await entityManager.getRepository(OpportunityLocation).delete({ - opportunityId: id, - }); - - 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: id, - 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: id }, { type: locationType }); - } - - 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 entityManager.getRepository(QuestionScreening).delete({ - id: Not(In(questionIds)), - opportunityId: id, - }); + await handleOpportunityLocationUpdate( + entityManager, + id, + externalLocationId, + locationType, + ctx, + ); - 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 handleOpportunityOrganizationUpdate( + entityManager, + id, + organization, + organizationImage, + ); - 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, - }, - }); + await handleOpportunityKeywordsUpdate(entityManager, id, keywords); - 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) => { From 8045ec9ae13138ebfac483bbbba3daa24c50fbce Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Mon, 15 Dec 2025 14:51:47 +0200 Subject: [PATCH 04/13] fix: add location back --- src/common/opportunity/pubsub.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/common/opportunity/pubsub.ts b/src/common/opportunity/pubsub.ts index aac3ff2276..6185a59e7c 100644 --- a/src/common/opportunity/pubsub.ts +++ b/src/common/opportunity/pubsub.ts @@ -4,6 +4,7 @@ import { CandidateAcceptedOpportunityMessage, CandidatePreferenceUpdated, CandidateRejectedOpportunityMessage, + LocationType, MatchedCandidate, OpportunityMessage, RecruiterAcceptedCandidateMatchMessage, @@ -324,9 +325,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({ @@ -335,18 +335,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( @@ -404,6 +405,12 @@ export const notifyJobOpportunity = async ({ createdAt: getSecondsTimestamp(opportunity.createdAt), updatedAt: getSecondsTimestamp(opportunity.updatedAt), keywords: keywords.map((k) => k.keyword), + location: [ + { + ...locations?.[0]?.location, + type: locations?.[0]?.type, + }, + ], }, organization: { ...organization, From 291e75e66da50564dca0764d33a98a0292b9d81f Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Mon, 15 Dec 2025 14:57:26 +0200 Subject: [PATCH 05/13] fix: add location back --- src/common/opportunity/pubsub.ts | 35 +++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/common/opportunity/pubsub.ts b/src/common/opportunity/pubsub.ts index 6185a59e7c..8bbe2db89c 100644 --- a/src/common/opportunity/pubsub.ts +++ b/src/common/opportunity/pubsub.ts @@ -4,7 +4,6 @@ import { CandidateAcceptedOpportunityMessage, CandidatePreferenceUpdated, CandidateRejectedOpportunityMessage, - LocationType, MatchedCandidate, OpportunityMessage, RecruiterAcceptedCandidateMatchMessage, @@ -399,18 +398,40 @@ export const notifyJobOpportunity = async ({ ...users.map((u) => u.userId), ]); + // Map continent names to their codes + const continentMap: Record = { + Africa: 'AF', + Antarctica: 'AN', + Asia: 'AS', + Europe: 'EU', + 'North America': 'NA', + 'South America': 'SA', + Oceania: 'OC', + }; + + // 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: [ - { - ...locations?.[0]?.location, - type: locations?.[0]?.type, - }, - ], + location: [locationPayload], }, organization: { ...organization, From 0140717d5cde612538b55357f6e944fe285a8890 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 16 Dec 2025 12:02:34 +0200 Subject: [PATCH 06/13] Update src/migration/1765793807971-AddOpportunityLocationTable.ts Co-authored-by: Ole-Martin Bratteng <1681525+omBratteng@users.noreply.github.com> --- ...65793807971-AddOpportunityLocationTable.ts | 78 +++++++++++-------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/src/migration/1765793807971-AddOpportunityLocationTable.ts b/src/migration/1765793807971-AddOpportunityLocationTable.ts index 1d7dbf4f44..b2581ed4e8 100644 --- a/src/migration/1765793807971-AddOpportunityLocationTable.ts +++ b/src/migration/1765793807971-AddOpportunityLocationTable.ts @@ -6,40 +6,54 @@ export class AddOpportunityLocationTable1765793807971 name = 'AddOpportunityLocationTable1765793807971'; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `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")); COMMENT ON COLUMN "opportunity_location"."type" IS 'LocationType from protobuf schema'`, - ); - await queryRunner.query( - `CREATE INDEX "IDX_opportunity_location_opportunityId" ON "opportunity_location" ("opportunityId") `, - ); - await queryRunner.query( - `CREATE INDEX "IDX_opportunity_location_locationId" ON "opportunity_location" ("locationId") `, - ); - await queryRunner.query(`ALTER TABLE "opportunity" DROP COLUMN "location"`); - await queryRunner.query( - `ALTER TABLE "opportunity_location" ADD CONSTRAINT "FK_opportunity_location_opportunity_opportunityId" FOREIGN KEY ("opportunityId") REFERENCES "opportunity"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - await queryRunner.query( - `ALTER TABLE "opportunity_location" ADD 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 */ ` + 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( - `ALTER TABLE "opportunity_location" DROP CONSTRAINT "FK_opportunity_location_dataset_location_locationId"`, - ); - await queryRunner.query( - `ALTER TABLE "opportunity_location" DROP CONSTRAINT "FK_opportunity_location_opportunity_opportunityId"`, - ); - await queryRunner.query( - `ALTER TABLE "opportunity" ADD "location" jsonb DEFAULT '[]'`, - ); - await queryRunner.query( - `DROP INDEX "public"."IDX_opportunity_location_locationId"`, - ); - await queryRunner.query( - `DROP INDEX "public"."IDX_opportunity_location_opportunityId"`, - ); - await queryRunner.query(`DROP TABLE "opportunity_location"`); + await queryRunner.query(/* sql */ ` + ALTER TABLE "opportunity" + ADD "location" jsonb DEFAULT '[]' + `); + + await queryRunner.query(/* sql */ ` + DROP TABLE "opportunity_location" + `); } } From b1a22ff87f695a260a3ea4ce6927eb3c761d0688 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 16 Dec 2025 12:03:38 +0200 Subject: [PATCH 07/13] fix: move continents to root --- src/common/opportunity/pubsub.ts | 13 +------------ src/types.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/common/opportunity/pubsub.ts b/src/common/opportunity/pubsub.ts index 8bbe2db89c..65aeda73ef 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'; @@ -398,17 +398,6 @@ export const notifyJobOpportunity = async ({ ...users.map((u) => u.userId), ]); - // Map continent names to their codes - const continentMap: Record = { - Africa: 'AF', - Antarctica: 'AN', - Asia: 'AS', - Europe: 'EU', - 'North America': 'NA', - 'South America': 'SA', - Oceania: 'OC', - }; - // Check if the location country is a continent and return only continent code const locationData = locations?.[0]; const datasetLocation = locationData ? await locationData.location : null; 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', +}; From aeac074e827abce249aa054a87e12c011cb5ddf0 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 16 Dec 2025 12:12:06 +0200 Subject: [PATCH 08/13] fix: remove console log --- src/graphorm/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 097063e1bb..3b875eb837 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1736,7 +1736,6 @@ const obj = new GraphORM({ ) `, transform: (value: unknown) => { - console.log(value); if (isNullOrUndefined(value)) return []; if (Array.isArray(value)) return value; return [value]; From 7cf6700a69cf51d35f18f7085cad9ce5e315e0dc Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 16 Dec 2025 12:17:21 +0200 Subject: [PATCH 09/13] fix: imports --- src/schema/opportunity.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index b739b8a62c..c803194fea 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -25,8 +25,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, From 2d86eba4e47f89cca1a8308677902c08e163ed22 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 16 Dec 2025 14:11:12 +0200 Subject: [PATCH 10/13] fix: test cases --- __tests__/fixture/opportunity.ts | 90 +++++++++++++++-------- __tests__/schema/opportunity.ts | 122 +++++++++++++++++++------------ src/entity/dataset/utils.ts | 55 ++++++++++++++ src/graphorm/index.ts | 1 - src/schema/opportunity.ts | 61 +++++++++++++--- 5 files changed, 241 insertions(+), 88 deletions(-) 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__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index dc220ba655..a71cfaf5a7 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 { @@ -5299,7 +5321,7 @@ describe('mutation parseOpportunity', () => { const body = res.body; expect(body.errors).toBeFalsy(); - + console.log(body.data.parseOpportunity); expect(body.data.parseOpportunity).toMatchObject({ title: 'Mocked Opportunity Title', tldr: 'This is a mocked TL;DR of the opportunity.', @@ -5332,12 +5354,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: 'San Francisco', + country: 'USA', + subdivision: 'CA', + }, }, ], questions: [], @@ -5445,12 +5469,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: 'San Francisco', + country: 'USA', + subdivision: 'CA', + }, }, ], questions: [], @@ -5621,12 +5647,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: 'San Francisco', + country: 'USA', + subdivision: 'CA', + }, }, ], questions: [], diff --git a/src/entity/dataset/utils.ts b/src/entity/dataset/utils.ts index a5513e6eb7..ebcc16299c 100644 --- a/src/entity/dataset/utils.ts +++ b/src/entity/dataset/utils.ts @@ -20,3 +20,58 @@ export const createLocationFromMapbox = async ( iso3: properties.context?.country?.country_code_alpha_3?.toUpperCase(), }); }; + +/** + * Find an existing location in the dataset_location table based on city, country, and subdivision. + * Falls back to country+subdivision, then country only if more specific matches aren't found. + */ +export const findDatasetLocation = async ( + con: DataSource, + locationData: { + city?: string | null; + country?: string | null; + subdivision?: string | null; + }, +): Promise => { + const { city, country, subdivision } = locationData; + + if (!country) { + return null; + } + + const repo = con.manager.getRepository(DatasetLocation); + + // Try to find exact match: city + country + subdivision + if (city && subdivision) { + const exactMatch = await repo.findOne({ + where: { city, country, subdivision }, + }); + if (exactMatch) return exactMatch; + } + + // Try city + country (subdivision null) + if (city) { + const cityCountryMatch = await repo.findOne({ + where: { city, country }, + }); + if (cityCountryMatch) return cityCountryMatch; + } + + // Try country + subdivision (city null) + if (subdivision) { + const countrySubdivisionMatch = await repo.findOne({ + where: { country, subdivision }, + }); + if (countrySubdivisionMatch) return countrySubdivisionMatch; + } + + // Try country only (city and subdivision null) + const countryOnlyMatch = await repo.findOne({ + where: { country }, + }); + if (countryOnlyMatch) return countryOnlyMatch; + + // Location not found - return null + // We don't create new locations without proper iso2/iso3 codes + return null; +}; diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 3b875eb837..d30c63d3a1 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1556,7 +1556,6 @@ const obj = new GraphORM({ childColumn: 'opportunityId', }, }, - questions: { relation: { isMany: true, diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index c803194fea..a97da8e268 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, @@ -90,7 +91,10 @@ import { SocialMediaType, } from '../common/schema/organizations'; import { DatasetLocation } from '../entity/dataset/DatasetLocation'; -import { createLocationFromMapbox } from '../entity/dataset/utils'; +import { + createLocationFromMapbox, + findDatasetLocation, +} from '../entity/dataset/utils'; import { OpportunityLocation } from '../entity/opportunities/OpportunityLocation'; import { getGondulClient } from '../common/gondul'; import { createOpportunityPrompt } from '../common/opportunity/prompt'; @@ -2232,11 +2236,15 @@ export const resolvers: IResolvers = traceResolvers< ); }); - 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 ( _, @@ -2549,6 +2557,14 @@ export const resolvers: IResolvers = traceResolvers< const opportunityContent = new OpportunityContent(renderedContent); + // Extract and process locations + const locationData = (parsedOpportunity.location || []) as Array<{ + city?: string; + country?: string; + subdivision?: string; + type?: number; + }>; + const opportunityResult = await ctx.con.transaction( async (entityManager) => { const flags: Opportunity['flags'] = {}; @@ -2559,17 +2575,38 @@ 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, { + city: loc.city, + country: loc.country, + subdivision: loc.subdivision, + }); + + if (datasetLocation) { + await entityManager.getRepository(OpportunityLocation).save({ + opportunityId: opportunity.id, + locationId: datasetLocation.id, + type: loc.type || LocationType.REMOTE, + }); + } + } + await addOpportunityDefaultQuestionFeedback({ entityManager, opportunityId: opportunity.id, @@ -2597,11 +2634,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 { From fada170bc86d50eb2d728970358b2b0038fe10dc Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 16 Dec 2025 14:12:31 +0200 Subject: [PATCH 11/13] fix: test cases --- src/schema/autocompletes.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/schema/autocompletes.ts b/src/schema/autocompletes.ts index 080d42ea77..db575cc22c 100644 --- a/src/schema/autocompletes.ts +++ b/src/schema/autocompletes.ts @@ -107,15 +107,6 @@ export const resolvers = traceResolvers({ ): Promise => { const { query } = autocompleteBaseSchema.parse(payload); - return [ - { - id: 'custom-123', - country: 'The Netherlands', - city: 'Amsterdam', - subdivision: 'Noord-Holland', - }, - ]; - try { // Use the new Mapbox client with Garmr integration const data = await mapboxClient.autocomplete(query); From 899c06fc758a67345be50a1f027a5ed6cc4d4faf Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 16 Dec 2025 14:12:55 +0200 Subject: [PATCH 12/13] fix: test cases --- __tests__/schema/opportunity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index a71cfaf5a7..5e0ecdbdac 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -5321,7 +5321,7 @@ describe('mutation parseOpportunity', () => { const body = res.body; expect(body.errors).toBeFalsy(); - console.log(body.data.parseOpportunity); + expect(body.data.parseOpportunity).toMatchObject({ title: 'Mocked Opportunity Title', tldr: 'This is a mocked TL;DR of the opportunity.', From 979569344d362b80385c8a7e1589dbbbbfee120f Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 16 Dec 2025 14:37:21 +0200 Subject: [PATCH 13/13] fix: iso2 lookup only --- __tests__/helpers.ts | 1 + __tests__/schema/opportunity.ts | 15 ++++++---- src/common/schema/opportunities.ts | 1 + src/entity/dataset/utils.ts | 46 ++++++------------------------ src/schema/opportunity.ts | 8 ++---- 5 files changed, 21 insertions(+), 50 deletions(-) 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 5e0ecdbdac..d7610f3e4d 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -5262,6 +5262,9 @@ describe('mutation parseOpportunity', () => { await deleteKeysByPattern(`${rateLimiterName}:*`); + // Ensure dataset locations are available + await saveFixtures(con, DatasetLocation, datasetLocationsFixture); + const transport = createMockBrokkrTransport(); const serviceClient = { @@ -5358,9 +5361,9 @@ describe('mutation parseOpportunity', () => { { type: LocationType.REMOTE, location: { - city: 'San Francisco', + city: null, country: 'USA', - subdivision: 'CA', + subdivision: null, }, }, ], @@ -5473,9 +5476,9 @@ describe('mutation parseOpportunity', () => { { type: LocationType.REMOTE, location: { - city: 'San Francisco', + city: null, country: 'USA', - subdivision: 'CA', + subdivision: null, }, }, ], @@ -5651,9 +5654,9 @@ describe('mutation parseOpportunity', () => { { type: LocationType.REMOTE, location: { - city: 'San Francisco', + city: null, country: 'USA', - subdivision: 'CA', + subdivision: null, }, }, ], diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index d269a96b0d..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(), diff --git a/src/entity/dataset/utils.ts b/src/entity/dataset/utils.ts index ebcc16299c..0f040d801a 100644 --- a/src/entity/dataset/utils.ts +++ b/src/entity/dataset/utils.ts @@ -22,56 +22,26 @@ export const createLocationFromMapbox = async ( }; /** - * Find an existing location in the dataset_location table based on city, country, and subdivision. - * Falls back to country+subdivision, then country only if more specific matches aren't found. + * Find an existing location in the dataset_location table based on iso2 country code. */ export const findDatasetLocation = async ( con: DataSource, locationData: { - city?: string | null; - country?: string | null; - subdivision?: string | null; + iso2?: string | null; }, ): Promise => { - const { city, country, subdivision } = locationData; + const { iso2 } = locationData; - if (!country) { + if (!iso2) { return null; } const repo = con.manager.getRepository(DatasetLocation); - // Try to find exact match: city + country + subdivision - if (city && subdivision) { - const exactMatch = await repo.findOne({ - where: { city, country, subdivision }, - }); - if (exactMatch) return exactMatch; - } - - // Try city + country (subdivision null) - if (city) { - const cityCountryMatch = await repo.findOne({ - where: { city, country }, - }); - if (cityCountryMatch) return cityCountryMatch; - } - - // Try country + subdivision (city null) - if (subdivision) { - const countrySubdivisionMatch = await repo.findOne({ - where: { country, subdivision }, - }); - if (countrySubdivisionMatch) return countrySubdivisionMatch; - } - - // Try country only (city and subdivision null) - const countryOnlyMatch = await repo.findOne({ - where: { country }, + // Find location by iso2 country code + const location = await repo.findOne({ + where: { iso2: iso2.toUpperCase() }, }); - if (countryOnlyMatch) return countryOnlyMatch; - // Location not found - return null - // We don't create new locations without proper iso2/iso3 codes - return null; + return location; }; diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index a97da8e268..b256d6b9c4 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -2559,9 +2559,7 @@ export const resolvers: IResolvers = traceResolvers< // Extract and process locations const locationData = (parsedOpportunity.location || []) as Array<{ - city?: string; - country?: string; - subdivision?: string; + iso2?: string; type?: number; }>; @@ -2593,9 +2591,7 @@ export const resolvers: IResolvers = traceResolvers< // Create OpportunityLocation entries for each location for (const loc of locationData) { const datasetLocation = await findDatasetLocation(ctx.con, { - city: loc.city, - country: loc.country, - subdivision: loc.subdivision, + iso2: loc.iso2, }); if (datasetLocation) {