Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
21 changes: 12 additions & 9 deletions __tests__/schema/autocompletes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
90 changes: 86 additions & 4 deletions __tests__/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5629,9 +5629,7 @@ describe('mutation parseOpportunity', () => {

await saveFixtures(con, DatasetLocation, [
{
country: 'Europe',
iso2: 'EU',
iso3: 'EUR',
continent: 'Europe',
},
]);

Expand Down Expand Up @@ -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,
Expand All @@ -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', () => {
Expand Down
13 changes: 10 additions & 3 deletions src/common/opportunity/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand All @@ -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 (
Expand Down
31 changes: 31 additions & 0 deletions src/common/schema/opportunities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions src/entity/dataset/DatasetLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
19 changes: 17 additions & 2 deletions src/entity/dataset/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,24 @@ export const findOrCreateDatasetLocation = async (
*/
export const findDatasetLocation = async (
con: DataSource,
locationData: Partial<Pick<DatasetLocation, 'iso2' | 'city' | 'subdivision'>>,
locationData: Partial<
Pick<DatasetLocation, 'iso2' | 'city' | 'subdivision' | 'continent'>
>,
): Promise<DatasetLocation | null> => {
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;
Expand Down
6 changes: 6 additions & 0 deletions src/graphorm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
73 changes: 73 additions & 0 deletions src/migration/1767884509731-DatasetLocationContinent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class DatasetLocationContinent1767884509731 implements MigrationInterface {
name = 'DatasetLocationContinent1767884509731';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`,
);
}
}
3 changes: 2 additions & 1 deletion src/schema/autocompletes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const resolvers = traceResolvers<unknown, BaseContext>({
.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')
Expand All @@ -132,7 +133,7 @@ export const resolvers = traceResolvers<unknown, BaseContext>({
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,
}));
Expand Down
Loading
Loading