feat: location favorites

This commit is contained in:
Yaros
2025-12-12 15:19:24 +01:00
parent 33cdea88aa
commit d307843870
20 changed files with 1388 additions and 6 deletions

View File

@@ -1,7 +1,12 @@
import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
CreateFavoriteLocationDto,
FavoriteLocationResponseDto,
UpdateFavoriteLocationDto,
} from 'src/dtos/favorite-location.dto';
import {
MapMarkerDto,
MapMarkerResponseDto,
@@ -39,4 +44,59 @@ export class MapController {
reverseGeocode(@Query() dto: MapReverseGeocodeDto): Promise<MapReverseGeocodeResponseDto[]> {
return this.service.reverseGeocode(dto);
}
@Authenticated()
@Get('favorite-locations')
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Get favorite locations',
description: "Retrieve a list of user's favorite locations.",
history: new HistoryBuilder().added('v2').stable('v2'),
})
getFavoriteLocations(@Auth() auth: AuthDto): Promise<FavoriteLocationResponseDto[]> {
return this.service.getFavoriteLocations(auth);
}
@Authenticated()
@Post('favorite-locations')
@HttpCode(HttpStatus.CREATED)
@Endpoint({
summary: 'Create favorite location',
description: 'Create a new favorite location for the user.',
history: new HistoryBuilder().added('v2').stable('v2'),
})
createFavoriteLocation(
@Auth() auth: AuthDto,
@Body() dto: CreateFavoriteLocationDto,
): Promise<FavoriteLocationResponseDto> {
return this.service.createFavoriteLocation(auth, dto);
}
@Authenticated()
@Put('favorite-locations/:id')
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Update favorite location',
description: 'Update an existing favorite location.',
history: new HistoryBuilder().added('v2').stable('v2'),
})
updateFavoriteLocation(
@Auth() auth: AuthDto,
@Param('id') id: string,
@Body() dto: UpdateFavoriteLocationDto,
): Promise<FavoriteLocationResponseDto> {
return this.service.updateFavoriteLocation(auth, id, dto);
}
@Authenticated()
@Delete('favorite-locations/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete favorite location',
description: 'Delete a favorite location by its ID.',
history: new HistoryBuilder().added('v2').stable('v2'),
})
deleteFavoriteLocation(@Param('id') id: string) {
return this.service.deleteFavoriteLocation(id);
}
}

View File

