feat: persistent memories (#8330)

* feat: persistent memories

* refactor: use new add/remove asset utility
This commit is contained in:
Jason Rasmussen 2024-04-02 10:23:17 -04:00 committed by GitHub
parent 0849dbd1af
commit cd0e537e3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 3497 additions and 0 deletions

View file

@ -10,6 +10,7 @@ import { DownloadController } from 'src/controllers/download.controller';
import { FaceController } from 'src/controllers/face.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MemoryController } from 'src/controllers/memory.controller';
import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller';
@ -36,6 +37,7 @@ export const controllers = [
FaceController,
JobController,
LibraryController,
MemoryController,
OAuthController,
PartnerController,
SearchController,

View file

@ -0,0 +1,64 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto } from 'src/dtos/memory.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MemoryService } from 'src/services/memory.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Memory')
@Controller('memories')
@Authenticated()
export class MemoryController {
constructor(private service: MemoryService) {}
@Get()
searchMemories(@Auth() auth: AuthDto): Promise<MemoryResponseDto[]> {
return this.service.search(auth);
}
@Post()
createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise<MemoryResponseDto> {
return this.service.create(auth, dto);
}
@Get(':id')
getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<MemoryResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
updateMemory(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: MemoryUpdateDto,
): Promise<MemoryResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@Put(':id/assets')
addMemoryAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: BulkIdsDto,
): Promise<BulkIdResponseDto[]> {
return this.service.addAssets(auth, id, dto);
}
@Delete(':id/assets')
@HttpCode(HttpStatus.OK)
removeMemoryAssets(
@Auth() auth: AuthDto,
@Body() dto: BulkIdsDto,
@Param() { id }: UUIDParamDto,
): Promise<BulkIdResponseDto[]> {
return this.service.removeAssets(auth, id, dto);
}
}

View file

