diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts index dcfdf0bc58..343a7c91d0 100644 --- a/e2e/src/api/specs/map.e2e-spec.ts +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -159,4 +159,75 @@ describe('/map', () => { expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); }); }); + + describe('GET /map/reverse-geocode', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/map/reverse-geocode'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should throw an error if a lat is not provided', async () => { + const { status, body } = await request(app) + .get('/map/reverse-geocode?lon=123') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90'])); + }); + + it('should throw an error if a lat is not a number', async () => { + const { status, body } = await request(app) + .get('/map/reverse-geocode?lat=abc&lon=123.456') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90'])); + }); + + it('should throw an error if a lat is out of range', async () => { + const { status, body } = await request(app) + .get('/map/reverse-geocode?lat=91&lon=123.456') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90'])); + }); + + it('should throw an error if a lon is not provided', async () => { + const { status, body } = await request(app) + .get('/map/reverse-geocode?lat=75') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['lon must be a number between -180 and 180'])); + }); + + const reverseGeocodeTestCases = [ + { + name: 'Vaucluse', + lat: -33.858_977_058_663_13, + lon: 151.278_490_730_270_48, + results: [{ city: 'Vaucluse', state: 'New South Wales', country: 'Australia' }], + }, + { + name: 'Ravenhall', + lat: -37.765_732_399_174_75, + lon: 144.752_453_164_883_3, + results: [{ city: 'Ravenhall', state: 'Victoria', country: 'Australia' }], + }, + { + name: 'Scarborough', + lat: -31.894_346_156_789_997, + lon: 115.757_617_103_904_64, + results: [{ city: 'Scarborough', state: 'Western Australia', country: 'Australia' }], + }, + ]; + + it.each(reverseGeocodeTestCases)(`should resolve to $name`, async ({ lat, lon, results }) => { + const { status, body } = await request(app) + .get(`/map/reverse-geocode?lat=${lat}&lon=${lon}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(results.length); + expect(body).toEqual(results); + }); + }); }); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index fa054333cb..5323982046 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index b332e73e71..e7aaf38de7 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart index 7a33498c73..2846dae6c3 100644 Binary files a/mobile/openapi/lib/api/map_api.dart and b/mobile/openapi/lib/api/map_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f423676c5f..4fe810b886 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart new file mode 100644 index 0000000000..ac99dd91a9 Binary files /dev/null and b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d7c2e5af2e..5a01da88a2 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3109,6 +3109,60 @@ ] } }, + "/map/reverse-geocode": { + "get": { + "operationId": "reverseGeocode", + "parameters": [ + { + "name": "lat", + "required": true, + "in": "query", + "schema": { + "format": "double", + "type": "number" + } + }, + { + "name": "lon", + "required": true, + "in": "query", + "schema": { + "format": "double", + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MapReverseGeocodeResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Map" + ] + } + }, "/map/style.json": { "get": { "operationId": "getMapStyle", @@ -9128,6 +9182,28 @@ ], "type": "object" }, + "MapReverseGeocodeResponseDto": { + "properties": { + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, + "state": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "city", + "country", + "state" + ], + "type": "object" + }, "MapTheme": { "enum": [ "light", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 106250a6b3..5d6c2a0ecc 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -554,6 +554,11 @@ export type MapMarkerResponseDto = { lon: number; state: string | null; }; +export type MapReverseGeocodeResponseDto = { + city: string | null; + country: string | null; + state: string | null; +}; export type OnThisDayDto = { year: number; }; @@ -1991,6 +1996,20 @@ export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, ...opts })); } +export function reverseGeocode({ lat, lon }: { + lat: number; + lon: number; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: MapReverseGeocodeResponseDto[]; + }>(`/map/reverse-geocode${QS.query(QS.explode({ + lat, + lon + }))}`, { + ...opts + })); +} export function getMapStyle({ key, theme }: { key?: string; theme: MapTheme; diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts index 223e6b8147..d6c26c58a0 100644 --- a/server/src/controllers/map.controller.ts +++ b/server/src/controllers/map.controller.ts @@ -1,7 +1,12 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MapMarkerDto, MapMarkerResponseDto } from 'src/dtos/search.dto'; +import { + MapMarkerDto, + MapMarkerResponseDto, + MapReverseGeocodeDto, + MapReverseGeocodeResponseDto, +} from 'src/dtos/map.dto'; import { MapThemeDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MapService } from 'src/services/map.service'; @@ -22,4 +27,11 @@ export class MapController { getMapStyle(@Query() dto: MapThemeDto) { return this.service.getMapStyle(dto.theme); } + + @Authenticated() + @Get('reverse-geocode') + @HttpCode(HttpStatus.OK) + reverseGeocode(@Query() dto: MapReverseGeocodeDto): Promise { + return this.service.reverseGeocode(dto); + } } diff --git a/server/src/dtos/map.dto.ts b/server/src/dtos/map.dto.ts new file mode 100644 index 0000000000..1d0b84a4d0 --- /dev/null +++ b/server/src/dtos/map.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsLatitude, IsLongitude } from 'class-validator'; +import { ValidateBoolean, ValidateDate } from 'src/validation'; + +export class MapReverseGeocodeDto { + @ApiProperty({ format: 'double' }) + @Type(() => Number) + @IsLatitude({ message: ({ property }) => `${property} must be a number between -90 and 90` }) + lat!: number; + + @ApiProperty({ format: 'double' }) + @Type(() => Number) + @IsLongitude({ message: ({ property }) => `${property} must be a number between -180 and 180` }) + lon!: number; +} + +export class MapReverseGeocodeResponseDto { + @ApiProperty() + city!: string | null; + + @ApiProperty() + state!: string | null; + + @ApiProperty() + country!: string | null; +} + +export class MapMarkerDto { + @ValidateBoolean({ optional: true }) + isArchived?: boolean; + + @ValidateBoolean({ optional: true }) + isFavorite?: boolean; + + @ValidateDate({ optional: true }) + fileCreatedAfter?: Date; + + @ValidateDate({ optional: true }) + fileCreatedBefore?: Date; + + @ValidateBoolean({ optional: true }) + withPartners?: boolean; + + @ValidateBoolean({ optional: true }) + withSharedAlbums?: boolean; +} + +export class MapMarkerResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty({ format: 'double' }) + lat!: number; + + @ApiProperty({ format: 'double' }) + lon!: number; + + @ApiProperty() + city!: string | null; + + @ApiProperty() + state!: string | null; + + @ApiProperty() + country!: string | null; +} diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 59bb95b475..0874300d5f 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -289,26 +289,6 @@ export class SearchExploreResponseDto { items!: SearchExploreItem[]; } -export class MapMarkerDto { - @ValidateBoolean({ optional: true }) - isArchived?: boolean; - - @ValidateBoolean({ optional: true }) - isFavorite?: boolean; - - @ValidateDate({ optional: true }) - fileCreatedAfter?: Date; - - @ValidateDate({ optional: true }) - fileCreatedBefore?: Date; - - @ValidateBoolean({ optional: true }) - withPartners?: boolean; - - @ValidateBoolean({ optional: true }) - withSharedAlbums?: boolean; -} - export class MemoryLaneDto { @IsInt() @Type(() => Number) @@ -324,22 +304,3 @@ export class MemoryLaneDto { @ApiProperty({ type: 'integer' }) month!: number; } -export class MapMarkerResponseDto { - @ApiProperty() - id!: string; - - @ApiProperty({ format: 'double' }) - lat!: number; - - @ApiProperty({ format: 'double' }) - lon!: number; - - @ApiProperty() - city!: string | null; - - @ApiProperty() - state!: string | null; - - @ApiProperty() - country!: string | null; -} diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index 453b76691a..ffd84a3e02 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -1,7 +1,7 @@ import { Inject } from '@nestjs/common'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MapMarkerDto, MapMarkerResponseDto } from 'src/dtos/search.dto'; +import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; @@ -53,4 +53,11 @@ export class MapService { return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`)); } + + async reverseGeocode(dto: MapReverseGeocodeDto) { + const { lat: latitude, lon: longitude } = dto; + // eventually this should probably return an array of results + const result = await this.mapRepository.reverseGeocode({ latitude, longitude }); + return result ? [result] : []; + } }