refactor(server)*: tsconfigs (#2689)

* refactor(server): tsconfigs

* chore: dummy commit

* fix: start.sh

* chore: restore original entry scripts
This commit is contained in:
Jason Rasmussen
2023-06-08 11:01:07 -04:00
committed by GitHub
parent a2130aa6c5
commit 8ebac41318
465 changed files with 209 additions and 332 deletions

View File

@@ -0,0 +1,66 @@
import { IAccessRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PartnerEntity, SharedLinkEntity } from '../entities';
export class AccessRepository implements IAccessRepository {
constructor(
@InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>,
@InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
) {}
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean> {
return this.partnerRepository.exist({
where: {
sharedWithId: userId,
sharedById: partnerId,
},
});
}
hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean> {
return this.partnerRepository.exist({
where: {
sharedWith: {
id: userId,
},
sharedBy: {
assets: {
id: assetId,
},
},
},
relations: {
sharedWith: true,
sharedBy: {
assets: true,
},
},
});
}
async hasSharedLinkAssetAccess(sharedLinkId: string, assetId: string): Promise<boolean> {
return (
// album asset
(await this.sharedLinkRepository.exist({
where: {
id: sharedLinkId,
album: {
assets: {
id: assetId,
},
},
},
})) ||
// individual asset
(await this.sharedLinkRepository.exist({
where: {
id: sharedLinkId,
assets: {
id: assetId,
},
},
}))
);
}
}

View File

@@ -0,0 +1,165 @@
import { AlbumAssetCount, IAlbumRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { dataSource } from '../database.config';
import { AlbumEntity } from '../entities';
@Injectable()
export class AlbumRepository implements IAlbumRepository {
constructor(@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>) {}
getByIds(ids: string[]): Promise<AlbumEntity[]> {
return this.repository.find({
where: {
id: In(ids),
},
relations: {
owner: true,
sharedUsers: true,
},
});
}
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
return this.repository.find({
where: { ownerId, assets: { id: assetId } },
relations: { owner: true, sharedUsers: true },
order: { createdAt: 'DESC' },
});
}
async getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]> {
// Guard against running invalid query when ids list is empty.
if (!ids.length) {
return [];
}
// Only possible with query builder because of GROUP BY.
const countByAlbums = await this.repository
.createQueryBuilder('album')
.select('album.id')
.addSelect('COUNT(albums_assets.assetsId)', 'asset_count')
.leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id')
.where('album.id IN (:...ids)', { ids })
.groupBy('album.id')
.getRawMany();
return countByAlbums.map<AlbumAssetCount>((albumCount) => ({
albumId: albumCount['album_id'],
assetCount: Number(albumCount['asset_count']),
}));
}
/**
* Returns the album IDs that have an invalid thumbnail, when:
* - Thumbnail references an asset outside the album
* - Empty album still has a thumbnail set
*/
async getInvalidThumbnail(): Promise<string[]> {
// Using dataSource, because there is no direct access to albums_assets_assets.
const albumHasAssets = dataSource
.createQueryBuilder()
.select('1')
.from('albums_assets_assets', 'albums_assets')
.where('"albums"."id" = "albums_assets"."albumsId"');
const albumContainsThumbnail = albumHasAssets
.clone()
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
const albums = await this.repository
.createQueryBuilder('albums')
.select('albums.id')
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`)
.getMany();
return albums.map((album) => album.id);
}
getOwned(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
where: { ownerId },
order: { createdAt: 'DESC' },
});
}
/**
* Get albums shared with and shared by owner.
*/
getShared(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
where: [
{ sharedUsers: { id: ownerId } },
{ sharedLinks: { userId: ownerId } },
{ ownerId, sharedUsers: { id: Not(IsNull()) } },
],
order: { createdAt: 'DESC' },
});
}
/**
* Get albums of owner that are _not_ shared
*/
getNotShared(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
where: { ownerId, sharedUsers: { id: IsNull() }, sharedLinks: { id: IsNull() } },
order: { createdAt: 'DESC' },
});
}
async deleteAll(userId: string): Promise<void> {
await this.repository.delete({ ownerId: userId });
}
getAll(): Promise<AlbumEntity[]> {
return this.repository.find({
relations: {
owner: true,
},
});
}
async hasAsset(id: string, assetId: string): Promise<boolean> {
const count = await this.repository.count({
where: {
id,
assets: {
id: assetId,
},
},
relations: {
assets: true,
},
});
return Boolean(count);
}
async create(album: Partial<AlbumEntity>): Promise<AlbumEntity> {
return this.save(album);
}
async update(album: Partial<AlbumEntity>): Promise<AlbumEntity> {
return this.save(album);
}
async delete(album: AlbumEntity): Promise<void> {
await this.repository.remove(album);
}
private async save(album: Partial<AlbumEntity>) {
const { id } = await this.repository.save(album);
return this.repository.findOneOrFail({
where: { id },
relations: {
owner: true,
sharedUsers: true,
},
});
}
}

View File

@@ -0,0 +1,45 @@
import { IKeyRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { APIKeyEntity } from '../entities';
@Injectable()
export class APIKeyRepository implements IKeyRepository {
constructor(@InjectRepository(APIKeyEntity) private repository: Repository<APIKeyEntity>) {}
async create(dto: Partial<APIKeyEntity>): Promise<APIKeyEntity> {
return this.repository.save(dto);
}
async update(userId: string, id: string, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity> {
await this.repository.update({ userId, id }, dto);
return this.repository.findOneOrFail({ where: { id: dto.id } });
}
async delete(userId: string, id: string): Promise<void> {
await this.repository.delete({ userId, id });
}
getKey(hashedToken: string): Promise<APIKeyEntity | null> {
return this.repository.findOne({
select: {
id: true,
key: true,
userId: true,
},
where: { key: hashedToken },
relations: {
user: true,
},
});
}
getById(userId: string, id: string): Promise<APIKeyEntity | null> {
return this.repository.findOne({ where: { userId, id } });
}
getByUserId(userId: string): Promise<APIKeyEntity[]> {
return this.repository.find({ where: { userId }, order: { createdAt: 'DESC' } });
}
}

View File

@@ -0,0 +1,255 @@
import {
AssetSearchOptions,
IAssetRepository,
LivePhotoSearchOptions,
MapMarker,
MapMarkerSearchOptions,
Paginated,
PaginationOptions,
WithoutProperty,
WithProperty,
} from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
import { AssetEntity, AssetType } from '../entities';
import OptionalBetween from '../utils/optional-between.util';
import { paginate } from '../utils/pagination.util';
@Injectable()
export class AssetRepository implements IAssetRepository {
constructor(@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>) {}
getByIds(ids: string[]): Promise<AssetEntity[]> {
return this.repository.find({
where: { id: In(ids) },
relations: {
exifInfo: true,
smartInfo: true,
tags: true,
faces: {
person: true,
},
},
});
}
async deleteAll(ownerId: string): Promise<void> {
await this.repository.delete({ ownerId });
}
getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
return paginate(this.repository, pagination, {
where: {
isVisible: options.isVisible,
type: options.type,
},
relations: {
exifInfo: true,
smartInfo: true,
tags: true,
faces: {
person: true,
},
},
order: {
// Ensures correct order when paginating
createdAt: 'ASC',
},
});
}
async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
const { id } = await this.repository.save(asset);
return this.repository.findOneOrFail({
where: { id },
relations: {
exifInfo: true,
owner: true,
smartInfo: true,
tags: true,
faces: true,
},
});
}
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null> {
const { ownerId, otherAssetId, livePhotoCID, type } = options;
return this.repository.findOne({
where: {
id: Not(otherAssetId),
ownerId,
type,
exifInfo: {
livePhotoCID,
},
},
relations: {
exifInfo: true,
},
});
}
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity> {
let relations: FindOptionsRelations<AssetEntity> = {};
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
switch (property) {
case WithoutProperty.THUMBNAIL:
where = [
{ resizePath: IsNull(), isVisible: true },
{ resizePath: '', isVisible: true },
{ webpPath: IsNull(), isVisible: true },
{ webpPath: '', isVisible: true },
];
break;
case WithoutProperty.ENCODED_VIDEO:
where = [
{ type: AssetType.VIDEO, encodedVideoPath: IsNull() },
{ type: AssetType.VIDEO, encodedVideoPath: '' },
];
break;
case WithoutProperty.EXIF:
relations = {
exifInfo: true,
};
where = {
isVisible: true,
exifInfo: {
assetId: IsNull(),
},
};
break;
case WithoutProperty.CLIP_ENCODING:
relations = {
smartInfo: true,
};
where = {
isVisible: true,
resizePath: Not(IsNull()),
smartInfo: {
clipEmbedding: IsNull(),
},
};
break;
case WithoutProperty.OBJECT_TAGS:
relations = {
smartInfo: true,
};
where = {
resizePath: Not(IsNull()),
isVisible: true,
smartInfo: {
tags: IsNull(),
},
};
break;
case WithoutProperty.FACES:
relations = {
faces: true,
};
where = {
resizePath: Not(IsNull()),
isVisible: true,
faces: {
assetId: IsNull(),
personId: IsNull(),
},
};
break;
case WithoutProperty.SIDECAR:
where = [
{ sidecarPath: IsNull(), isVisible: true },
{ sidecarPath: '', isVisible: true },
];
break;
default:
throw new Error(`Invalid getWithout property: ${property}`);
}
return paginate(this.repository, pagination, {
relations,
where,
order: {
// Ensures correct order when paginating
createdAt: 'ASC',
},
});
}
getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity> {
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
switch (property) {
case WithProperty.SIDECAR:
where = [{ sidecarPath: Not(IsNull()), isVisible: true }];
break;
default:
throw new Error(`Invalid getWith property: ${property}`);
}
return paginate(this.repository, pagination, {
where,
order: {
// Ensures correct order when paginating
createdAt: 'ASC',
},
});
}
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { albums: { id: albumId } },
order: { fileCreatedAt: 'DESC' },
});
}
async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
const { isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
const assets = await this.repository.find({
select: {
id: true,
exifInfo: {
latitude: true,
longitude: true,
},
},
where: {
ownerId,
isVisible: true,
isArchived: false,
exifInfo: {
latitude: Not(IsNull()),
longitude: Not(IsNull()),
},
isFavorite,
fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore),
},
relations: {
exifInfo: true,
},
order: {
fileCreatedAt: 'DESC',
},
});
return assets.map((asset) => ({
id: asset.id,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lat: asset.exifInfo!.latitude!,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lon: asset.exifInfo!.longitude!,
}));
}
}

View File

@@ -0,0 +1,12 @@
import { CommunicationEvent } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { CommunicationGateway } from '../communication.gateway';
@Injectable()
export class CommunicationRepository {
constructor(private ws: CommunicationGateway) {}
send(event: CommunicationEvent, userId: string, data: any) {
this.ws.server.to(userId).emit(event, JSON.stringify(data));
}
}

View File

@@ -0,0 +1,16 @@
import { ICryptoRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt';
import { randomBytes, createHash } from 'crypto';
@Injectable()
export class CryptoRepository implements ICryptoRepository {
randomBytes = randomBytes;
hashBcrypt = hash;
compareBcrypt = compareSync;
hashSha256(value: string) {
return createHash('sha256').update(value).digest('base64');
}
}

View File

@@ -0,0 +1,22 @@
import { AssetFaceId, IFaceRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetFaceEntity } from '../entities/asset-face.entity';
@Injectable()
export class FaceRepository implements IFaceRepository {
constructor(@InjectRepository(AssetFaceEntity) private repository: Repository<AssetFaceEntity>) {}
getAll(): Promise<AssetFaceEntity[]> {
return this.repository.find({ relations: { asset: true } });
}
getByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
return this.repository.find({ where: ids, relations: { asset: true } });
}
create(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity> {
return this.repository.save(entity);
}
}

View File

@@ -0,0 +1,78 @@
import { DiskUsage, ImmichReadStream, IStorageRepository } from '@app/domain';
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
import fs from 'fs/promises';
import mv from 'mv';
import { promisify } from 'node:util';
import path from 'path';
const moveFile = promisify<string, string, mv.Options>(mv);
export class FilesystemProvider implements IStorageRepository {
async createReadStream(filepath: string, mimeType: string): Promise<ImmichReadStream> {
const { size } = await fs.stat(filepath);
await fs.access(filepath, constants.R_OK | constants.W_OK);
return {
stream: createReadStream(filepath),
length: size,
type: mimeType,
};
}
async moveFile(source: string, destination: string): Promise<void> {
await moveFile(source, destination, { mkdirp: true, clobber: false });
}
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
try {
await fs.access(filepath, mode);
return true;
} catch (_) {
return false;
}
}
async unlink(file: string) {
await fs.unlink(file);
}
async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) {
await fs.rm(folder, options);
}
async removeEmptyDirs(directory: string) {
this._removeEmptyDirs(directory, false);
}
private async _removeEmptyDirs(directory: string, self: boolean) {
// lstat does not follow symlinks (in contrast to stat)
const stats = await fs.lstat(directory);
if (!stats.isDirectory()) {
return;
}
const files = await fs.readdir(directory);
await Promise.all(files.map((file) => this._removeEmptyDirs(path.join(directory, file), true)));
if (self) {
const updated = await fs.readdir(directory);
if (updated.length === 0) {
await fs.rmdir(directory);
}
}
}
mkdirSync(filepath: string): void {
if (!existsSync(filepath)) {
mkdirSync(filepath, { recursive: true });
}
}
async checkDiskUsage(folder: string): Promise<DiskUsage> {
const stats = await fs.statfs(folder);
return {
available: stats.bavail * stats.bsize,
free: stats.bfree * stats.bsize,
total: stats.blocks * stats.bsize,
};
}
}

View File

@@ -0,0 +1,58 @@
import { GeoPoint, IGeocodingRepository, ReverseGeocodeResult } from '@app/domain';
import { localGeocodingConfig } from '@app/infra';
import { Injectable, Logger } from '@nestjs/common';
import { readdir, rm } from 'fs/promises';
import { getName } from 'i18n-iso-countries';
import geocoder, { AddressObject } from 'local-reverse-geocoder';
import path from 'path';
import { promisify } from 'util';
export interface AdminCode {
name: string;
asciiName: string;
geoNameId: string;
}
export type GeoData = AddressObject & {
admin1Code?: AdminCode | string;
admin2Code?: AdminCode | string;
};
const init = (): Promise<void> => new Promise<void>((resolve) => geocoder.init(localGeocodingConfig, resolve));
const lookup = promisify<GeoPoint[], number, AddressObject[][]>(geocoder.lookUp).bind(geocoder);
@Injectable()
export class GeocodingRepository implements IGeocodingRepository {
private logger = new Logger(GeocodingRepository.name);
async init(): Promise<void> {
await init();
}
async deleteCache() {
const dumpDirectory = localGeocodingConfig.dumpDirectory;
if (dumpDirectory) {
// delete contents
const items = await readdir(dumpDirectory, { withFileTypes: true });
const folders = items.filter((item) => item.isDirectory());
for (const { name } of folders) {
await rm(path.join(dumpDirectory, name), { recursive: true, force: true });
}
}
}
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
const [address] = await lookup([point], 1);
this.logger.verbose(`Raw: ${JSON.stringify(address, null, 2)}`);
const { countryCode, name: city, admin1Code, admin2Code } = address[0] as GeoData;
const country = getName(countryCode, 'en');
const stateParts = [(admin2Code as AdminCode)?.name, (admin1Code as AdminCode)?.name].filter((name) => !!name);
const state = stateParts.length > 0 ? stateParts.join(', ') : null;
this.logger.debug(`Normalized: ${JSON.stringify({ country, state, city })}`);
return { country, state, city };
}
}

View File

@@ -0,0 +1,21 @@
export * from './access.repository';
export * from './album.repository';
export * from './api-key.repository';
export * from './asset.repository';
export * from './communication.repository';
export * from './crypto.repository';
export * from './face.repository';
export * from './filesystem.provider';
export * from './geocoding.repository';
export * from './job.repository';
export * from './machine-learning.repository';
export * from './media.repository';
export * from './partner.repository';
export * from './person.repository';
export * from './shared-link.repository';
export * from './smart-info.repository';
export * from './system-config.repository';
export * from './tag.repository';
export * from './typesense.repository';
export * from './user-token.repository';
export * from './user.repository';

View File

@@ -0,0 +1,84 @@
import { IJobRepository, JobCounts, JobItem, JobName, JOBS_TO_QUEUE, QueueName, QueueStatus } from '@app/domain';
import { getQueueToken } from '@nestjs/bullmq';
import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq';
import { bullConfig } from '../infra.config';
@Injectable()
export class JobRepository implements IJobRepository {
private workers: Partial<Record<QueueName, Worker>> = {};
private logger = new Logger(JobRepository.name);
constructor(private moduleRef: ModuleRef) {}
addHandler(queueName: QueueName, concurrency: number, handler: (item: JobItem) => Promise<void>) {
const workerHandler: Processor = async (job: Job) => handler(job as JobItem);
const workerOptions: WorkerOptions = { ...bullConfig, concurrency };
this.workers[queueName] = new Worker(queueName, workerHandler, workerOptions);
}
setConcurrency(queueName: QueueName, concurrency: number) {
const worker = this.workers[queueName];
if (!worker) {
this.logger.warn(`Unable to set queue concurrency, worker not found: '${queueName}'`);
return;
}
worker.concurrency = concurrency;
}
async getQueueStatus(name: QueueName): Promise<QueueStatus> {
const queue = this.getQueue(name);
return {
isActive: !!(await queue.getActiveCount()),
isPaused: await queue.isPaused(),
};
}
pause(name: QueueName) {
return this.getQueue(name).pause();
}
resume(name: QueueName) {
return this.getQueue(name).resume();
}
empty(name: QueueName) {
return this.getQueue(name).drain();
}
getJobCounts(name: QueueName): Promise<JobCounts> {
return this.getQueue(name).getJobCounts(
'active',
'completed',
'failed',
'delayed',
'waiting',
'paused',
) as unknown as Promise<JobCounts>;
}
async queue(item: JobItem): Promise<void> {
const jobName = item.name;
const jobData = (item as { data?: any })?.data || {};
const jobOptions = this.getJobOptions(item) || undefined;
await this.getQueue(JOBS_TO_QUEUE[jobName]).add(jobName, jobData, jobOptions);
}
private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) {
case JobName.GENERATE_FACE_THUMBNAIL:
return { priority: 1 };
default:
return null;
}
}
private getQueue(queue: QueueName) {
return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false });
}
}

View File

@@ -0,0 +1,24 @@
import { DetectFaceResult, IMachineLearningRepository, MachineLearningInput, MACHINE_LEARNING_URL } from '@app/domain';
import { Injectable } from '@nestjs/common';
import axios from 'axios';
const client = axios.create({ baseURL: MACHINE_LEARNING_URL });
@Injectable()
export class MachineLearningRepository implements IMachineLearningRepository {
classifyImage(input: MachineLearningInput): Promise<string[]> {
return client.post<string[]>('/image-classifier/tag-image', input).then((res) => res.data);
}
detectFaces(input: MachineLearningInput): Promise<DetectFaceResult[]> {
return client.post<DetectFaceResult[]>('/facial-recognition/detect-faces', input).then((res) => res.data);
}
encodeImage(input: MachineLearningInput): Promise<number[]> {
return client.post<number[]>('/sentence-transformer/encode-image', input).then((res) => res.data);
}
encodeText(input: string): Promise<number[]> {
return client.post<number[]>('/sentence-transformer/encode-text', { text: input }).then((res) => res.data);
}
}

View File

@@ -0,0 +1,127 @@
import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain';
import { exiftool } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import sharp from 'sharp';
import { promisify } from 'util';
import fs from 'fs/promises';
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
export class MediaRepository implements IMediaRepository {
crop(input: string, options: CropOptions): Promise<Buffer> {
return sharp(input, { failOnError: false })
.extract({
left: options.left,
top: options.top,
width: options.width,
height: options.height,
})
.toBuffer();
}
extractThumbnailFromExif(input: string, output: string): Promise<void> {
return exiftool.extractThumbnail(input, output);
}
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
switch (options.format) {
case 'webp':
await sharp(input, { failOnError: false })
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.webp()
.rotate()
.toFile(output);
return;
case 'jpeg':
await sharp(input, { failOnError: false })
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.jpeg()
.rotate()
.toFile(output);
return;
}
}
extractVideoThumbnail(input: string, output: string, size: number) {
return new Promise<void>((resolve, reject) => {
ffmpeg(input)
.outputOptions([
'-ss 00:00:00.000',
'-frames:v 1',
`-vf scale='min(${size},iw)':'min(${size},ih)':force_original_aspect_ratio=increase`,
])
.output(output)
.on('error', reject)
.on('end', resolve)
.run();
});
}
async probe(input: string): Promise<VideoInfo> {
const results = await probe(input);
return {
format: {
formatName: results.format.format_name,
formatLongName: results.format.format_long_name,
duration: results.format.duration || 0,
},
videoStreams: results.streams
.filter((stream) => stream.codec_type === 'video')
.map((stream) => ({
height: stream.height || 0,
width: stream.width || 0,
codecName: stream.codec_name,
codecType: stream.codec_type,
frameCount: Number.parseInt(stream.nb_frames ?? '0'),
rotation: Number.parseInt(`${stream.rotation ?? 0}`),
})),
audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio')
.map((stream) => ({
codecType: stream.codec_type,
codecName: stream.codec_name,
})),
};
}
transcode(input: string, output: string, options: TranscodeOptions): Promise<void> {
if (!options.twoPass) {
return new Promise((resolve, reject) => {
ffmpeg(input, { niceness: 10 })
.outputOptions(options.outputOptions)
.output(output)
.on('error', reject)
.on('end', resolve)
.run();
});
}
// two-pass allows for precise control of bitrate at the cost of running twice
// recommended for vp9 for better quality and compression
return new Promise((resolve, reject) => {
ffmpeg(input, { niceness: 10 })
.outputOptions(options.outputOptions)
.addOptions('-pass', '1')
.addOptions('-passlogfile', output)
.addOptions('-f null')
.output('/dev/null') // first pass output is not saved as only the .log file is needed
.on('error', reject)
.on('end', () => {
// second pass
ffmpeg(input, { niceness: 10 })
.outputOptions(options.outputOptions)
.addOptions('-pass', '2')
.addOptions('-passlogfile', output)
.output(output)
.on('error', reject)
.on('end', () => fs.unlink(`${output}-0.log`))
.on('end', () => fs.rm(`${output}-0.log.mbtree`, { force: true }))
.on('end', resolve)
.run();
})
.run();
});
}
}

View File

@@ -0,0 +1,27 @@
import { IPartnerRepository, PartnerIds } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PartnerEntity } from '../entities';
@Injectable()
export class PartnerRepository implements IPartnerRepository {
constructor(@InjectRepository(PartnerEntity) private readonly repository: Repository<PartnerEntity>) {}
getAll(userId: string): Promise<PartnerEntity[]> {
return this.repository.find({ where: [{ sharedWithId: userId }, { sharedById: userId }] });
}
get({ sharedWithId, sharedById }: PartnerIds): Promise<PartnerEntity | null> {
return this.repository.findOne({ where: { sharedById, sharedWithId } });
}
async create({ sharedById, sharedWithId }: PartnerIds): Promise<PartnerEntity> {
await this.repository.save({ sharedBy: { id: sharedById }, sharedWith: { id: sharedWithId } });
return this.repository.findOneOrFail({ where: { sharedById, sharedWithId } });
}
async remove(entity: PartnerEntity): Promise<void> {
await this.repository.remove(entity);
}
}

View File

@@ -0,0 +1,79 @@
import { IPersonRepository, PersonSearchOptions } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
export class PersonRepository implements IPersonRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
) {}
delete(entity: PersonEntity): Promise<PersonEntity | null> {
return this.personRepository.remove(entity);
}
async deleteAll(): Promise<number> {
const people = await this.personRepository.find();
await this.personRepository.remove(people);
return people.length;
}
getAll(userId: string, options?: PersonSearchOptions): Promise<PersonEntity[]> {
return this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.where('person.ownerId = :userId', { userId })
.orderBy('COUNT(face.assetId)', 'DESC')
.having('COUNT(face.assetId) >= :faces', { faces: options?.minimumFaceCount || 1 })
.groupBy('person.id')
.getMany();
}
getAllWithoutFaces(): Promise<PersonEntity[]> {
return this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.having('COUNT(face.assetId) = 0')
.groupBy('person.id')
.getMany();
}
getById(ownerId: string, personId: string): Promise<PersonEntity | null> {
return this.personRepository.findOne({ where: { id: personId, ownerId } });
}
getAssets(ownerId: string, personId: string): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {
ownerId,
faces: {
personId,
},
isVisible: true,
isArchived: false,
},
relations: {
faces: {
person: true,
},
exifInfo: true,
},
order: {
createdAt: 'ASC',
},
// TODO: remove after either (1) pagination or (2) time bucket is implemented for this query
take: 1000,
});
}
create(entity: Partial<PersonEntity>): Promise<PersonEntity> {
return this.personRepository.save(entity);
}
async update(entity: Partial<PersonEntity>): Promise<PersonEntity> {
const { id } = await this.personRepository.save(entity);
return this.personRepository.findOneByOrFail({ id });
}
}

View File

@@ -0,0 +1,93 @@
import { ISharedLinkRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SharedLinkEntity } from '../entities';
@Injectable()
export class SharedLinkRepository implements ISharedLinkRepository {
constructor(@InjectRepository(SharedLinkEntity) private repository: Repository<SharedLinkEntity>) {}
get(userId: string, id: string): Promise<SharedLinkEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
relations: {
assets: {
exifInfo: true,
},
album: {
assets: {
exifInfo: true,
},
owner: true,
},
},
order: {
createdAt: 'DESC',
assets: {
fileCreatedAt: 'ASC',
},
album: {
assets: {
fileCreatedAt: 'ASC',
},
},
},
});
}
getAll(userId: string): Promise<SharedLinkEntity[]> {
return this.repository.find({
where: {
userId,
},
relations: {
assets: true,
album: {
owner: true,
},
},
order: {
createdAt: 'DESC',
},
});
}
async getByKey(key: Buffer): Promise<SharedLinkEntity | null> {
return await this.repository.findOne({
where: {
key,
},
relations: {
assets: true,
album: {
assets: true,
},
user: true,
},
order: {
createdAt: 'DESC',
},
});
}
create(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
return this.save(entity);
}
update(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
return this.save(entity);
}
async remove(entity: SharedLinkEntity): Promise<void> {
await this.repository.remove(entity);
}
private async save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
await this.repository.save(entity);
return this.repository.findOneOrFail({ where: { id: entity.id } });
}
}

View File

@@ -0,0 +1,14 @@
import { ISmartInfoRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SmartInfoEntity } from '../entities';
@Injectable()
export class SmartInfoRepository implements ISmartInfoRepository {
constructor(@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>) {}
async upsert(info: Partial<SmartInfoEntity>): Promise<void> {
await this.repository.upsert(info, { conflictPaths: ['assetId'] });
}
}

View File

@@ -0,0 +1,23 @@
import { ISystemConfigRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { SystemConfigEntity } from '../entities';
export class SystemConfigRepository implements ISystemConfigRepository {
constructor(
@InjectRepository(SystemConfigEntity)
private repository: Repository<SystemConfigEntity>,
) {}
load(): Promise<SystemConfigEntity[]> {
return this.repository.find();
}
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
return this.repository.save(items);
}
async deleteKeys(keys: string[]): Promise<void> {
await this.repository.delete({ key: In(keys) });
}
}

View File

@@ -0,0 +1,123 @@
import { ITagRepository } from '@app/domain';
import { AssetEntity, TagEntity } from '@app/infra/entities';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@Injectable()
export class TagRepository implements ITagRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(TagEntity) private repository: Repository<TagEntity>,
) {}
getById(userId: string, id: string): Promise<TagEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
relations: {
user: true,
},
});
}
getAll(userId: string): Promise<TagEntity[]> {
return this.repository.find({ where: { userId } });
}
create(tag: Partial<TagEntity>): Promise<TagEntity> {
return this.save(tag);
}
update(tag: Partial<TagEntity>): Promise<TagEntity> {
return this.save(tag);
}
async remove(tag: TagEntity): Promise<void> {
await this.repository.remove(tag);
}
async getAssets(userId: string, tagId: string): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {
tags: {
userId,
id: tagId,
},
},
relations: {
exifInfo: true,
tags: true,
faces: {
person: true,
},
},
order: {
createdAt: 'ASC',
},
});
}
async addAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
for (const assetId of assetIds) {
const asset = await this.assetRepository.findOneOrFail({
where: {
ownerId: userId,
id: assetId,
},
relations: {
tags: true,
},
});
asset.tags.push({ id } as TagEntity);
await this.assetRepository.save(asset);
}
}
async removeAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
for (const assetId of assetIds) {
const asset = await this.assetRepository.findOneOrFail({
where: {
ownerId: userId,
id: assetId,
},
relations: {
tags: true,
},
});
asset.tags = asset.tags.filter((tag) => tag.id !== id);
await this.assetRepository.save(asset);
}
}
hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean> {
return this.repository.exist({
where: {
id: tagId,
userId,
assets: {
id: assetId,
},
},
relations: {
assets: true,
},
});
}
hasName(userId: string, name: string): Promise<boolean> {
return this.repository.exist({
where: {
name,
userId,
},
});
}
private async save(tag: Partial<TagEntity>): Promise<TagEntity> {
const { id } = await this.repository.save(tag);
return this.repository.findOneOrFail({ where: { id }, relations: { user: true } });
}
}

View File

@@ -0,0 +1,463 @@
import {
ISearchRepository,
OwnedFaceEntity,
SearchCollection,
SearchCollectionIndexStatus,
SearchExploreItem,
SearchFaceFilter,
SearchFilter,
SearchResult,
} from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import _, { Dictionary } from 'lodash';
import { catchError, filter, firstValueFrom, from, map, mergeMap, of, toArray } from 'rxjs';
import { Client } from 'typesense';
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
import { AlbumEntity, AssetEntity, AssetFaceEntity } from '../entities';
import { typesenseConfig } from '../infra.config';
import { albumSchema, assetSchema, faceSchema } from '../typesense-schemas';
function removeNil<T extends Dictionary<any>>(item: T): T {
_.forOwn(item, (value, key) => {
if (_.isNil(value) || (_.isObject(value) && !_.isDate(value) && _.isEmpty(removeNil(value)))) {
delete item[key];
}
});
return item;
}
interface MultiSearchError {
code: number;
error: string;
}
interface CustomAssetEntity extends AssetEntity {
geo?: [number, number];
motion?: boolean;
people?: string[];
}
const schemaMap: Record<SearchCollection, CollectionCreateSchema> = {
[SearchCollection.ASSETS]: assetSchema,
[SearchCollection.ALBUMS]: albumSchema,
[SearchCollection.FACES]: faceSchema,
};
const schemas = Object.entries(schemaMap) as [SearchCollection, CollectionCreateSchema][];
@Injectable()
export class TypesenseRepository implements ISearchRepository {
private logger = new Logger(TypesenseRepository.name);
private _client: Client | null = null;
private get client(): Client {
if (!this._client) {
throw new Error('Typesense client not available (no apiKey was provided)');
}
return this._client;
}
constructor() {
if (!typesenseConfig.apiKey) {
return;
}
this._client = new Client(typesenseConfig);
}
async setup(): Promise<void> {
const collections = await this.client.collections().retrieve();
for (const collection of collections) {
this.logger.debug(`${collection.name} collection has ${collection.num_documents} documents`);
// await this.client.collections(collection.name).delete();
}
// upsert collections
for (const [collectionName, schema] of schemas) {
const collection = await this.client
.collections(schema.name)
.retrieve()
.catch(() => null);
if (!collection) {
this.logger.log(`Creating schema: ${collectionName}/${schema.name}`);
await this.client.collections().create(schema);
} else {
this.logger.log(`Schema up to date: ${collectionName}/${schema.name}`);
}
}
}
async checkMigrationStatus(): Promise<SearchCollectionIndexStatus> {
const migrationMap: SearchCollectionIndexStatus = {
[SearchCollection.ASSETS]: false,
[SearchCollection.ALBUMS]: false,
[SearchCollection.FACES]: false,
};
// check if alias is using the current schema
const { aliases } = await this.client.aliases().retrieve();
this.logger.log(`Alias mapping: ${JSON.stringify(aliases)}`);
for (const [aliasName, schema] of schemas) {
const match = aliases.find((alias) => alias.name === aliasName);
if (!match || match.collection_name !== schema.name) {
migrationMap[aliasName] = true;
}
}
this.logger.log(`Collections needing migration: ${JSON.stringify(migrationMap)}`);
return migrationMap;
}
async importAlbums(items: AlbumEntity[], done: boolean): Promise<void> {
await this.import(SearchCollection.ALBUMS, items, done);
}
async importAssets(items: AssetEntity[], done: boolean): Promise<void> {
await this.import(SearchCollection.ASSETS, items, done);
}
async importFaces(items: OwnedFaceEntity[], done: boolean): Promise<void> {
await this.import(SearchCollection.FACES, items, done);
}
private async import(
collection: SearchCollection,
items: AlbumEntity[] | AssetEntity[] | OwnedFaceEntity[],
done: boolean,
): Promise<void> {
try {
if (items.length > 0) {
await this.client.collections(schemaMap[collection].name).documents().import(this.patch(collection, items), {
action: 'upsert',
dirty_values: 'coerce_or_drop',
});
}
if (done) {
await this.updateAlias(collection);
}
} catch (error: any) {
this.handleError(error);
}
}
async explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]> {
const common = {
q: '*',
filter_by: this.buildFilterBy('ownerId', userId, true),
per_page: 100,
};
const asset$ = this.client.collections<AssetEntity>(assetSchema.name).documents();
const { facet_counts: facets } = await asset$.search({
...common,
query_by: 'originalFileName',
facet_by: 'exifInfo.city,smartInfo.objects',
max_facet_values: 12,
});
return firstValueFrom(
from(facets || []).pipe(
mergeMap(
(facet) =>
from(facet.counts).pipe(
mergeMap((count) => {
const config = {
...common,
query_by: 'originalFileName',
filter_by: [
this.buildFilterBy('ownerId', userId, true),
this.buildFilterBy(facet.field_name, count.value, true),
].join(' && '),
per_page: 1,
};
this.logger.verbose(`Explore subquery: "filter_by:${config.filter_by}" (count:${count.count})`);
return from(asset$.search(config)).pipe(
catchError((error: any) => {
this.logger.warn(`Explore subquery error: ${error}`, error?.stack);
return of({ hits: [] });
}),
map((result) => ({
value: count.value,
data: result.hits?.[0]?.document as AssetEntity,
})),
filter((item) => !!item.data),
);
}, 5),
toArray(),
map((items) => ({
fieldName: facet.field_name as string,
items,
})),
),
3,
),
toArray(),
),
);
}
async deleteAlbums(ids: string[]): Promise<void> {
await this.delete(SearchCollection.ALBUMS, ids);
}
async deleteAssets(ids: string[]): Promise<void> {
await this.delete(SearchCollection.ASSETS, ids);
}
async deleteFaces(ids: string[]): Promise<void> {
await this.delete(SearchCollection.FACES, ids);
}
async deleteAllFaces(): Promise<number> {
const records = await this.client.collections(faceSchema.name).documents().delete({ filter_by: 'ownerId:!=null' });
return records.num_deleted;
}
async delete(collection: SearchCollection, ids: string[]): Promise<void> {
await this.client
.collections(schemaMap[collection].name)
.documents()
.delete({ filter_by: this.buildFilterBy('id', ids, true) });
}
async searchAlbums(query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>> {
const results = await this.client
.collections<AlbumEntity>(albumSchema.name)
.documents()
.search({
q: query,
query_by: 'albumName',
filter_by: this.getAlbumFilters(filters),
});
return this.asResponse(results, filters.debug);
}
async searchAssets(query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>> {
const results = await this.client
.collections<AssetEntity>(assetSchema.name)
.documents()
.search({
q: query,
query_by: [
'originalFileName',
'exifInfo.country',
'exifInfo.state',
'exifInfo.city',
'exifInfo.description',
'smartInfo.tags',
'smartInfo.objects',
'people',
].join(','),
per_page: 250,
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
filter_by: this.getAssetFilters(filters),
sort_by: filters.recent ? 'createdAt:desc' : undefined,
});
return this.asResponse(results, filters.debug);
}
async searchFaces(input: number[], filters: SearchFaceFilter): Promise<SearchResult<AssetFaceEntity>> {
const { results } = await this.client.multiSearch.perform({
searches: [
{
collection: faceSchema.name,
q: '*',
vector_query: `embedding:([${input.join(',')}], k:5)`,
per_page: 250,
filter_by: this.buildFilterBy('ownerId', filters.ownerId, true),
} as any,
],
});
return this.asResponse(results[0] as SearchResponse<AssetFaceEntity>);
}
async vectorSearch(input: number[], filters: SearchFilter): Promise<SearchResult<AssetEntity>> {
const { results } = await this.client.multiSearch.perform({
searches: [
{
collection: assetSchema.name,
q: '*',
vector_query: `smartInfo.clipEmbedding:([${input.join(',')}], k:100)`,
per_page: 250,
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
filter_by: this.getAssetFilters(filters),
} as any,
],
});
return this.asResponse(results[0] as SearchResponse<AssetEntity>, filters.debug);
}
private asResponse<T extends DocumentSchema>(
resultsOrError: SearchResponse<T> | MultiSearchError,
debug?: boolean,
): SearchResult<T> {
const { error, code } = resultsOrError as MultiSearchError;
if (error) {
throw new Error(`Typesense multi-search error: ${code} - ${error}`);
}
const results = resultsOrError as SearchResponse<T>;
return {
page: results.page,
total: results.found,
count: results.out_of,
items: (results.hits || []).map((hit) => hit.document),
distances: (results.hits || []).map((hit: any) => hit.vector_distance),
facets: (results.facet_counts || []).map((facet) => ({
counts: facet.counts.map((item) => ({ count: item.count, value: item.value })),
fieldName: facet.field_name as string,
})),
debug: debug ? results : undefined,
} as SearchResult<T>;
}
private handleError(error: any) {
this.logger.error('Unable to index documents');
const results = error.importResults || [];
for (const result of results) {
try {
result.document = JSON.parse(result.document);
if (result.document?.smartInfo?.clipEmbedding) {
result.document.smartInfo.clipEmbedding = '<truncated>';
}
} catch {}
}
this.logger.verbose(JSON.stringify(results, null, 2));
}
private async updateAlias(collection: SearchCollection) {
const schema = schemaMap[collection];
const alias = await this.client
.aliases(collection)
.retrieve()
.catch(() => null);
// update alias to current collection
this.logger.log(`Using new schema: ${alias?.collection_name || '(unset)'} => ${schema.name}`);
await this.client.aliases().upsert(collection, { collection_name: schema.name });
// delete previous collection
if (alias && alias.collection_name !== schema.name) {
this.logger.log(`Deleting old schema: ${alias.collection_name}`);
await this.client.collections(alias.collection_name).delete();
}
}
private patch(collection: SearchCollection, items: AssetEntity[] | AlbumEntity[] | OwnedFaceEntity[]) {
return items.map((item) => {
switch (collection) {
case SearchCollection.ASSETS:
return this.patchAsset(item as AssetEntity);
case SearchCollection.ALBUMS:
return this.patchAlbum(item as AlbumEntity);
case SearchCollection.FACES:
return this.patchFace(item as OwnedFaceEntity);
}
});
}
private patchAlbum(album: AlbumEntity): AlbumEntity {
return removeNil(album);
}
private patchAsset(asset: AssetEntity): CustomAssetEntity {
let custom = asset as CustomAssetEntity;
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng && lat !== 0 && lng !== 0) {
custom = { ...custom, geo: [lat, lng] };
}
const people = asset.faces?.map((face) => face.person.name).filter((name) => name) || [];
if (people.length) {
custom = { ...custom, people };
}
return removeNil({ ...custom, motion: !!asset.livePhotoVideoId });
}
private patchFace(face: OwnedFaceEntity): OwnedFaceEntity {
return removeNil(face);
}
private getFacetFieldNames(collection: SearchCollection) {
return (schemaMap[collection].fields || [])
.filter((field) => field.facet)
.map((field) => field.name)
.join(',');
}
private getAlbumFilters(filters: SearchFilter) {
const { userId } = filters;
const _filters = [this.buildFilterBy('ownerId', userId, true)];
if (filters.id) {
_filters.push(this.buildFilterBy('id', filters.id, true));
}
for (const item of albumSchema.fields || []) {
const value = filters[item.name as keyof SearchFilter];
if (item.facet && value !== undefined) {
_filters.push(this.buildFilterBy(item.name, value));
}
}
const result = _filters.join(' && ');
this.logger.debug(`Album filters are: ${result}`);
return result;
}
private getAssetFilters(filters: SearchFilter) {
const { userId } = filters;
const _filters = [this.buildFilterBy('ownerId', userId, true)];
if (filters.id) {
_filters.push(this.buildFilterBy('id', filters.id, true));
}
for (const item of assetSchema.fields || []) {
const value = filters[item.name as keyof SearchFilter];
if (item.facet && value !== undefined) {
_filters.push(this.buildFilterBy(item.name, value));
}
}
const result = _filters.join(' && ');
this.logger.debug(`Asset filters are: ${result}`);
return result;
}
private buildFilterBy(key: string, values: boolean | string | string[], exact?: boolean) {
const token = exact ? ':=' : ':';
const _values = (Array.isArray(values) ? values : [values]).map((value) => {
if (typeof value === 'boolean' || value === 'true' || value === 'false') {
return value;
}
return '`' + value + '`';
});
const value = _values.length > 1 ? `[${_values.join(',')}]` : _values[0];
return `${key}${token}${value}`;
}
}

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserTokenEntity } from '../entities';
import { IUserTokenRepository } from '@app/domain/user-token';
@Injectable()
export class UserTokenRepository implements IUserTokenRepository {
constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {}
getByToken(token: string): Promise<UserTokenEntity | null> {
return this.repository.findOne({ where: { token }, relations: { user: true } });
}
getAll(userId: string): Promise<UserTokenEntity[]> {
return this.repository.find({
where: {
userId,
},
relations: {
user: true,
},
order: {
updatedAt: 'desc',
createdAt: 'desc',
},
});
}
create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
return this.repository.save(userToken);
}
save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
return this.repository.save(userToken);
}
async delete(userId: string, id: string): Promise<void> {
await this.repository.delete({ userId, id });
}
}

View File

@@ -0,0 +1,100 @@
import { IUserRepository, UserListFilter, UserStatsQueryResponse } from '@app/domain';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository } from 'typeorm';
import { UserEntity } from '../entities';
@Injectable()
export class UserRepository implements IUserRepository {
constructor(@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>) {}
async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
}
async getAdmin(): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { isAdmin: true } });
}
async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> {
let builder = this.userRepository.createQueryBuilder('user').where({ email });
if (withPassword) {
builder = builder.addSelect('user.password');
}
return builder.getOne();
}
async getByStorageLabel(storageLabel: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { storageLabel } });
}
async getByOAuthId(oauthId: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { oauthId } });
}
async getDeletedUsers(): Promise<UserEntity[]> {
return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
}
async getList({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> {
return this.userRepository.find({
withDeleted,
order: {
createdAt: 'DESC',
},
});
}
async create(user: Partial<UserEntity>): Promise<UserEntity> {
return this.userRepository.save(user);
}
async update(id: string, user: Partial<UserEntity>): Promise<UserEntity> {
user.id = id;
await this.userRepository.save(user);
const updatedUser = await this.get(id);
if (!updatedUser) {
throw new InternalServerErrorException('Cannot reload user after update');
}
return updatedUser;
}
async delete(user: UserEntity, hard?: boolean): Promise<UserEntity> {
if (hard) {
return this.userRepository.remove(user);
} else {
return this.userRepository.softRemove(user);
}
}
async restore(user: UserEntity): Promise<UserEntity> {
return this.userRepository.recover(user);
}
async getUserStats(): Promise<UserStatsQueryResponse[]> {
const stats = await this.userRepository
.createQueryBuilder('users')
.select('users.id', 'userId')
.addSelect('users.firstName', 'userFirstName')
.addSelect('users.lastName', 'userLastName')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
.addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
.leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif')
.groupBy('users.id')
.orderBy('users.createdAt', 'ASC')
.getRawMany();
for (const stat of stats) {
stat.photos = Number(stat.photos);
stat.videos = Number(stat.videos);
stat.usage = Number(stat.usage);
}
return stats;
}
}