@ -33,6 +33,10 @@ export enum Permission {
TIMELINE_READ = 'timeline.read',
TIMELINE_DOWNLOAD = 'timeline.download',
MEMORY_READ = 'memory.read',
MEMORY_WRITE = 'memory.write',
MEMORY_DELETE = 'memory.delete',
PERSON_READ = 'person.read',
PERSON_WRITE = 'person.write',
PERSON_MERGE = 'person.merge',
@ -259,6 +263,18 @@ export class AccessCore {
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
}
case Permission.MEMORY_READ: {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids);
}
case Permission.MEMORY_WRITE: {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids);
}
case Permission.MEMORY_DELETE: {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_READ: {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}

View file

@ -0,0 +1,84 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { MemoryEntity, MemoryType } from 'src/entities/memory.entity';
import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
class MemoryBaseDto {
@ValidateBoolean({ optional: true })
isSaved?: boolean;
@ValidateDate({ optional: true })
seenAt?: Date;
}
class OnThisDayDto {
@IsInt()
@IsPositive()
year!: number;
}
type MemoryData = OnThisDayDto;
export class MemoryUpdateDto extends MemoryBaseDto {
@ValidateDate({ optional: true })
memoryAt?: Date;
}
export class MemoryCreateDto extends MemoryBaseDto {
@IsEnum(MemoryType)
@ApiProperty({ enum: MemoryType, enumName: 'MemoryType' })
type!: MemoryType;
@IsObject()
@ValidateNested()
@Type((options) => {
switch (options?.object.type) {
case MemoryType.ON_THIS_DAY: {
return OnThisDayDto;
}
default: {
return Object;
}
}
})
data!: MemoryData;
@ValidateDate()
memoryAt!: Date;
@ValidateUUID({ optional: true, each: true })
assetIds?: string[];
}
export class MemoryResponseDto {
id!: string;
createdAt!: Date;
updatedAt!: Date;
deletedAt?: Date;
memoryAt!: Date;
seenAt?: Date;
ownerId!: string;
type!: MemoryType;
data!: MemoryData;
isSaved!: boolean;
assets!: AssetResponseDto[];
}
export const mapMemory = (entity: MemoryEntity): MemoryResponseDto => {
return {
id: entity.id,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
deletedAt: entity.deletedAt,
memoryAt: entity.memoryAt,
seenAt: entity.seenAt,
ownerId: entity.ownerId,
type: entity.type,
data: entity.data,
isSaved: entity.isSaved,
assets: entity.assets.map((asset) => mapAsset(asset)),
};
};

View file

@ -9,6 +9,7 @@ import { AuditEntity } from 'src/entities/audit.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { LibraryEntity } from 'src/entities/library.entity';
import { MemoryEntity } from 'src/entities/memory.entity';
import { MoveEntity } from 'src/entities/move.entity';
import { PartnerEntity } from 'src/entities/partner.entity';
import { PersonEntity } from 'src/entities/person.entity';
@ -32,6 +33,7 @@ export const entities = [
AuditEntity,
ExifEntity,
GeodataPlacesEntity,
MemoryEntity,
MoveEntity,
PartnerEntity,
PersonEntity,

View file

@ -0,0 +1,67 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export enum MemoryType {
/** pictures taken on this day X years ago */
ON_THIS_DAY = 'on_this_day',
}
export type OnThisDayData = { year: number };
export interface MemoryData {
[MemoryType.ON_THIS_DAY]: OnThisDayData;
}
@Entity('memories')
export class MemoryEntity<T extends MemoryType = MemoryType> {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
owner!: UserEntity;
@Column()
ownerId!: string;
@Column()
type!: T;
@Column({ type: 'jsonb' })
data!: MemoryData[T];
/** unless set to true, will be automatically deleted in the future */
@Column({ default: false })
isSaved!: boolean;
/** memories are sorted in ascending order by this value */
@Column({ type: 'timestamptz' })
memoryAt!: Date;
/** when the user last viewed the memory */
@Column({ type: 'timestamptz', nullable: true })
seenAt?: Date;
@ManyToMany(() => AssetEntity)
@JoinTable()
assets!: AssetEntity[];
}

View file

@ -32,6 +32,10 @@ export interface IAccessRepository {
checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
};
memory: {
checkOwnerAccess(userId: string, memoryIds: Set<string>): Promise<Set<string>>;
};
person: {
checkFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>;
checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>;

View file

@ -0,0 +1,14 @@
import { MemoryEntity } from 'src/entities/memory.entity';
export const IMemoryRepository = 'IMemoryRepository';
export interface IMemoryRepository {
search(ownerId: string): Promise<MemoryEntity[]>;
get(id: string): Promise<MemoryEntity | null>;
create(memory: Partial<MemoryEntity>): Promise<MemoryEntity>;
update(memory: Partial<MemoryEntity>): Promise<MemoryEntity>;
delete(id: string): Promise<void>;
getAssetIds(id: string, assetIds: string[]): Promise<Set<string>>;
addAssetIds(id: string, assetIds: string[]): Promise<void>;
removeAssetIds(id: string, assetIds: string[]): Promise<void>;
}

View file

@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddMemoryTable1711637874206 implements MigrationInterface {
name = 'AddMemoryTable1711637874206'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "memories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "ownerId" uuid NOT NULL, "type" character varying NOT NULL, "data" jsonb NOT NULL, "isSaved" boolean NOT NULL DEFAULT false, "memoryAt" TIMESTAMP WITH TIME ZONE NOT NULL, "seenAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_aaa0692d9496fe827b0568612f8" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "memories_assets_assets" ("memoriesId" uuid NOT NULL, "assetsId" uuid NOT NULL, CONSTRAINT "PK_fcaf7112a013d1703c011c6793d" PRIMARY KEY ("memoriesId", "assetsId"))`);
await queryRunner.query(`CREATE INDEX "IDX_984e5c9ab1f04d34538cd32334" ON "memories_assets_assets" ("memoriesId") `);
await queryRunner.query(`CREATE INDEX "IDX_6942ecf52d75d4273de19d2c16" ON "memories_assets_assets" ("assetsId") `);
await queryRunner.query(`ALTER TABLE "memories" ADD CONSTRAINT "FK_575842846f0c28fa5da46c99b19" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "FK_984e5c9ab1f04d34538cd32334e" FOREIGN KEY ("memoriesId") REFERENCES "memories"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "FK_6942ecf52d75d4273de19d2c16f" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "memories_assets_assets" DROP CONSTRAINT "FK_6942ecf52d75d4273de19d2c16f"`);
await queryRunner.query(`ALTER TABLE "memories_assets_assets" DROP CONSTRAINT "FK_984e5c9ab1f04d34538cd32334e"`);
await queryRunner.query(`ALTER TABLE "memories" DROP CONSTRAINT "FK_575842846f0c28fa5da46c99b19"`);
await queryRunner.query(`DROP INDEX "public"."IDX_6942ecf52d75d4273de19d2c16"`);
await queryRunner.query(`DROP INDEX "public"."IDX_984e5c9ab1f04d34538cd32334"`);
await queryRunner.query(`DROP TABLE "memories_assets_assets"`);
await queryRunner.query(`DROP TABLE "memories"`);
}
}

View file

@ -196,6 +196,20 @@ WHERE
)
AND ("LibraryEntity"."deletedAt" IS NULL)
-- AccessRepository.memory.checkOwnerAccess
SELECT
"MemoryEntity"."id" AS "MemoryEntity_id"
FROM
"memories" "MemoryEntity"
WHERE
(
(
("MemoryEntity"."id" IN ($1))
AND ("MemoryEntity"."ownerId" = $2)
)
)
AND ("MemoryEntity"."deletedAt" IS NULL)
-- AccessRepository.person.checkOwnerAccess
SELECT
"PersonEntity"."id" AS "PersonEntity_id"

View file

@ -0,0 +1,18 @@
-- NOTE: This file is auto generated by ./sql-generator
-- MemoryRepository.getAssetIds
SELECT
"memories_assets"."assetsId" AS "assetId"
FROM
"memories_assets_assets" "memories_assets"
WHERE
"memories_assets"."memoriesId" = $1
AND "memories_assets"."assetsId" IN ($2)
-- MemoryRepository.removeAssetIds
DELETE FROM "memories_assets_assets"
WHERE
(
"memoriesId" = $1
AND "assetsId" IN ($2)
)

View file

@ -5,6 +5,7 @@ import { AlbumEntity } from 'src/entities/album.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryEntity } from 'src/entities/library.entity';
import { MemoryEntity } from 'src/entities/memory.entity';
import { PartnerEntity } from 'src/entities/partner.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
@ -19,6 +20,7 @@ type IAssetAccess = IAccessRepository['asset'];
type IAuthDeviceAccess = IAccessRepository['authDevice'];
type ILibraryAccess = IAccessRepository['library'];
type ITimelineAccess = IAccessRepository['timeline'];
type IMemoryAccess = IAccessRepository['memory'];
type IPersonAccess = IAccessRepository['person'];
type IPartnerAccess = IAccessRepository['partner'];
@ -345,6 +347,28 @@ class TimelineAccess implements ITimelineAccess {
}
}
class MemoryAccess implements IMemoryAccess {
constructor(private memoryRepository: Repository<MemoryEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, memoryIds: Set<string>): Promise<Set<string>> {
if (memoryIds.size === 0) {
return new Set();
}
return this.memoryRepository
.find({
select: { id: true },
where: {
id: In([...memoryIds]),
ownerId: userId,
},
})
.then((memories) => new Set(memories.map((memory) => memory.id)));
}
}
class PersonAccess implements IPersonAccess {
constructor(
private assetFaceRepository: Repository<AssetFaceEntity>,
@ -416,6 +440,7 @@ export class AccessRepository implements IAccessRepository {
asset: IAssetAccess;
authDevice: IAuthDeviceAccess;
library: ILibraryAccess;
memory: IMemoryAccess;
person: IPersonAccess;
partner: IPartnerAccess;
timeline: ITimelineAccess;
@ -425,6 +450,7 @@ export class AccessRepository implements IAccessRepository {
@InjectRepository(AssetEntity) assetRepository: Repository<AssetEntity>,
@InjectRepository(AlbumEntity) albumRepository: Repository<AlbumEntity>,
@InjectRepository(LibraryEntity) libraryRepository: Repository<LibraryEntity>,
@InjectRepository(MemoryEntity) memoryRepository: Repository<MemoryEntity>,
@InjectRepository(PartnerEntity) partnerRepository: Repository<PartnerEntity>,
@InjectRepository(PersonEntity) personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>,
@ -436,6 +462,7 @@ export class AccessRepository implements IAccessRepository {
this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository);
this.authDevice = new AuthDeviceAccess(tokenRepository);
this.library = new LibraryAccess(libraryRepository);
this.memory = new MemoryAccess(memoryRepository);
this.person = new PersonAccess(assetFaceRepository, personRepository);
this.partner = new PartnerAccess(partnerRepository);
this.timeline = new TimelineAccess(partnerRepository);

View file

@ -13,6 +13,7 @@ import { IJobRepository } from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMemoryRepository } from 'src/interfaces/memory.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
@ -42,6 +43,7 @@ import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MetricRepository } from 'src/repositories/metric.repository';
import { MoveRepository } from 'src/repositories/move.repository';
@ -72,6 +74,7 @@ export const repositories = [
{ provide: ILibraryRepository, useClass: LibraryRepository },
{ provide: IKeyRepository, useClass: ApiKeyRepository },
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
{ provide: IMemoryRepository, useClass: MemoryRepository },
{ provide: IMetadataRepository, useClass: MetadataRepository },
{ provide: IMetricRepository, useClass: MetricRepository },
{ provide: IMoveRepository, useClass: MoveRepository },

View file

@ -0,0 +1,104 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { MemoryEntity } from 'src/entities/memory.entity';
import { IMemoryRepository } from 'src/interfaces/memory.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { DataSource, In, Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class MemoryRepository implements IMemoryRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(MemoryEntity) private repository: Repository<MemoryEntity>,
@InjectDataSource() private dataSource: DataSource,
) {}
search(ownerId: string): Promise<MemoryEntity[]> {
return this.repository.find({
where: {
ownerId,
},
order: {
memoryAt: 'DESC',
},
});
}
get(id: string): Promise<MemoryEntity | null> {
return this.repository.findOne({
where: {
id,
},
relations: {
assets: true,
},
});
}
create(memory: Partial<MemoryEntity>): Promise<MemoryEntity> {
return this.save(memory);
}
update(memory: Partial<MemoryEntity>): Promise<MemoryEntity> {
return this.save(memory);
}
async delete(id: string): Promise<void> {
await this.repository.delete({ id });
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@ChunkedSet({ paramIndex: 1 })
async getAssetIds(id: string, assetIds: string[]): Promise<Set<string>> {
if (assetIds.length === 0) {
return new Set();
}
const results = await this.dataSource
.createQueryBuilder()
.select('memories_assets.assetsId', 'assetId')
.from('memories_assets_assets', 'memories_assets')
.where('"memories_assets"."memoriesId" = :memoryId', { memoryId: id })
.andWhere('memories_assets.assetsId IN (:...assetIds)', { assetIds })
.getRawMany();
return new Set(results.map((row) => row['assetId']));
}
@GenerateSql({ params: [{ albumId: DummyValue.UUID, assetIds: [DummyValue.UUID] }] })
async addAssetIds(id: string, assetIds: string[]): Promise<void> {
await this.dataSource
.createQueryBuilder()
.insert()
.into('memories_assets_assets', ['memoriesId', 'assetsId'])
.values(assetIds.map((assetId) => ({ memoriesId: id, assetsId: assetId })))
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@Chunked({ paramIndex: 1 })
async removeAssetIds(id: string, assetIds: string[]): Promise<void> {
await this.dataSource
.createQueryBuilder()
.delete()
.from('memories_assets_assets')
.where({
memoriesId: id,
assetsId: In(assetIds),
})
.execute();
}
private async save(memory: Partial<MemoryEntity>): Promise<MemoryEntity> {
const { id } = await this.repository.save(memory);
return this.repository.findOneOrFail({
where: { id },
relations: {
assets: true,
},
});
}
}

View file

@ -11,6 +11,7 @@ import { DownloadService } from 'src/services/download.service';
import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MediaService } from 'src/services/media.service';
import { MemoryService } from 'src/services/memory.service';
import { MetadataService } from 'src/services/metadata.service';
import { MicroservicesService } from 'src/services/microservices.service';
import { PartnerService } from 'src/services/partner.service';
@ -42,6 +43,7 @@ export const services = [
JobService,
LibraryService,
MediaService,
MemoryService,
MetadataService,
PartnerService,
PersonService,

View file

@ -0,0 +1,214 @@
import { BadRequestException } from '@nestjs/common';
import { MemoryType } from 'src/entities/memory.entity';
import { IMemoryRepository } from 'src/interfaces/memory.interface';
import { MemoryService } from 'src/services/memory.service';
import { authStub } from 'test/fixtures/auth.stub';
import { memoryStub } from 'test/fixtures/memory.stub';
import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newMemoryRepositoryMock } from 'test/repositories/memory.repository.mock';
describe(MemoryService.name, () => {
let accessMock: IAccessRepositoryMock;
let memoryMock: jest.Mocked<IMemoryRepository>;
let sut: MemoryService;
beforeEach(() => {
accessMock = newAccessRepositoryMock();
memoryMock = newMemoryRepositoryMock();
sut = new MemoryService(accessMock, memoryMock);
});
it('should be defined', () => {
expect(sut).toBeDefined();
});
describe('search', () => {
it('should search memories', async () => {
memoryMock.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]);
await expect(sut.search(authStub.admin)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }),
expect.objectContaining({ id: 'memoryEmpty', assets: [] }),
]),
);
});
it('should map ', async () => {
await expect(sut.search(authStub.admin)).resolves.toEqual([]);
});
});
describe('get', () => {
it('should throw an error when no access', async () => {
await expect(sut.get(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException);
});
it('should throw an error when the memory is not found', async () => {
accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['race-condition']));
await expect(sut.get(authStub.admin, 'race-condition')).rejects.toBeInstanceOf(BadRequestException);
});
it('should get a memory by id', async () => {
memoryMock.get.mockResolvedValue(memoryStub.memory1);
accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
await expect(sut.get(authStub.admin, 'memory1')).resolves.toMatchObject({ id: 'memory1' });
expect(memoryMock.get).toHaveBeenCalledWith('memory1');
expect(accessMock.memory.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['memory1']));
});
});
describe('create', () => {
it('should skip assets the user does not have access to', async () => {
memoryMock.create.mockResolvedValue(memoryStub.empty);
await expect(
sut.create(authStub.admin, {
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 },
assetIds: ['not-mine'],
memoryAt: new Date(2024),
}),
).resolves.toMatchObject({ assets: [] });
expect(memoryMock.create).toHaveBeenCalledWith(expect.objectContaining({ assets: [] }));
});
it('should create a memory', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1']));
memoryMock.create.mockResolvedValue(memoryStub.memory1);
await expect(
sut.create(authStub.admin, {
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 },
assetIds: ['asset1'],
memoryAt: new Date(2024),
}),
).resolves.toBeDefined();
expect(memoryMock.create).toHaveBeenCalledWith(
expect.objectContaining({
ownerId: userStub.admin.id,
assets: [{ id: 'asset1' }],
}),
);
});
it('should create a memory without assets', async () => {
memoryMock.create.mockResolvedValue(memoryStub.memory1);
await expect(
sut.create(authStub.admin, {
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 },
memoryAt: new Date(2024),
}),
).resolves.toBeDefined();
});
});
describe('update', () => {
it('should require access', async () => {
await expect(sut.update(authStub.admin, 'not-found', { isSaved: true })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(memoryMock.update).not.toHaveBeenCalled();
});
it('should update a memory', async () => {
accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
memoryMock.update.mockResolvedValue(memoryStub.memory1);
await expect(sut.update(authStub.admin, 'memory1', { isSaved: true })).resolves.toBeDefined();
expect(memoryMock.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'memory1',
isSaved: true,
}),
);
});
});
describe('remove', () => {
it('should require access', async () => {
await expect(sut.remove(authStub.admin, 'not-found')).rejects.toBeInstanceOf(BadRequestException);
expect(memoryMock.delete).not.toHaveBeenCalled();
});
it('should delete a memory', async () => {
accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
await expect(sut.remove(authStub.admin, 'memory1')).resolves.toBeUndefined();
expect(memoryMock.delete).toHaveBeenCalledWith('memory1');
});
});
describe('addAssets', () => {
it('should require memory access', async () => {
await expect(sut.addAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(memoryMock.addAssetIds).not.toHaveBeenCalled();
});
it('should require asset access', async () => {
accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
memoryMock.get.mockResolvedValue(memoryStub.memory1);
await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([
{ error: 'no_permission', id: 'not-found', success: false },
]);
expect(memoryMock.addAssetIds).not.toHaveBeenCalled();
});
it('should skip assets already in the memory', async () => {
accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
memoryMock.get.mockResolvedValue(memoryStub.memory1);
memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1']));
await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([
{ error: 'duplicate', id: 'asset1', success: false },
]);
expect(memoryMock.addAssetIds).not.toHaveBeenCalled();
});
it('should add assets', async () => {
accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1']));
memoryMock.get.mockResolvedValue(memoryStub.memory1);
await expect(sut.addAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', success: true },
]);
expect(memoryMock.addAssetIds).toHaveBeenCalledWith('memory1', ['asset1']);
});
});
describe('removeAssets', () => {
it('should require memory access', async () => {
await expect(sut.removeAssets(authStub.admin, 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(memoryMock.removeAssetIds).not.toHaveBeenCalled();
});
it('should skip assets not in the memory', async () => {
accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['not-found'] })).resolves.toEqual([
{ error: 'not_found', id: 'not-found', success: false },
]);
expect(memoryMock.removeAssetIds).not.toHaveBeenCalled();
});
it('should require asset access', async () => {
accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1']));
await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([
{ error: 'no_permission', id: 'asset1', success: false },
]);
expect(memoryMock.removeAssetIds).not.toHaveBeenCalled();
});
it('should remove assets', async () => {
accessMock.memory.checkOwnerAccess.mockResolvedValue(new Set(['memory1']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1']));
memoryMock.getAssetIds.mockResolvedValue(new Set(['asset1']));
await expect(sut.removeAssets(authStub.admin, 'memory1', { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', success: true },
]);
expect(memoryMock.removeAssetIds).toHaveBeenCalledWith('memory1', ['asset1']);
});
});
});

