feat: persistent memories (#15953)

feat: memories

refactor

chore: use heart as favorite icon

fix: linting
This commit is contained in:
Jason Rasmussen 2025-02-21 13:31:37 -05:00 committed by GitHub
parent 502f6e020d
commit d350022dec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 585 additions and 70 deletions

View file

@ -1,8 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } 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 { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto } from 'src/dtos/memory.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MemoryService } from 'src/services/memory.service';
@ -15,8 +15,8 @@ export class MemoryController {
@Get()
@Authenticated({ permission: Permission.MEMORY_READ })
searchMemories(@Auth() auth: AuthDto): Promise<MemoryResponseDto[]> {
return this.service.search(auth);
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()

2
server/src/db.d.ts vendored
View file

@ -227,11 +227,13 @@ export interface Memories {
createdAt: Generated<Timestamp>;
data: Json;
deletedAt: Timestamp | null;
hideAt: Timestamp | null;
id: Generated<string>;
isSaved: Generated<boolean>;
memoryAt: Timestamp;
ownerId: string;
seenAt: Timestamp | null;
showAt: Timestamp | null;
type: string;
updatedAt: Generated<Timestamp>;
}

View file

@ -5,7 +5,7 @@ import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { MemoryType } from 'src/enum';
import { MemoryItem } from 'src/types';
import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
class MemoryBaseDto {
@ValidateBoolean({ optional: true })
@ -15,6 +15,22 @@ class MemoryBaseDto {
seenAt?: Date;
}
export class MemorySearchDto {
@Optional()
@IsEnum(MemoryType)
@ApiProperty({ enum: MemoryType, enumName: 'MemoryType' })
type?: MemoryType;
@ValidateDate({ optional: true })
for?: Date;
@ValidateBoolean({ optional: true })
isTrashed?: boolean;
@ValidateBoolean({ optional: true })
isSaved?: boolean;
}
class OnThisDayDto {
@IsInt()
@IsPositive()
@ -62,6 +78,8 @@ export class MemoryResponseDto {
deletedAt?: Date;
memoryAt!: Date;
seenAt?: Date;
showAt?: Date;
hideAt?: Date;
ownerId!: string;
@ApiProperty({ enumName: 'MemoryType', enum: MemoryType })
type!: MemoryType;
@ -78,6 +96,8 @@ export const mapMemory = (entity: MemoryItem): MemoryResponseDto => {
deletedAt: entity.deletedAt ?? undefined,
memoryAt: entity.memoryAt,
seenAt: entity.seenAt ?? undefined,
showAt: entity.showAt ?? undefined,
hideAt: entity.hideAt ?? undefined,
ownerId: entity.ownerId,
type: entity.type as MemoryType,
data: entity.data as unknown as MemoryData,

View file

@ -53,6 +53,12 @@ export class MemoryEntity<T extends MemoryType = MemoryType> {
@Column({ type: 'timestamptz' })
memoryAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
showAt?: Date;
@Column({ type: 'timestamptz', nullable: true })
hideAt?: Date;
/** when the user last viewed the memory */
@Column({ type: 'timestamptz', nullable: true })
seenAt?: Date;

View file

@ -14,6 +14,10 @@ export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadat
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
export type MemoriesState = {
/** memories have already been created through this date */
lastOnThisDayDate: string;
};
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
@ -23,4 +27,5 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
[SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial<SystemFlags>;
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
[SystemMetadataKey.MEMORIES_STATE]: MemoriesState;
}

View file

@ -187,6 +187,7 @@ export enum StorageFolder {
export enum SystemMetadataKey {
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
MEMORIES_STATE = 'memories-state',
ADMIN_ONBOARDING = 'admin-onboarding',
SYSTEM_CONFIG = 'system-config',
SYSTEM_FLAGS = 'system-flags',
@ -233,6 +234,8 @@ export enum ManualJobName {
PERSON_CLEANUP = 'person-cleanup',
TAG_CLEANUP = 'tag-cleanup',
USER_CLEANUP = 'user-cleanup',
MEMORY_CLEANUP = 'memory-cleanup',
MEMORY_CREATE = 'memory-create',
}
export enum AssetPathType {
@ -477,6 +480,10 @@ export enum JobName {
CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs',
CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens',
// memories
MEMORIES_CLEANUP = 'memories-cleanup',
MEMORIES_CREATE = 'memories-create',
// smart search
QUEUE_SMART_SEARCH = 'queue-smart-search',
SMART_SEARCH = 'smart-search',

View file

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddMemoryShowHideDates1739824470990 implements MigrationInterface {
name = 'AddMemoryShowHideDates1739824470990'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "memories" ADD "showAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "memories" ADD "hideAt" TIMESTAMP WITH TIME ZONE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "hideAt"`);
await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "showAt"`);
}
}

View file

@ -1,12 +1,68 @@
-- NOTE: This file is auto generated by ./sql-generator
-- MemoryRepository.cleanup
delete from "memories"
where
"createdAt" < $1
and "isSaved" = $2
-- MemoryRepository.search
select
*
"memories".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"assets".*
from
"assets"
inner join "memories_assets_assets" on "assets"."id" = "memories_assets_assets"."assetsId"
where
"memories_assets_assets"."memoriesId" = "memories"."id"
and "assets"."deletedAt" is null
) as agg
) as "assets"
from
"memories"
where
"ownerId" = $1
"deletedAt" is null
and "ownerId" = $1
order by
"memoryAt" desc
-- MemoryRepository.search (date filter)
select
"memories".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"assets".*
from
"assets"
inner join "memories_assets_assets" on "assets"."id" = "memories_assets_assets"."assetsId"
where
"memories_assets_assets"."memoriesId" = "memories"."id"
and "assets"."deletedAt" is null
) as agg
) as "assets"
from
"memories"
where
(
"showAt" is null
or "showAt" <= $1
)
and (
"hideAt" is null
or "hideAt" >= $2
)
and "deletedAt" is null
and "ownerId" = $3
order by
"memoryAt" desc

View file

@ -1,9 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely';
import { DB, Memories } from 'src/db';
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { MemorySearchDto } from 'src/dtos/memory.dto';
import { IBulkAsset } from 'src/types';
@Injectable()
@ -11,10 +13,40 @@ export class MemoryRepository implements IBulkAsset {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
search(ownerId: string) {
cleanup() {
return this.db
.deleteFrom('memories')
.where('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate())
.where('isSaved', '=', false)
.execute();
}
@GenerateSql(
{ params: [DummyValue.UUID, {}] },
{ name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] },
)
search(ownerId: string, dto: MemorySearchDto) {
return this.db
.selectFrom('memories')
.selectAll()
.selectAll('memories')
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('assets')
.selectAll('assets')
.innerJoin('memories_assets_assets', 'assets.id', 'memories_assets_assets.assetsId')
.whereRef('memories_assets_assets.memoriesId', '=', 'memories.id')
.where('assets.deletedAt', 'is', null),
).as('assets'),
)
.$if(dto.isSaved !== undefined, (qb) => qb.where('isSaved', '=', dto.isSaved!))
.$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!))
.$if(dto.for !== undefined, (qb) =>
qb
.where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', dto.for!)]))
.where((where) => where.or([where('hideAt', 'is', null), where('hideAt', '>=', dto.for!)])),
)
.where('deletedAt', dto.isTrashed ? 'is not' : 'is', null)
.where('ownerId', '=', ownerId)
.orderBy('memoryAt', 'desc')
.execute();

View file

@ -40,6 +40,8 @@ describe(JobService.name, () => {
{ name: JobName.ASSET_DELETION_CHECK },
{ name: JobName.USER_DELETE_CHECK },
{ name: JobName.PERSON_CLEANUP },
{ name: JobName.MEMORIES_CLEANUP },
{ name: JobName.MEMORIES_CREATE },
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
{ name: JobName.USER_SYNC_USAGE },

View file

@ -31,6 +31,14 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
return { name: JobName.USER_DELETE_CHECK };
}
case ManualJobName.MEMORY_CLEANUP: {
return { name: JobName.MEMORIES_CLEANUP };
}
case ManualJobName.MEMORY_CREATE: {
return { name: JobName.MEMORIES_CREATE };
}
default: {
throw new BadRequestException('Invalid job name');
}
@ -207,6 +215,8 @@ export class JobService extends BaseService {
{ name: JobName.ASSET_DELETION_CHECK },
{ name: JobName.USER_DELETE_CHECK },
{ name: JobName.PERSON_CLEANUP },
{ name: JobName.MEMORIES_CLEANUP },
{ name: JobName.MEMORIES_CREATE },
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
{ name: JobName.USER_SYNC_USAGE },

View file

@ -21,7 +21,7 @@ describe(MemoryService.name, () => {
describe('search', () => {
it('should search memories', async () => {
mocks.memory.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]);
await expect(sut.search(authStub.admin)).resolves.toEqual(
await expect(sut.search(authStub.admin, {})).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }),
expect.objectContaining({ id: 'memoryEmpty', assets: [] }),
@ -30,7 +30,7 @@ describe(MemoryService.name, () => {
});
it('should map ', async () => {
await expect(sut.search(authStub.admin)).resolves.toEqual([]);
await expect(sut.search(authStub.admin, {})).resolves.toEqual([]);
});
});

View file

@ -1,16 +1,84 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { JsonObject } from 'src/db';
import { OnJob } from 'src/decorators';
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 { Permission } from 'src/enum';
import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
import { OnThisDayData } from 'src/entities/memory.entity';
import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util';
const DAYS = 3;
@Injectable()
export class MemoryService extends BaseService {
async search(auth: AuthDto) {
const memories = await this.memoryRepository.search(auth.user.id);
@OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK })
async onMemoriesCreate() {
const users = await this.userRepository.getList({ withDeleted: false });
const userMap: Record<string, string[]> = {};
for (const user of users) {
const partnerIds = await getMyPartnerIds({
userId: user.id,
repository: this.partnerRepository,
timelineEnabled: true,
});
userMap[user.id] = [user.id, ...partnerIds];
}
const start = DateTime.utc().startOf('day').minus({ days: DAYS });
const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE);
let lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state?.lastOnThisDayDate) : start;
// generate a memory +/- X days from today
for (let i = 0; i <= DAYS * 2 + 1; i++) {
const target = start.plus({ days: i });
if (lastOnThisDayDate > target) {
continue;
}
const showAt = target.startOf('day').toISO();
const hideAt = target.endOf('day').toISO();
this.logger.log(`Creating memories for month=${target.month}, day=${target.day}`);
for (const [userId, userIds] of Object.entries(userMap)) {
const memories = await this.assetRepository.getByDayOfYear(userIds, target);
for (const memory of memories) {
const data: OnThisDayData = { year: target.year - memory.yearsAgo };
await this.memoryRepository.create(
{
ownerId: userId,
type: MemoryType.ON_THIS_DAY,
data,
memoryAt: target.minus({ years: memory.yearsAgo }).toISO(),
showAt,
hideAt,
},
new Set(memory.assets.map(({ id }) => id)),
);
}
}
await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, {
...state,
lastOnThisDayDate: target.toISO(),
});
lastOnThisDayDate = target;
}
}
@OnJob({ name: JobName.MEMORIES_CLEANUP, queue: QueueName.BACKGROUND_TASK })
async onMemoriesCleanup() {
await this.memoryRepository.cleanup();
}
async search(auth: AuthDto, dto: MemorySearchDto) {
const memories = await this.memoryRepository.search(auth.user.id, dto);
return memories.map((memory) => mapMemory(memory));
}

View file

@ -326,6 +326,10 @@ export type JobItem =
| { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob }
| { name: JobName.DUPLICATE_DETECTION; data: IEntityJob }
// Memories
| { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob }
| { name: JobName.MEMORIES_CREATE; data?: IBaseJob }
// Filesystem
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
@ -357,7 +361,11 @@ export type JobItem =
| { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob }
// Version check
| { name: JobName.VERSION_CHECK; data: IBaseJob };
| { name: JobName.VERSION_CHECK; data: IBaseJob }
// Memories
| { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob }
| { name: JobName.MEMORIES_CREATE; data?: IBaseJob };
export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS;