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:
Claude Code 2026-04-04 07:56:39 -07:00
parent 932377ec12
commit 868b6d31ef
6 changed files with 0 additions and 467 deletions

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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 {}

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

@ -1 +0,0 @@
export { IdentitiesModule } from './identities.module';