View file

@ -0,0 +1,105 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IMemoryRepository } from 'src/interfaces/memory.interface';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@Injectable()
export class MemoryService {
private access: AccessCore;
constructor(
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IMemoryRepository) private repository: IMemoryRepository,
) {
this.access = AccessCore.create(accessRepository);
}
async search(auth: AuthDto) {
const memories = await this.repository.search(auth.user.id);
return memories.map((memory) => mapMemory(memory));
}
async get(auth: AuthDto, id: string): Promise<MemoryResponseDto> {
await this.access.requirePermission(auth, Permission.MEMORY_READ, id);
const memory = await this.findOrFail(id);
return mapMemory(memory);
}
async create(auth: AuthDto, dto: MemoryCreateDto) {
// TODO validate type/data combination
const assetIds = dto.assetIds || [];
const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, assetIds);
const memory = await this.repository.create({
ownerId: auth.user.id,
type: dto.type,
data: dto.data,
isSaved: dto.isSaved,
memoryAt: dto.memoryAt,
seenAt: dto.seenAt,
assets: [...allowedAssetIds].map((id) => ({ id }) as AssetEntity),
});
return mapMemory(memory);
}
async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> {
await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id);
const memory = await this.repository.update({
id,
isSaved: dto.isSaved,
memoryAt: dto.memoryAt,
seenAt: dto.seenAt,
});
return mapMemory(memory);
}
async remove(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.MEMORY_DELETE, id);
await this.repository.delete(id);
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.access.requirePermission(auth, Permission.MEMORY_READ, id);
const repos = { accessRepository: this.accessRepository, repository: this.repository };
const results = await addAssets(auth, repos, { id, assetIds: dto.ids });
const hasSuccess = results.find(({ success }) => success);
if (hasSuccess) {
await this.repository.update({ id, updatedAt: new Date() });
}
return results;
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id);
const repos = { accessRepository: this.accessRepository, repository: this.repository };
const permissions = [Permission.ASSET_SHARE];
const results = await removeAssets(auth, repos, { id, assetIds: dto.ids, permissions });
const hasSuccess = results.find(({ success }) => success);
if (hasSuccess) {
await this.repository.update({ id, updatedAt: new Date() });
}
return results;
}
private async findOrFail(id: string) {
const memory = await this.repository.get(id);
if (!memory) {
throw new BadRequestException('Memory not found');
}
return memory;
}
}

