diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index 6a0c4313f5..dc220ba655 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -1704,7 +1704,7 @@ describe('query getCandidatePreferences', () => { lastModified: new Date('2024-10-10T10:00:00Z'), }, salaryExpectation: { min: '50000', period: SalaryPeriod.ANNUAL }, - location: [ + customLocation: [ { country: 'Norway' }, { city: 'London', country: 'UK', continent: 'Europe' }, ], @@ -1912,7 +1912,6 @@ describe('mutation updateCandidatePreferences', () => { roleType: 1.0, employmentType: [1, 3], salaryExpectation: { min: 70000, period: 1 }, - location: [{ city: 'Berlin', country: 'Germany' }], locationType: [1, 2], customKeywords: true, }, @@ -1932,7 +1931,6 @@ describe('mutation updateCandidatePreferences', () => { roleType: 1.0, employmentType: [1, 3], // FULL_TIME, CONTRACT salaryExpectation: { min: '70000', period: 1 }, // ANNUAL - location: [{ city: 'Berlin', country: 'Germany' }], locationType: [1, 2], // REMOTE, ONSITE customKeywords: true, }); diff --git a/__tests__/workers/cdc/primary.ts b/__tests__/workers/cdc/primary.ts index a31db2a5aa..a3c9683c51 100644 --- a/__tests__/workers/cdc/primary.ts +++ b/__tests__/workers/cdc/primary.ts @@ -6809,7 +6809,7 @@ describe('user_candidate_preference', () => { min: 100000, currency: 'EUR', }, - location: [ + customLocation: [ { type: 1, // LocationType.REMOTE country: 'Germany', diff --git a/src/common/opportunity/pubsub.ts b/src/common/opportunity/pubsub.ts index aac3ff2276..c2ae1e00b2 100644 --- a/src/common/opportunity/pubsub.ts +++ b/src/common/opportunity/pubsub.ts @@ -84,27 +84,27 @@ export const notifyOpportunityMatchAccepted = async ({ return; } - const { match, candidatePreference, keywords } = await queryReadReplica( - con, - async ({ queryRunner }) => { + const { match, candidatePreference, keywords, locationData } = + await con.transaction(async (manager) => { const [match, candidatePreference] = await Promise.all([ - queryRunner.manager.getRepository(OpportunityMatch).findOneBy({ + manager.getRepository(OpportunityMatch).findOneBy({ opportunityId: data.opportunityId, userId: data.userId, }), - queryRunner.manager + manager .getRepository(UserCandidatePreference) - .findOneBy({ userId: data.userId }), + .findOne({ where: { userId: data.userId }, relations: ['location'] }), ]); const keywords = await fetchCandidateKeywords( - queryRunner.manager, + manager, candidatePreference, ); - return { match, candidatePreference, keywords }; - }, - ); + const locationData = await candidatePreference?.location; + + return { match, candidatePreference, keywords, locationData }; + }); if (!match) { logger.warn( @@ -127,6 +127,19 @@ export const notifyOpportunityMatchAccepted = async ({ ? new Date(candidatePreference.cv.lastModified) : candidatePreference.cv.lastModified; + // Prioritize relational location over customLocation + const locationArray = locationData + ? [ + { + ...locationData, + // Convert null to undefined for protobuf compatibility + subdivision: locationData.subdivision ?? undefined, + city: locationData.city ?? undefined, + externalId: locationData.externalId ?? undefined, + }, + ] + : candidatePreference.customLocation || []; + const message = new CandidateAcceptedOpportunityMessage({ opportunityId: match.opportunityId, userId: match.userId, @@ -135,6 +148,7 @@ export const notifyOpportunityMatchAccepted = async ({ screening: match.screening, candidatePreference: { ...candidatePreference, + location: locationArray, salaryExpectation: new Salary({ min: candidatePreference.salaryExpectation?.min ? BigInt(candidatePreference.salaryExpectation.min) @@ -430,19 +444,20 @@ export const notifyCandidatePreferenceChange = async ({ logger: FastifyBaseLogger; userId: string; }) => { - const { candidatePreference, keywords } = await queryReadReplica( - con, - async ({ queryRunner }) => { - const candidatePreference = await queryRunner.manager + const { candidatePreference, keywords, locationData } = await con.transaction( + async (manager) => { + const candidatePreference = await manager .getRepository(UserCandidatePreference) - .findOneBy({ userId: userId }); + .findOne({ where: { userId: userId }, relations: ['location'] }); const keywords = await fetchCandidateKeywords( - queryRunner.manager, + manager, candidatePreference, ); - return { candidatePreference, keywords }; + const locationData = await candidatePreference?.location; + + return { candidatePreference, keywords, locationData }; }, ); @@ -459,9 +474,23 @@ export const notifyCandidatePreferenceChange = async ({ ? new Date(candidatePreference.cv.lastModified) : candidatePreference?.cv?.lastModified; + // Prioritize relational location over customLocation + const locationArray = locationData + ? [ + { + ...locationData, + // Convert null to undefined for protobuf compatibility + subdivision: locationData.subdivision ?? undefined, + city: locationData.city ?? undefined, + externalId: locationData.externalId ?? undefined, + }, + ] + : candidatePreference.customLocation || []; + const message = new CandidatePreferenceUpdated({ payload: { ...candidatePreference, + location: locationArray, salaryExpectation: new Salary({ min: candidatePreference.salaryExpectation?.min ? BigInt(candidatePreference.salaryExpectation.min) diff --git a/src/common/schema/userCandidate.ts b/src/common/schema/userCandidate.ts index 0312a00875..695609837e 100644 --- a/src/common/schema/userCandidate.ts +++ b/src/common/schema/userCandidate.ts @@ -68,6 +68,10 @@ export const candidatePreferenceSchema = z.object({ }), ) .optional(), + externalLocationId: z.preprocess( + (val) => (val === '' ? null : val), + z.string().nullish().default(null), + ), locationType: z .array( z diff --git a/src/entity/user/UserCandidatePreference.ts b/src/entity/user/UserCandidatePreference.ts index e95106a8b0..e75ca5b106 100644 --- a/src/entity/user/UserCandidatePreference.ts +++ b/src/entity/user/UserCandidatePreference.ts @@ -4,6 +4,7 @@ import { Entity, Index, JoinColumn, + ManyToOne, OneToOne, PrimaryColumn, UpdateDateColumn, @@ -23,6 +24,7 @@ import type { UserCandidateCV, } from '../../common/schema/userCandidate'; import { listAllProtoEnumValues } from '../../common'; +import type { DatasetLocation } from '../dataset/DatasetLocation'; export type SalaryExpectation = z.infer; @@ -83,9 +85,21 @@ export class UserCandidatePreference { @Column({ type: 'jsonb', default: [], - comment: 'Location from protobuf schema', + comment: 'Custom location from protobuf schema (legacy)', }) - location: Array = []; + customLocation: Array = []; + + @Column({ type: 'text', nullable: true, default: null }) + @Index('IDX_user_candidate_preference_locationId') + locationId: string | null; + + @ManyToOne('DatasetLocation', { lazy: true, onDelete: 'SET NULL' }) + @JoinColumn({ + name: 'locationId', + foreignKeyConstraintName: + 'FK_user_candidate_preference_dataset_location_locationId', + }) + location: Promise | null; @Column({ type: 'integer', diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index d84efbff94..b052aea0f1 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -76,6 +76,7 @@ import type { OpportunityFlagsPublic, } from '../entity/opportunities/Opportunity'; import { SubscriptionStatus } from '../common/plus'; +import { isNullOrUndefined } from '../common/object'; const existsByUserAndPost = (entity: string, build?: (queryBuilder: QueryBuilder) => QueryBuilder) => @@ -1704,6 +1705,32 @@ const obj = new GraphORM({ }, location: { jsonType: true, + select: (_, alias) => ` + COALESCE( + CASE + WHEN ${alias}."locationId" IS NOT NULL THEN + ( + SELECT jsonb_build_array( + jsonb_build_object( + 'city', dl.city, + 'subdivision', dl.subdivision, + 'country', dl.country + ) + ) + FROM dataset_location dl + WHERE dl.id = ${alias}."locationId" + ) + ELSE NULL + END, + ${alias}."customLocation" + ) + `, + transform: (value: unknown) => { + console.log(value); + if (isNullOrUndefined(value)) return []; + if (Array.isArray(value)) return value; + return [value]; + }, }, keywords: { relation: { @@ -1833,18 +1860,22 @@ const obj = new GraphORM({ }, location: { select: (_, alias) => ` - COALESCE( - ( - SELECT jsonb_build_object( - 'city', location->0->>'city', - 'subdivision', location->0->>'subdivision', - 'country', location->0->>'country' - ) - FROM user_candidate_preference - WHERE "userId" = ${alias}.id - LIMIT 1 - ), - ${alias}.flags + ( + SELECT COALESCE( + ( + SELECT jsonb_build_object( + 'city', dl.city, + 'subdivision', dl.subdivision, + 'country', dl.country + ) + FROM dataset_location dl + WHERE dl.id = ucp."locationId" + ), + ucp."customLocation"->0 + ) + FROM user_candidate_preference ucp + WHERE ucp."userId" = ${alias}.id + LIMIT 1 ) `, transform: (data: Record): string | null => { diff --git a/src/migration/1765804151420-1734249600000-MigrateUserCandidatePreferenceLocation.ts b/src/migration/1765804151420-1734249600000-MigrateUserCandidatePreferenceLocation.ts new file mode 100644 index 0000000000..80d64378b7 --- /dev/null +++ b/src/migration/1765804151420-1734249600000-MigrateUserCandidatePreferenceLocation.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MigrateUserCandidatePreferenceLocation1765804151420 + implements MigrationInterface +{ + name = 'MigrateUserCandidatePreferenceLocation1765804151420'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "user_candidate_preference" + RENAME COLUMN "location" TO "customLocation" + `); + + await queryRunner.query(/* sql */ ` + COMMENT ON COLUMN "user_candidate_preference"."customLocation" IS 'Custom location from protobuf schema (legacy)' + `); + + await queryRunner.query(/* sql */ ` + ALTER TABLE "user_candidate_preference" + ADD "locationId" uuid + `); + + await queryRunner.query(/* sql */ ` + CREATE INDEX IF NOT EXISTS "IDX_user_candidate_preference_locationId" + ON "user_candidate_preference" ("locationId") + `); + + await queryRunner.query(/* sql */ ` + ALTER TABLE "user_candidate_preference" + ADD CONSTRAINT "FK_user_candidate_preference_dataset_location_locationId" + FOREIGN KEY ("locationId") + REFERENCES "dataset_location"("id") + ON DELETE SET NULL + ON UPDATE NO ACTION + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "user_candidate_preference" + DROP CONSTRAINT "FK_user_candidate_preference_dataset_location_locationId" + `); + + await queryRunner.query(/* sql */ ` + DROP INDEX IF EXISTS "public"."IDX_user_candidate_preference_locationId" + `); + + await queryRunner.query(/* sql */ ` + ALTER TABLE "user_candidate_preference" + DROP COLUMN "locationId" + `); + + await queryRunner.query(/* sql */ ` + ALTER TABLE "user_candidate_preference" + RENAME COLUMN "customLocation" TO "location" + `); + + await queryRunner.query(/* sql */ ` + COMMENT ON COLUMN "user_candidate_preference"."location" IS 'Location from protobuf schema' + `); + } +} diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index b58d5ca675..3624a9b188 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -25,6 +25,8 @@ 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, @@ -138,8 +140,9 @@ export interface GQLOpportunityMatch export interface GQLUserCandidatePreference extends Omit< UserCandidatePreference, - 'userId' | 'user' | 'updatedAt' | 'cvParsed' + 'userId' | 'user' | 'updatedAt' | 'cvParsed' | 'location' > { + location?: Array; keywords?: Array<{ keyword: string }>; } @@ -762,6 +765,7 @@ export const typeDefs = /* GraphQL */ ` employmentType: [ProtoEnumValue] salaryExpectation: SalaryExpectationInput location: [LocationInput] + externalLocationId: String locationType: [ProtoEnumValue] customKeywords: Boolean ): EmptyResponse @auth @@ -1103,6 +1107,7 @@ export const resolvers: IResolvers = traceResolvers< return { ...new UserCandidatePreference(), + location: [], keywords: [], }; }, @@ -1502,10 +1507,32 @@ export const resolvers: IResolvers = traceResolvers< throw preferences.error; } + // Handle externalLocationId -> locationId mapping + let location: DatasetLocation | null = null; + if (preferences.data.externalLocationId) { + location = await con.getRepository(DatasetLocation).findOne({ + where: { externalId: preferences.data.externalLocationId }, + }); + + if (!location) { + location = await createLocationFromMapbox( + con, + preferences.data.externalLocationId, + ); + } + } + await con.getRepository(UserCandidatePreference).upsert( { userId, - ...preferences.data, + status: preferences.data.status, + role: preferences.data.role, + roleType: preferences.data.roleType, + employmentType: preferences.data.employmentType, + salaryExpectation: preferences.data.salaryExpectation, + locationType: preferences.data.locationType, + customKeywords: preferences.data.customKeywords, + locationId: location?.id ?? null, }, { conflictPaths: ['userId'],