Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
112 changes: 85 additions & 27 deletions common/school/externalSearch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
import { school_state_enum as State, pupil_schooltype_enum as SchoolType } from '@prisma/client';
import { getLogger } from '../logger/logger';
import { SchoolTypeMap } from './schoolType';
import { getStateFromZip } from '../util/stateMappings';
import { SchoolTypeMap, schoolTypeSearchStrings } from './schoolType';

interface GooglePlaceSuggestion {
placePrediction: {
placeId: string;
text: {
text: string;
};
};
}

interface GooglePlaceDetails {
id: string;
displayName: {
text: string;
};
shortFormattedAddress: string;
addressComponents: {
longText: string;
shortText: string;
types: string[];
}[];
googleMapsTypeLabel: {
text: string;
};
}

export interface ExternalSchool {
id: string;
Expand All @@ -13,46 +39,53 @@ export interface ExternalSchool {

export interface SchoolResult extends Omit<ExternalSchool, 'school_type'> {
schooltype?: SchoolType;
state: State;
state?: State;
}

const logger = getLogger('Jedeschule');

const getStateFromExternalSchool = (school?: ExternalSchool) => {
const state = school?.id.substring(0, 2).toLowerCase();
const isValid = Object.values(State).includes(state as State);
if (!isValid) {
/**
* The state is always the first part of the school id, if for some reason the state doesn't match
* we need the heads up about that.
*/
const msg = 'Could not get state from school';
logger.error(msg, new Error(msg), school);
return;
const logger = getLogger('ExternalSchoolSearch');

const getSchoolTypeFromExternalSchool = ({ name, schoolType }: { name: string; schoolType?: string }) => {
// first check if the schoolType from google maps can be directly mapped to our SchoolType enum
const schoolTypeFromMap = schoolType ? SchoolTypeMap[schoolType.toLowerCase()] : undefined;
if (schoolTypeFromMap) {
return schoolTypeFromMap;
}
return state as State;
};

const getSchoolTypeFromExternalSchool = (school?: ExternalSchool) => {
const schoolType = school?.school_type?.toLowerCase();
if (!schoolType) {
return;
// if not, use the school name and check if it contains any of the search strings defined for each school type
for (const [type, searchStrings] of Object.entries(schoolTypeSearchStrings) as [SchoolType, string[]][]) {
for (const str of searchStrings) {
const regex = new RegExp(`\\b${str}\\b`, 'i');
if (regex.test(name.toLowerCase())) {
return type;
}
}
}
return SchoolTypeMap[schoolType];
};

interface SearchSchoolsArgs {
filters: { name: string };
options: { limit: number };
}

export const searchExternalSchools = async (params: SearchSchoolsArgs): Promise<SchoolResult[]> => {
const { filters, options } = params;
const { filters } = params;
try {
const response = await fetch(`https://jedeschule.codefor.de/schools/?limit=${Math.min(options.limit, 50)}&include_raw=false&name=${filters.name}`);
const response = await fetch(`https://places.googleapis.com/v1/places:autocomplete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': process.env.GOOGLE_PLACES_KEY || '',
},
body: JSON.stringify({
input: filters.name,
includedPrimaryTypes: ['preschool', 'primary_school', 'school', 'secondary_school', 'university'],
includedRegionCodes: ['de'],
languageCode: 'de',
regionCode: 'de',
}),
});
if (response.ok) {
const schools = (await response.json()) as ExternalSchool[];
return schools.map((school) => ({ ...school, state: getStateFromExternalSchool(school), schooltype: getSchoolTypeFromExternalSchool(school) }));
const suggestions = ((await response.json()) as { suggestions: GooglePlaceSuggestion[] }).suggestions ?? [];
return suggestions.map((school) => ({ id: school.placePrediction.placeId, name: school.placePrediction.text.text }));
}
const responseText = await response.text();
throw new Error(`Failed to fetch external schools due to ${responseText} - Status: ${response.status}`);
Expand All @@ -61,3 +94,28 @@ export const searchExternalSchools = async (params: SearchSchoolsArgs): Promise<
throw error;
}
};

export const getSchoolDetails = async (placeId: string): Promise<SchoolResult> => {
try {
const response = await fetch(
`https://places.googleapis.com/v1/places/${placeId}?key=${process.env.GOOGLE_PLACES_KEY}&fields=id,displayName,shortFormattedAddress,addressComponents,googleMapsTypeLabel&languageCode=de`
);
if (response.ok) {
const school = (await response.json()) as GooglePlaceDetails;
const zip = school.addressComponents.find((comp) => comp.types.includes('postal_code'))?.longText;
return {
id: school.id,
name: `${school.displayName.text}${school.shortFormattedAddress ? `, ${school.shortFormattedAddress}` : ''}`,
city: school.addressComponents.find((comp) => comp.types.includes('locality'))?.longText,
zip,
state: getStateFromZip(zip ? Number(zip) : undefined) as State | undefined,
schooltype: getSchoolTypeFromExternalSchool({ name: school.displayName.text, schoolType: school.googleMapsTypeLabel.text }),
};
}
const responseText = await response.text();
throw new Error(`Failed to fetch external school details due to ${responseText} - Status: ${response.status}`);
} catch (error) {
logger.error('Error fetching external school details', error, { placeId });
throw error;
}
};
26 changes: 26 additions & 0 deletions common/school/schoolType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,29 @@ export const SchoolTypeMap: Record<string, SchoolType> = {
privatschule: 'privatschule',
other: 'other',
};

export const schoolTypeSearchStrings: Record<SchoolType, string[]> = {
grundschule: ['grundschule'],
berufsschule: ['berufsschule'],
mittelschule: ['mittelschule', 'mittelstufenschule'],
sekundarschule: ['sekundarschule'],
stadtteilschule: ['stadtteilschule'],
berufsfachschule: ['berufsfachschule', 'bfs'],
fachoberschule: ['fachoberschule', 'fos'],
berufsoberschule: ['berufsoberschule', 'bos'],
oberstufenzentrum: ['oberstufenzentrum'],
abendschule_vhs: ['abendschule', 'vhs', 'fernstudium', 'abendgymnasium', 'weiterbildungskolleg'],
berufskolleg: ['berufskolleg'],
oberschule: ['oberschule'],
f_rderschule: ['förderschule'],
beruflichesgymnasium: ['beruflichesgymnasium', 'fachgymnasium'],
uni_studienkolleg: ['studienkolleg', 'universität'],
fachschule: ['fachschule'],
hauptschule: ['hauptschule'],
gesamtschule: ['gesamtschule', 'integrierte sekundarschule'],
realschule: ['realschule'],
gymnasium: ['gymnasium'],
auslandsschule: [],
privatschule: ['montessori', 'waldorf', 'privatschule', 'ersatzschule'],
other: [],
};
23 changes: 12 additions & 11 deletions graphql/external_school/fields.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Arg, Authorized, Field, InputType, Int, ObjectType, Query, Resolver } from 'type-graphql';
import { SchoolResult, searchExternalSchools } from '../../common/school/externalSearch';
import { getSchoolDetails, SchoolResult, searchExternalSchools } from '../../common/school/externalSearch';
import { pupil_state_enum as State, pupil_schooltype_enum as SchoolType } from '@prisma/client';
import { Role } from '../authorizations';
import { RateLimit } from '../rate-limit';
Expand All @@ -10,8 +10,8 @@ class ExternalSchoolSearch implements SchoolResult {
id: string;
@Field(() => String)
name: string;
@Field(() => State)
state: State;
@Field(() => State, { nullable: true })
state?: State;
@Field(() => SchoolType, { nullable: true })
schooltype?: SchoolType;
@Field(() => String, { nullable: true })
Expand All @@ -28,18 +28,19 @@ class ExternalSchoolSearchFilters {
name: string;
}

@InputType()
class ExternalSchoolSearchOptions {
@Field((type) => Int, { defaultValue: 20 })
limit: number;
}

@Resolver((of) => ExternalSchoolSearch)
export class ExternalSchoolResolver {
@Query((returns) => [ExternalSchoolSearch])
@Authorized(Role.UNAUTHENTICATED)
@RateLimit('ExternalSchoolSearch', 100 /* requests per */, 5 * 60 * 60 * 1000 /* 5 hours */)
externalSchoolSearch(@Arg('filters') filters: ExternalSchoolSearchFilters, @Arg('options') options: ExternalSchoolSearchOptions) {
return searchExternalSchools({ filters, options });
externalSchoolSearch(@Arg('filters') filters: ExternalSchoolSearchFilters) {
return searchExternalSchools({ filters });
}

@Query((returns) => ExternalSchoolSearch)
@Authorized(Role.UNAUTHENTICATED)
@RateLimit('SchoolDetail', 100 /* requests per */, 5 * 60 * 60 * 1000 /* 5 hours */)
schoolDetail(@Arg('schoolId') id: string) {
return getSchoolDetails(id);
}
}