mirror of
https://github.com/immich-app/immich.git
synced 2025-12-25 01:11:43 +03:00
feat: location favorites
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> & {
|
||||
|
||||
34
server/src/dtos/favorite-location.dto.ts
Normal file
34
server/src/dtos/favorite-location.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
35
server/src/schema/tables/favorite-location.table.ts
Normal file
35
server/src/schema/tables/favorite-location.table.ts
Normal 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>;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user