feat(identities): ✨ Add identity centroid processor with new endpoints, DTOs, service methods, and module configuration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
932377ec12
commit
868b6d31ef
6 changed files with 0 additions and 467 deletions
|
|
@ -1,78 +0,0 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AddPhotosToIdentityDto, IdentityResponseDto, UpdateIdentityDto } from './identities.dto';
|
||||
import { IdentitiesService } from './identities.service';
|
||||
|
||||
@ApiTags('identities')
|
||||
@Controller('api/identities')
|
||||
export class IdentitiesController {
|
||||
constructor(private readonly identitiesService: IdentitiesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all identities' })
|
||||
@ApiResponse({ status: 200, description: 'Identities retrieved', type: [IdentityResponseDto] })
|
||||
async list() {
|
||||
const identities = await this.identitiesService.findAll();
|
||||
return { success: true, data: identities };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new identity' })
|
||||
@ApiResponse({ status: 201, description: 'Identity created', type: IdentityResponseDto })
|
||||
async create() {
|
||||
const identity = await this.identitiesService.create();
|
||||
return { success: true, data: identity };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update identity (name, isSelf, coverPhotoId)' })
|
||||
@ApiParam({ name: 'id', description: 'Identity UUID', format: 'uuid' })
|
||||
@ApiResponse({ status: 200, description: 'Identity updated', type: IdentityResponseDto })
|
||||
@ApiResponse({ status: 404, description: 'Identity not found' })
|
||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateIdentityDto) {
|
||||
const identity = await this.identitiesService.update(id, dto);
|
||||
return { success: true, data: identity };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Delete an identity' })
|
||||
@ApiParam({ name: 'id', description: 'Identity UUID', format: 'uuid' })
|
||||
@ApiResponse({ status: 204, description: 'Identity deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Identity not found' })
|
||||
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.identitiesService.delete(id);
|
||||
}
|
||||
|
||||
@Post(':id/photos')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Add photos to identity' })
|
||||
@ApiParam({ name: 'id', description: 'Identity UUID', format: 'uuid' })
|
||||
@ApiResponse({ status: 204, description: 'Photos added' })
|
||||
@ApiResponse({ status: 404, description: 'Identity not found' })
|
||||
async addPhotos(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AddPhotosToIdentityDto): Promise<void> {
|
||||
await this.identitiesService.addPhotos(id, 'lilith-default', dto);
|
||||
}
|
||||
|
||||
@Delete(':id/photos')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Remove photos from identity' })
|
||||
@ApiParam({ name: 'id', description: 'Identity UUID', format: 'uuid' })
|
||||
@ApiResponse({ status: 204, description: 'Photos removed' })
|
||||
@ApiResponse({ status: 404, description: 'Identity not found' })
|
||||
async removePhotos(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AddPhotosToIdentityDto): Promise<void> {
|
||||
await this.identitiesService.removePhotos(id, dto);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateIdentityDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isSelf?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
coverPhotoId?: string;
|
||||
}
|
||||
|
||||
export class AddPhotosToIdentityDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsUUID(undefined, { each: true })
|
||||
photoIds!: string[];
|
||||
}
|
||||
|
||||
export class IdentityResponseDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
name?: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
isSelf!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
photoCount!: number;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
coverPhotoId?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
coverThumbnailUrl?: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: Date;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { IdentitiesController } from './identities.controller';
|
||||
import { IdentitiesService } from './identities.service';
|
||||
|
||||
import { MinioModule } from '@/common/minio';
|
||||
import { IdentityEntity, PhotoEntity } from '@/entities';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([IdentityEntity, PhotoEntity]),
|
||||
BullModule.registerQueue({ name: 'identity-centroid' }),
|
||||
MinioModule.forEnv({
|
||||
defaultBucket: 'media-gallery',
|
||||
}),
|
||||
],
|
||||
controllers: [IdentitiesController],
|
||||
providers: [IdentitiesService],
|
||||
exports: [IdentitiesService],
|
||||
})
|
||||
export class IdentitiesModule {}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Queue } from 'bullmq';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { AddPhotosToIdentityDto, IdentityResponseDto, UpdateIdentityDto } from './identities.dto';
|
||||
|
||||
import { createLogger } from '@/common';
|
||||
import { MinioService } from '@/common/minio';
|
||||
import { IdentityEntity, PhotoEntity } from '@/entities';
|
||||
|
||||
const PRESIGNED_URL_EXPIRY = 3600;
|
||||
|
||||
@Injectable()
|
||||
export class IdentitiesService {
|
||||
private readonly logger = createLogger(IdentitiesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(IdentityEntity)
|
||||
private readonly identityRepository: Repository<IdentityEntity>,
|
||||
@InjectRepository(PhotoEntity)
|
||||
private readonly photoRepository: Repository<PhotoEntity>,
|
||||
private readonly minioService: MinioService,
|
||||
@InjectQueue('identity-centroid')
|
||||
private readonly identityCentroidQueue: Queue,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<IdentityResponseDto[]> {
|
||||
const identities = await this.identityRepository.find({
|
||||
order: { isSelf: 'DESC', photoCount: 'DESC', createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return Promise.all(identities.map((identity) => this.mapToResponse(identity)));
|
||||
}
|
||||
|
||||
async create(): Promise<IdentityResponseDto> {
|
||||
const identity = this.identityRepository.create({
|
||||
isSelf: false,
|
||||
photoCount: 0,
|
||||
});
|
||||
|
||||
const saved = await this.identityRepository.save(identity);
|
||||
return this.mapToResponse(saved);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateIdentityDto): Promise<IdentityResponseDto> {
|
||||
const identity = await this.identityRepository.findOne({ where: { id } });
|
||||
|
||||
if (!identity) {
|
||||
throw new NotFoundException('Identity not found');
|
||||
}
|
||||
|
||||
if (dto.isSelf === true) {
|
||||
// Clear self flag on all currently-self identities before setting new one
|
||||
await this.identityRepository.update({ isSelf: true }, { isSelf: false });
|
||||
identity.isSelf = true;
|
||||
} else if (dto.isSelf === false) {
|
||||
identity.isSelf = false;
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
identity.name = dto.name;
|
||||
}
|
||||
|
||||
if (dto.coverPhotoId !== undefined) {
|
||||
identity.coverPhotoId = dto.coverPhotoId ?? null;
|
||||
}
|
||||
|
||||
const saved = await this.identityRepository.save(identity);
|
||||
|
||||
this.logger.logWithData('info', 'Identity updated', { id, isSelf: saved.isSelf });
|
||||
|
||||
return this.mapToResponse(saved);
|
||||
}
|
||||
|
||||
async addPhotos(id: string, userId: string, dto: AddPhotosToIdentityDto): Promise<void> {
|
||||
const identity = await this.identityRepository.findOne({
|
||||
where: { id, userId },
|
||||
relations: ['photos'],
|
||||
});
|
||||
|
||||
if (!identity) {
|
||||
throw new NotFoundException('Identity not found');
|
||||
}
|
||||
|
||||
const photos = await this.photoRepository.find({
|
||||
where: { id: In(dto.photoIds) },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
const existingIds = new Set(identity.photos.map((p) => p.id));
|
||||
const newPhotos = photos.filter((p) => !existingIds.has(p.id));
|
||||
|
||||
identity.photos = [...identity.photos, ...newPhotos];
|
||||
identity.photoCount = identity.photos.length;
|
||||
|
||||
await this.identityRepository.save(identity);
|
||||
|
||||
// Trigger centroid build when identity has >= 3 photos and no centroid yet
|
||||
if (identity.photos.length >= 3 && identity.centroidStatus === 'empty') {
|
||||
await this.identityCentroidQueue.add(
|
||||
'build',
|
||||
{ identityId: id, userId },
|
||||
{ attempts: 2, backoff: { type: 'exponential', delay: 5000 } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async removePhotos(id: string, dto: AddPhotosToIdentityDto): Promise<void> {
|
||||
const identity = await this.identityRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['photos'],
|
||||
});
|
||||
|
||||
if (!identity) {
|
||||
throw new NotFoundException('Identity not found');
|
||||
}
|
||||
|
||||
const removeSet = new Set(dto.photoIds);
|
||||
identity.photos = identity.photos.filter((p) => !removeSet.has(p.id));
|
||||
identity.photoCount = identity.photos.length;
|
||||
|
||||
// Clear cover photo if it was removed
|
||||
if (identity.coverPhotoId && removeSet.has(identity.coverPhotoId)) {
|
||||
identity.coverPhotoId = null;
|
||||
}
|
||||
|
||||
await this.identityRepository.save(identity);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const identity = await this.identityRepository.findOne({ where: { id } });
|
||||
|
||||
if (!identity) {
|
||||
throw new NotFoundException('Identity not found');
|
||||
}
|
||||
|
||||
await this.identityRepository.remove(identity);
|
||||
}
|
||||
|
||||
private async mapToResponse(identity: IdentityEntity): Promise<IdentityResponseDto> {
|
||||
const response: IdentityResponseDto = {
|
||||
id: identity.id,
|
||||
name: identity.name,
|
||||
isSelf: identity.isSelf,
|
||||
photoCount: identity.photoCount,
|
||||
coverPhotoId: identity.coverPhotoId,
|
||||
createdAt: identity.createdAt,
|
||||
updatedAt: identity.updatedAt,
|
||||
};
|
||||
|
||||
if (identity.coverPhotoId) {
|
||||
const coverPhoto = await this.photoRepository.findOne({
|
||||
where: { id: identity.coverPhotoId },
|
||||
select: ['thumbnailKey'],
|
||||
});
|
||||
|
||||
if (coverPhoto?.thumbnailKey) {
|
||||
try {
|
||||
response.coverThumbnailUrl = await this.minioService.getDownloadUrl(
|
||||
coverPhoto.thumbnailKey,
|
||||
PRESIGNED_URL_EXPIRY,
|
||||
);
|
||||
} catch {
|
||||
// URL generation failed, leave undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { createLogger } from '@/common';
|
||||
import { MinioService } from '@/common/minio';
|
||||
import { IdentityEntity, PhotoEntity } from '@/entities';
|
||||
|
||||
import { ImajinIdentityClient } from '../face-extraction/imajin-identity.client';
|
||||
|
||||
interface IdentityCentroidJobData {
|
||||
identityId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface IdentityMatchingJobData {
|
||||
photoId: string;
|
||||
storageKey: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
@Processor('identity-centroid', { concurrency: 1 })
|
||||
export class IdentityCentroidProcessor extends WorkerHost {
|
||||
private readonly logger = createLogger(IdentityCentroidProcessor.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(IdentityEntity)
|
||||
private readonly identityRepository: Repository<IdentityEntity>,
|
||||
@InjectRepository(PhotoEntity)
|
||||
private readonly photoRepository: Repository<PhotoEntity>,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly imajinIdentityClient: ImajinIdentityClient,
|
||||
@InjectQueue('identity-matching')
|
||||
private readonly identityMatchingQueue: Queue,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<IdentityCentroidJobData>): Promise<void> {
|
||||
const { identityId, userId } = job.data;
|
||||
|
||||
this.logger.logWithData('info', 'Building identity centroid', {
|
||||
identityId,
|
||||
userId,
|
||||
attempt: job.attemptsMade + 1,
|
||||
});
|
||||
|
||||
const identity = await this.identityRepository.findOne({
|
||||
where: { id: identityId },
|
||||
relations: ['photos'],
|
||||
});
|
||||
|
||||
if (!identity) {
|
||||
this.logger.logWithData('warn', 'Identity not found for centroid build', { identityId });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.identityRepository.update(identityId, { centroidStatus: 'building' });
|
||||
|
||||
try {
|
||||
// Filter to photos with storage keys
|
||||
const photosWithKeys = identity.photos.filter((p) => p.storageKey);
|
||||
|
||||
if (photosWithKeys.length === 0) {
|
||||
this.logger.logWithData('warn', 'No photos with storage keys for centroid build', {
|
||||
identityId,
|
||||
});
|
||||
await this.identityRepository.update(identityId, { centroidStatus: 'empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate presigned URLs for all photos (1 hour)
|
||||
const imageUrls = await Promise.all(
|
||||
photosWithKeys.map((p) => this.minioService.getDownloadUrl(p.storageKey!, 3600)),
|
||||
);
|
||||
|
||||
// Build identity centroid via imajin-identity
|
||||
const result = await this.imajinIdentityClient.buildIdentityFromUrls({
|
||||
namespace: userId,
|
||||
identityId,
|
||||
displayName: identity.name ?? identityId,
|
||||
imageUrls,
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Identity build failed: ${result.message ?? 'unknown error'}`);
|
||||
}
|
||||
|
||||
await this.identityRepository.update(identityId, {
|
||||
centroidStatus: 'ready',
|
||||
centroidPhotoCount: result.image_count,
|
||||
imajinSyncedAt: new Date(),
|
||||
});
|
||||
|
||||
this.logger.logWithData('info', 'Identity centroid built successfully', {
|
||||
identityId,
|
||||
imageCount: result.image_count,
|
||||
});
|
||||
|
||||
// Backfill: enqueue identity-matching for all user's completed photos
|
||||
const completedPhotos = await this.photoRepository
|
||||
.createQueryBuilder('photo')
|
||||
.innerJoin('photo.device', 'device')
|
||||
.where('device.userId = :userId', { userId })
|
||||
.andWhere('photo.faceExtractionStatus = :status', { status: 'completed' })
|
||||
.andWhere('photo.storageKey IS NOT NULL')
|
||||
.select(['photo.id', 'photo.storageKey'])
|
||||
.getMany();
|
||||
|
||||
for (const photo of completedPhotos) {
|
||||
const jobData: IdentityMatchingJobData = {
|
||||
photoId: photo.id,
|
||||
storageKey: photo.storageKey!,
|
||||
userId,
|
||||
};
|
||||
await this.identityMatchingQueue.add('match', jobData, {
|
||||
attempts: 2,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.logWithData('info', 'Enqueued backfill identity matching jobs', {
|
||||
identityId,
|
||||
photoCount: completedPhotos.length,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
this.logger.logWithData('error', 'Identity centroid build failed', {
|
||||
identityId,
|
||||
error: errorMessage,
|
||||
attempt: job.attemptsMade + 1,
|
||||
});
|
||||
|
||||
await this.identityRepository.update(identityId, { centroidStatus: 'empty' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { IdentitiesModule } from './identities.module';
|
||||
Loading…
Add table
Reference in a new issue