@@ -274,6 +274,16 @@ export type AssetFace = {
updateId: string;
};
export type FavoriteLocation = {
id: string;
name: string;
userId: string;
latitude: number | null;
longitude: number | null;
createdAt: Date;
updatedAt: Date;
};
export type Plugin = Selectable<PluginTable>;
export type PluginFilter = Selectable<PluginFilterTable> & {

View File

@@ -0,0 +1,34 @@
import { IsLatitude, IsLongitude, IsString } from 'class-validator';
import { Optional } from 'src/validation';
export class CreateFavoriteLocationDto {
@IsString()
name!: string;
@IsLatitude()
latitude!: number;
@IsLongitude()
longitude!: number;
}
export class UpdateFavoriteLocationDto {
@Optional()
@IsString()
name?: string;
@Optional()
@IsLatitude()
latitude?: number;
@Optional()
@IsLongitude()
longitude?: number;
}
export class FavoriteLocationResponseDto {
id!: string;
name!: string;
latitude!: number | null;
longitude!: number | null;
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { getName } from 'i18n-iso-countries';
import { Expression, Insertable, Kysely, NotNull, sql, SqlBool } from 'kysely';
import { Expression, Insertable, Kysely, NotNull, sql, SqlBool, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
@@ -12,6 +12,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { DB } from 'src/schema';
import { FavoriteLocationTable } from 'src/schema/tables/favorite-location.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
@@ -138,6 +139,42 @@ export class MapRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFavoriteLocations(userId: string) {
return this.db
.selectFrom('favorite_location')
.selectAll()
.where('userId', '=', userId)
.orderBy('name', 'asc')
.execute();
}
async createFavoriteLocation(entity: Insertable<FavoriteLocationTable>) {
const inserted = await this.db
.insertInto('favorite_location')
.values(entity)
.returningAll()
.executeTakeFirstOrThrow();
return inserted;
}
async updateFavoriteLocation(id: string, userId: string, updates: Updateable<FavoriteLocationTable>) {
const updated = await this.db
.updateTable('favorite_location')
.set(updates)
.where('id', '=', id)
.where('userId', '=', userId)
.returningAll()
.executeTakeFirstOrThrow();
return updated;
}
async deleteFavoriteLocation(id: string) {
await this.db.deleteFrom('favorite_location').where('id', '=', id).execute();
}
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);

View File

@@ -39,6 +39,7 @@ import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { FavoriteLocationTable } from 'src/schema/tables/favorite-location.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table';
@@ -97,6 +98,7 @@ export class ImmichDatabase {
AuditTable,
AssetExifTable,
FaceSearchTable,
FavoriteLocationTable,
GeodataPlacesTable,
LibraryTable,
MemoryTable,
@@ -192,6 +194,7 @@ export interface DB {
audit: AuditTable;
face_search: FaceSearchTable;
favorite_location: FavoriteLocationTable;
geodata_places: GeodataPlacesTable;

View File

@@ -0,0 +1,20 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "favorite_location" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying NOT NULL,
"userId" uuid NOT NULL,
"latitude" double precision,
"longitude" double precision,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "favorite_location_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "favorite_location_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "favorite_location_userId_idx" ON "favorite_location" ("userId");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "favorite_location";`.execute(db);
}

View File

@@ -0,0 +1,35 @@
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
@Table('favorite_location')
export class FavoriteLocationTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ type: 'character varying', nullable: false })
name!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', index: true })
userId!: string;
@Column({ type: 'double precision', nullable: true })
latitude!: number | null;
@Column({ type: 'double precision', nullable: true })
longitude!: number | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
}

View File

@@ -1,3 +1,4 @@
import { CreateFavoriteLocationDto, UpdateFavoriteLocationDto } from 'src/dtos/favorite-location.dto';
import { MapService } from 'src/services/map.service';
import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -93,4 +94,71 @@ describe(MapService.name, () => {
expect(mocks.map.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 });
});
});
describe('getFavoriteLocations', () => {
it('should return favorite locations for the user', async () => {
const favoriteLocation = {
id: 'loc1',
userId: authStub.user1.user.id,
name: 'Home',
latitude: 12.34,
longitude: 56.78,
};
mocks.map.getFavoriteLocations.mockResolvedValue([favoriteLocation]);
const result = await sut.getFavoriteLocations(authStub.user1);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(favoriteLocation);
expect(mocks.map.getFavoriteLocations).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
describe('createFavoriteLocation', () => {
it('should create a new favorite location', async () => {
const dto: CreateFavoriteLocationDto = { name: 'Work', latitude: 1, longitude: 2 };
const created = { id: 'loc2', userId: authStub.user1.user.id, ...dto };
mocks.map.createFavoriteLocation.mockResolvedValue(created);
const result = await sut.createFavoriteLocation(authStub.user1, dto);
expect(result).toEqual(created);
expect(mocks.map.createFavoriteLocation).toHaveBeenCalledWith({
userId: authStub.user1.user.id,
name: dto.name,
latitude: dto.latitude,
longitude: dto.longitude,
});
});
});
describe('updateFavoriteLocation', () => {
it('should update an existing favorite location', async () => {
const dto: UpdateFavoriteLocationDto = { name: 'Gym' };
const updated = { id: 'loc3', userId: authStub.user1.user.id, name: 'Gym', latitude: null, longitude: null };
mocks.map.updateFavoriteLocation.mockResolvedValue(updated);
const result = await sut.updateFavoriteLocation(authStub.user1, 'loc3', dto);
expect(result).toEqual(updated);
expect(mocks.map.updateFavoriteLocation).toHaveBeenCalledWith('loc3', authStub.user1.user.id, {
id: 'loc3',
userId: authStub.user1.user.id,
name: 'Gym',
});
});
});
describe('deleteFavoriteLocation', () => {
it('should call repository to delete a location by id', async () => {
mocks.map.deleteFavoriteLocation.mockResolvedValue(undefined);
await sut.deleteFavoriteLocation('loc4');
expect(mocks.map.deleteFavoriteLocation).toHaveBeenCalledWith('loc4');
});
});
});

View File

@@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import {
CreateFavoriteLocationDto,
FavoriteLocationResponseDto,
UpdateFavoriteLocationDto,
} from 'src/dtos/favorite-location.dto';
import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto';
import { BaseService } from 'src/services/base.service';
import { getMyPartnerIds } from 'src/utils/asset.util';
@@ -32,4 +37,38 @@ export class MapService extends BaseService {
const result = await this.mapRepository.reverseGeocode({ latitude, longitude });
return result ? [result] : [];
}
async getFavoriteLocations(auth: AuthDto): Promise<FavoriteLocationResponseDto[]> {
return this.mapRepository.getFavoriteLocations(auth.user.id);
}
async createFavoriteLocation(auth: AuthDto, dto: CreateFavoriteLocationDto): Promise<FavoriteLocationResponseDto> {
const entity = {
userId: auth.user.id,
name: dto.name,
latitude: dto.latitude,
longitude: dto.longitude,
};
return this.mapRepository.createFavoriteLocation(entity);
}
async updateFavoriteLocation(
auth: AuthDto,
id: string,
dto: UpdateFavoriteLocationDto,
): Promise<FavoriteLocationResponseDto> {
const entity = {
userId: auth.user.id,
id,
...(dto.name !== undefined && { name: dto.name }),
...(dto.latitude !== undefined && { latitude: dto.latitude }),
...(dto.longitude !== undefined && { longitude: dto.longitude }),
};
return this.mapRepository.updateFavoriteLocation(id, auth.user.id, entity);
}
async deleteFavoriteLocation(id: string) {
await this.mapRepository.deleteFavoriteLocation(id);
}
}