diff --git a/AGENTS.md b/AGENTS.md index cfadaf021d..699a9e2647 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,10 @@ The migration generator compares entities against the local database schema. Ens **Type Safety & Validation:** - We favor type safety throughout the codebase. Use TypeScript interfaces and types for compile-time type checking. - **Zod schemas** are preferred for runtime validation, especially for input validation, API boundaries, and data parsing. Zod provides both type inference and runtime validation, making it ideal for verifying user input, API payloads, and external data sources. +- **This project uses Zod 4.x** (currently 4.3.5). Be aware of API differences from Zod 3.x: + - `z.literal([...])` in Zod 4.x supports arrays and validates that the value matches one of the array elements + - For enum-like validation of string literals, both `z.literal([...])` and `z.enum([...])` work in Zod 4.x + - Always consult the [Zod 4.x documentation](https://zod.dev) for the latest API - When possible, prefer Zod schemas over manual validation as they provide type safety, better error messages, and can be inferred to TypeScript types. **Business Domains:** diff --git a/__tests__/schema/autocompletes.ts b/__tests__/schema/autocompletes.ts index f35f49803a..f61fb80cc7 100644 --- a/__tests__/schema/autocompletes.ts +++ b/__tests__/schema/autocompletes.ts @@ -1009,15 +1009,18 @@ describe('query autocompleteLocation', () => { it('should return Europe as a country', async () => { loggedUser = '1'; - await con.getRepository(DatasetLocation).save({ - id: '550e8400-e29b-41d4-a716-446655440006', - country: 'Europe', - subdivision: null, - city: null, - iso2: 'EU', - iso3: 'EUR', - externalId: 'eu1', - }); + await con.getRepository(DatasetLocation).save( + con.getRepository(DatasetLocation).create({ + id: '550e8400-e29b-41d4-a716-446655440006', + continent: 'Europe', + country: null, + subdivision: null, + city: null, + iso2: null, + iso3: null, + externalId: 'eu1', + }), + ); const res = await client.query(QUERY_WITH_DATASET, { variables: { query: 'europe', dataset: 'internal' }, diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index 6589e20e90..5aed39fca1 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -5629,9 +5629,7 @@ describe('mutation parseOpportunity', () => { await saveFixtures(con, DatasetLocation, [ { - country: 'Europe', - iso2: 'EU', - iso3: 'EUR', + continent: 'Europe', }, ]); @@ -6250,7 +6248,7 @@ describe('query opportunityPreview', () => { flags: {}, id: '550e8400-e29b-41d4-a716-446655440001', keywords: ['webdev', 'fullstack', 'Fortune 500'], - location: [{ country: 'Norway', iso2: 'NO' }], + location: [{ country: 'Norway', iso2: 'NO', type: LocationType.REMOTE }], meta: { employmentType: EmploymentType.FULL_TIME, equity: true, @@ -6271,6 +6269,90 @@ describe('query opportunityPreview', () => { updatedAt: expect.any(Number), }); }); + + it('should send valid locations data for continent matched opportunity', async () => { + await con.getRepository(OpportunityJob).update( + { id: opportunitiesFixture[0].id }, + { + flags: { + anonUserId: 'test-anon-user-123', + }, + }, + ); + + const continentLocation = await con.getRepository(DatasetLocation).save( + con.getRepository(DatasetLocation).create({ + continent: 'Europe', + }), + ); + + await con.getRepository(OpportunityLocation).delete({ + opportunityId: opportunitiesFixture[0].id, + }); + + await con.getRepository(OpportunityLocation).save( + con.getRepository(OpportunityLocation).create({ + opportunityId: opportunitiesFixture[0].id, + type: LocationType.REMOTE, + locationId: continentLocation.id, + }), + ); + + const opportunityPreviewSpy = jest.spyOn( + gondulModule.getGondulOpportunityServiceClient().instance, + 'preview', + ); + + const res = await client.query(OPPORTUNITY_PREVIEW_QUERY, { + variables: { first: 10 }, + }); + + expect(res.errors).toBeFalsy(); + + expect(opportunityPreviewSpy).toHaveBeenCalledTimes(1); + + const { id, location } = opportunityPreviewSpy.mock.calls[0][0]; + + expect({ id, location }).toEqual({ + id: '550e8400-e29b-41d4-a716-446655440001', + location: [{ continent: 'Europe', type: LocationType.REMOTE }], + }); + }); + + it('should default to US location when opportunity has no locations', async () => { + await con.getRepository(OpportunityJob).update( + { id: opportunitiesFixture[0].id }, + { + flags: { + anonUserId: 'test-anon-user-123', + }, + }, + ); + + await con.getRepository(OpportunityLocation).delete({ + opportunityId: opportunitiesFixture[0].id, + }); + + const opportunityPreviewSpy = jest.spyOn( + gondulModule.getGondulOpportunityServiceClient().instance, + 'preview', + ); + + const res = await client.query(OPPORTUNITY_PREVIEW_QUERY, { + variables: { first: 10 }, + }); + + expect(res.errors).toBeFalsy(); + + expect(opportunityPreviewSpy).toHaveBeenCalledTimes(1); + + const { id, location } = opportunityPreviewSpy.mock.calls[0][0]; + + expect({ id, location }).toEqual({ + id: '550e8400-e29b-41d4-a716-446655440001', + location: [{ iso2: 'US', country: 'United States' }], + }); + }); }); describe('query opportunityStats', () => { diff --git a/src/common/opportunity/parse.ts b/src/common/opportunity/parse.ts index ae29c5a9cc..1c94021648 100644 --- a/src/common/opportunity/parse.ts +++ b/src/common/opportunity/parse.ts @@ -218,9 +218,9 @@ export async function parseOpportunityWithBrokkr({ loc.continent?.toLowerCase().trim() === 'europe' ) { return { - ...loc, - country: 'Europe', - iso2: 'EU', + continent: 'Europe', + country: undefined, + iso2: undefined, }; } @@ -234,6 +234,13 @@ export async function parseOpportunityWithBrokkr({ return loc; }) .filter((loc) => { + const isContinent = loc.continent && !loc.country && !loc.iso2; + + // continent has specific handling in gondul and does not have iso2 code + if (isContinent) { + return true; + } + // Only keep locations with valid country and iso2 // Both are required for downstream processing in gondul return ( diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index 3e2b7c32a9..c4d9a4687a 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -126,6 +126,37 @@ export const opportunityCreateParseSchema = opportunityCreateSchema.extend({ .optional() .default({}), content: opportunityContentSchema.partial().optional().default({}), + location: z + .array( + z + .object({ + country: z.string().nonempty().max(240), + continent: z.literal([ + 'Africa', + 'Antarctica', + 'Asia', + 'Europe', + 'North America', + 'Oceania', + 'South America', + ]), + city: z.string().nonempty().max(240), + subdivision: z.string().nonempty().max(240), + type: z.coerce.number().min(1), + iso2: z.string().nonempty().max(2), + }) + .partial(), + ) + .optional() + .refine( + (locations) => { + return !!locations?.every((loc) => loc.continent || loc.country); + }, + { + error: + 'Each location needs to have either country or continent defined', + }, + ), }); export const opportunityEditSchema = z diff --git a/src/entity/dataset/DatasetLocation.ts b/src/entity/dataset/DatasetLocation.ts index 2a0a592a94..059e392d41 100644 --- a/src/entity/dataset/DatasetLocation.ts +++ b/src/entity/dataset/DatasetLocation.ts @@ -2,33 +2,38 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; @Entity() @Index( - 'IDX_location_country_subdivision_city_unique', - ['country', 'subdivision', 'city'], + 'IDX_location_country_subdivision_city_continent_unique', + ['country', 'subdivision', 'city', 'continent'], { unique: true }, ) @Index('IDX_dataset_location_country_trgm', { synchronize: false }) @Index('IDX_dataset_location_city_trgm', { synchronize: false }) @Index('IDX_dataset_location_subdivision_trgm', { synchronize: false }) +@Index('IDX_dataset_location_continent_trgm', { synchronize: false }) export class DatasetLocation { @PrimaryGeneratedColumn('uuid', { primaryKeyConstraintName: 'PK_dataset_location_id', }) id: string; - @Column() + @Column({ nullable: true }) country: string; + // CHK_dataset_location_country_or_continent constraint ensures either country OR continent is NOT NULL + @Column({ nullable: true }) + continent: string; + @Column({ type: 'text', nullable: true }) subdivision: string | null; @Column({ type: 'text', nullable: true }) city: string | null; - @Column() + @Column({ nullable: true }) @Index('IDX_dataset_location_iso2') iso2: string; - @Column() + @Column({ nullable: true }) @Index('IDX_dataset_location_iso3') iso3: string; diff --git a/src/entity/dataset/utils.ts b/src/entity/dataset/utils.ts index e0c1177d15..455c5ce81f 100644 --- a/src/entity/dataset/utils.ts +++ b/src/entity/dataset/utils.ts @@ -48,9 +48,24 @@ export const findOrCreateDatasetLocation = async ( */ export const findDatasetLocation = async ( con: DataSource, - locationData: Partial>, + locationData: Partial< + Pick + >, ): Promise => { - const { iso2 } = locationData; + const { iso2, continent } = locationData; + + // match continent only locations + if (!iso2 && continent) { + const continentQuery = con.manager + .getRepository(DatasetLocation) + .createQueryBuilder() + .where('continent = :continentOnly', { continentOnly: continent }) + .andWhere('country IS NULL'); + + const continentLocation = await continentQuery.getOne(); + + return continentLocation; + } if (!iso2) { return null; diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index a432f06f5b..9848ba7777 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1771,6 +1771,12 @@ const obj = new GraphORM({ }, Location: { from: 'DatasetLocation', + fields: { + // to not break existing implementations frontend only knows about country (which is like a name for dataset location) + country: { + select: (_, alias) => `COALESCE(${alias}.country, ${alias}.continent)`, + }, + }, }, UserExperience: { requiredColumns: ['userId'], diff --git a/src/migration/1767884509731-DatasetLocationContinent.ts b/src/migration/1767884509731-DatasetLocationContinent.ts new file mode 100644 index 0000000000..4379d0945e --- /dev/null +++ b/src/migration/1767884509731-DatasetLocationContinent.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DatasetLocationContinent1767884509731 implements MigrationInterface { + name = 'DatasetLocationContinent1767884509731'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "dataset_location" ADD "continent" character varying`, + ); + + await queryRunner.query( + `ALTER TABLE "dataset_location" ALTER COLUMN "country" DROP NOT NULL`, + ); + + await queryRunner.query( + `ALTER TABLE "dataset_location" ALTER COLUMN "iso2" DROP NOT NULL`, + ); + + await queryRunner.query( + `ALTER TABLE "dataset_location" ALTER COLUMN "iso3" DROP NOT NULL`, + ); + + await queryRunner.query( + `ALTER TABLE "dataset_location" ADD CONSTRAINT "CHK_dataset_location_country_or_continent" CHECK ("country" IS NOT NULL OR "continent" IS NOT NULL)`, + ); + + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_location_country_subdivision_city_unique"`, + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_location_country_subdivision_city_continent_unique" ON "dataset_location" ("country", "subdivision", "city", "continent")`, + ); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_dataset_location_continent_trgm" ON "dataset_location" USING gin ("continent" gin_trgm_ops)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_dataset_location_continent_trgm"`, + ); + + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_location_country_subdivision_city_continent_unique"`, + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_location_country_subdivision_city_unique" ON "dataset_location" ("country", "subdivision", "city")`, + ); + + await queryRunner.query( + `ALTER TABLE "dataset_location" DROP CONSTRAINT "CHK_dataset_location_country_or_continent"`, + ); + + await queryRunner.query( + `ALTER TABLE "dataset_location" ALTER COLUMN "iso3" SET NOT NULL`, + ); + + await queryRunner.query( + `ALTER TABLE "dataset_location" ALTER COLUMN "iso2" SET NOT NULL`, + ); + + await queryRunner.query( + `ALTER TABLE "dataset_location" ALTER COLUMN "country" SET NOT NULL`, + ); + + await queryRunner.query( + `ALTER TABLE "dataset_location" DROP COLUMN "continent"`, + ); + } +} diff --git a/src/schema/autocompletes.ts b/src/schema/autocompletes.ts index 7b1cb8e6f1..293c1fc94b 100644 --- a/src/schema/autocompletes.ts +++ b/src/schema/autocompletes.ts @@ -122,6 +122,7 @@ export const resolvers = traceResolvers({ .where('dl.country ILIKE :query', { query: `%${query}%` }) .orWhere('dl.city ILIKE :query', { query: `%${query}%` }) .orWhere('dl.subdivision ILIKE :query', { query: `%${query}%` }) + .orWhere('dl.continent ILIKE :query', { query: `%${query}%` }) .orderBy('dl.country', 'ASC') .addOrderBy('dl.subdivision', 'ASC') .addOrderBy('dl.city', 'ASC') @@ -132,7 +133,7 @@ export const resolvers = traceResolvers({ return results.map((location) => ({ // we map externalId to match mapbox IDs when in external dataset mode id: location.externalId, - country: location.country, + country: location.country || location.continent, city: location.city, subdivision: location.subdivision, })); diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index cee03bb161..c460f651fc 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -1514,10 +1514,35 @@ export const resolvers: IResolvers = traceResolvers< const locations = await Promise.all( opportunityLocations.map(async (ol) => { - return ol.location; + const datasetLocation = await ol.location; + + if (!datasetLocation.country && datasetLocation.continent) { + return new LocationMessage({ + type: ol.type, + continent: datasetLocation.continent, + }); + } + + return new LocationMessage({ + ...datasetLocation, + type: ol.type, + city: datasetLocation.city || undefined, + subdivision: datasetLocation.subdivision || undefined, + country: datasetLocation.country || undefined, + }); }), ); + // Default to US location if no locations are specified + if (locations.length === 0) { + locations.push( + new LocationMessage({ + iso2: 'US', + country: 'United States', + }), + ); + } + const opportunityMessage = new OpportunityMessage({ id: opportunity.id, createdAt: getSecondsTimestamp(opportunity.createdAt), @@ -1528,14 +1553,7 @@ export const resolvers: IResolvers = traceResolvers< tldr: opportunity.tldr, content: opportunityContent, meta: opportunity.meta, - location: locations.map((item) => { - return new LocationMessage({ - ...item, - city: item.city || undefined, - subdivision: item.subdivision || undefined, - country: item.country || undefined, - }); - }), + location: locations, keywords: keywords.map((k) => k.keyword), flags: opportunity.flags, });