30
server/test/fixtures/memory.stub.ts vendored Normal file
View file

@ -0,0 +1,30 @@
import { MemoryEntity, MemoryType } from 'src/entities/memory.entity';
import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub';
export const memoryStub = {
empty: <MemoryEntity>{
id: 'memoryEmpty',
createdAt: new Date(),
updatedAt: new Date(),
memoryAt: new Date(2024),
ownerId: userStub.admin.id,
owner: userStub.admin,
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 },
isSaved: false,
assets: [],
},
memory1: <MemoryEntity>{
id: 'memory1',
createdAt: new Date(),
updatedAt: new Date(),
memoryAt: new Date(2024),
ownerId: userStub.admin.id,
owner: userStub.admin,
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 },
isSaved: false,
assets: [assetStub.image1],
},
};

View file

@ -8,6 +8,7 @@ export interface IAccessRepositoryMock {
authDevice: jest.Mocked<IAccessRepository['authDevice']>;
library: jest.Mocked<IAccessRepository['library']>;
timeline: jest.Mocked<IAccessRepository['timeline']>;
memory: jest.Mocked<IAccessRepository['memory']>;
person: jest.Mocked<IAccessRepository['person']>;
partner: jest.Mocked<IAccessRepository['partner']>;
}
@ -49,6 +50,10 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
checkPartnerAccess: jest.fn().mockResolvedValue(new Set()),
},
memory: {
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
},
person: {
checkFaceOwnerAccess: jest.fn().mockResolvedValue(new Set()),
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),

View file

@ -0,0 +1,14 @@
import { IMemoryRepository } from 'src/interfaces/memory.interface';
export const newMemoryRepositoryMock = (): jest.Mocked<IMemoryRepository> => {
return {
search: jest.fn().mockResolvedValue([]),
get: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
getAssetIds: jest.fn().mockResolvedValue(new Set()),
addAssetIds: jest.fn(),
removeAssetIds: jest.fn(),
};
};