mirror of
https://github.com/immich-app/immich.git
synced 2026-09-30 13:23:21 +08:00
feat: birthday memories (#30831)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk';
|
||||
import { MemoryType, type MemoryResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline/rest-response.js';
|
||||
import type { MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js';
|
||||
@@ -25,7 +25,7 @@ export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]
|
||||
return {
|
||||
id: memoryId,
|
||||
assets: assets.map((asset) => toAssetResponseDto(asset)),
|
||||
data: { year: config.year } as OnThisDayDto,
|
||||
data: { year: config.year },
|
||||
memoryAt: config.memoryAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
"biometric_no_options": "No biometric options available",
|
||||
"biometric_not_available": "Biometric authentication is not available on this device",
|
||||
"birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
|
||||
"birthday_memory_title": "{name}'s birthday",
|
||||
"blurred_background": "Blurred background",
|
||||
"browse_templates": "Browse templates",
|
||||
"bugs_and_feature_requests": "Bugs & Feature Requests",
|
||||
|
||||
@@ -9,6 +9,7 @@ part 'memory.model.freezed.dart';
|
||||
enum MemoryTypeEnum {
|
||||
// do not change this order!
|
||||
onThisDay,
|
||||
birthday,
|
||||
}
|
||||
|
||||
@Freezed(fromJson: false, toJson: false)
|
||||
|
||||
@@ -24,6 +24,7 @@ class MemoryRepository extends DatabaseAccessor<Drift> with $MemoryRepositoryMix
|
||||
),
|
||||
])
|
||||
..where(_db.memoryEntity.ownerId.equals(ownerId))
|
||||
..where(_db.memoryEntity.type.equalsValue(MemoryTypeEnum.onThisDay))
|
||||
..where(_db.memoryEntity.deletedAt.isNull());
|
||||
|
||||
if (onlyFavorites) {
|
||||
|
||||
@@ -952,6 +952,7 @@ extension on AssetOrder {
|
||||
extension on MemoryType {
|
||||
MemoryTypeEnum toMemoryType() => switch (this) {
|
||||
MemoryType.onThisDay => MemoryTypeEnum.onThisDay,
|
||||
MemoryType.birthday => MemoryTypeEnum.birthday,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23056,7 +23056,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/OnThisDayDto"
|
||||
"$ref": "#/components/schemas/MemoryDataDto"
|
||||
},
|
||||
"hideAt": {
|
||||
"description": "Date when memory should be hidden",
|
||||
@@ -23123,6 +23123,30 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MemoryDataDto": {
|
||||
"properties": {
|
||||
"personId": {
|
||||
"description": "Person ID (birthday memories)",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
},
|
||||
"personName": {
|
||||
"description": "Name of the person when the memory was created (birthday memories)",
|
||||
"type": "string"
|
||||
},
|
||||
"year": {
|
||||
"description": "Year of the memory",
|
||||
"maximum": 9999,
|
||||
"minimum": 1000,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"year"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MemoryResponseDto": {
|
||||
"properties": {
|
||||
"assets": {
|
||||
@@ -23139,7 +23163,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/OnThisDayDto"
|
||||
"$ref": "#/components/schemas/MemoryDataDto"
|
||||
},
|
||||
"deletedAt": {
|
||||
"description": "Deletion date",
|
||||
@@ -23242,7 +23266,8 @@
|
||||
"MemoryType": {
|
||||
"description": "Memory type",
|
||||
"enum": [
|
||||
"on_this_day"
|
||||
"on_this_day",
|
||||
"birthday"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -24521,20 +24546,6 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"OnThisDayDto": {
|
||||
"properties": {
|
||||
"year": {
|
||||
"description": "Year for on this day memory",
|
||||
"maximum": 9999,
|
||||
"minimum": 1000,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"year"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"OnboardingDto": {
|
||||
"properties": {
|
||||
"isOnboarded": {
|
||||
|
||||
@@ -1842,15 +1842,19 @@ export type MapReverseGeocodeResponseDto = {
|
||||
/** State/Province name */
|
||||
state: string | null;
|
||||
};
|
||||
export type OnThisDayDto = {
|
||||
/** Year for on this day memory */
|
||||
export type MemoryDataDto = {
|
||||
/** Person ID (birthday memories) */
|
||||
personId?: string;
|
||||
/** Name of the person when the memory was created (birthday memories) */
|
||||
personName?: string;
|
||||
/** Year of the memory */
|
||||
year: number;
|
||||
};
|
||||
export type MemoryResponseDto = {
|
||||
assets: AssetResponseDto[];
|
||||
/** Creation date */
|
||||
createdAt: string;
|
||||
data: OnThisDayDto;
|
||||
data: MemoryDataDto;
|
||||
/** Deletion date */
|
||||
deletedAt?: string;
|
||||
/** Date when memory should be hidden */
|
||||
@@ -1874,7 +1878,7 @@ export type MemoryResponseDto = {
|
||||
export type MemoryCreateDto = {
|
||||
/** Asset IDs to associate with memory */
|
||||
assetIds?: string[];
|
||||
data: OnThisDayDto;
|
||||
data: MemoryDataDto;
|
||||
/** Date when memory should be hidden */
|
||||
hideAt?: string;
|
||||
/** Is memory saved */
|
||||
@@ -8218,7 +8222,8 @@ export enum MemorySearchOrder {
|
||||
Random = "random"
|
||||
}
|
||||
export enum MemoryType {
|
||||
OnThisDay = "on_this_day"
|
||||
OnThisDay = "on_this_day",
|
||||
Birthday = "birthday"
|
||||
}
|
||||
export enum PartnerDirection {
|
||||
SharedBy = "shared-by",
|
||||
|
||||
@@ -44,6 +44,36 @@ describe(MemoryController.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate data when type is birthday', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/memories')
|
||||
.send({
|
||||
type: 'birthday',
|
||||
data: { year: 1990 },
|
||||
memoryAt: new Date(2021).toISOString(),
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['data', 'personId'], message: 'Required for birthday memories' },
|
||||
{ path: ['data', 'personName'], message: 'Required for birthday memories' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept a birthday memory', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/memories')
|
||||
.send({
|
||||
type: 'birthday',
|
||||
data: { personId: factory.uuid(), personName: 'Alice', year: 1990 },
|
||||
memoryAt: new Date(2021).toISOString(),
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
|
||||
it('should accept showAt and hideAt', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/memories')
|
||||
|
||||
@@ -21,13 +21,15 @@ const MemorySearchSchema = z
|
||||
})
|
||||
.meta({ id: 'MemorySearchDto' });
|
||||
|
||||
const OnThisDaySchema = z
|
||||
const MemoryDataSchema = z
|
||||
.object({
|
||||
year: z.int().min(1000).max(9999).describe('Year for on this day memory'),
|
||||
year: z.int().min(1000).max(9999).describe('Year of the memory'),
|
||||
personId: z.uuidv4().optional().describe('Person ID (birthday memories)'),
|
||||
personName: z.string().optional().describe('Name of the person when the memory was created (birthday memories)'),
|
||||
})
|
||||
.meta({ id: 'OnThisDayDto' });
|
||||
.meta({ id: 'MemoryDataDto' });
|
||||
|
||||
type MemoryData = z.infer<typeof OnThisDaySchema>;
|
||||
type MemoryData = z.infer<typeof MemoryDataSchema>;
|
||||
|
||||
const MemoryUpdateSchema = nonEmptyPartial({
|
||||
isSaved: z.boolean().describe('Is memory saved'),
|
||||
@@ -35,10 +37,9 @@ const MemoryUpdateSchema = nonEmptyPartial({
|
||||
memoryAt: isoDatetimeToDate.describe('Memory date'),
|
||||
}).meta({ id: 'MemoryUpdateDto' });
|
||||
|
||||
const MemoryCreateSchema = z
|
||||
.object({
|
||||
const MemoryCreateBaseSchema = z.object({
|
||||
type: MemoryTypeSchema,
|
||||
data: OnThisDaySchema,
|
||||
data: MemoryDataSchema,
|
||||
memoryAt: isoDatetimeToDate.describe('Memory date'),
|
||||
assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs to associate with memory'),
|
||||
isSaved: z.boolean().optional().describe('Is memory saved'),
|
||||
@@ -51,8 +52,21 @@ const MemoryCreateSchema = z
|
||||
.optional()
|
||||
.describe('Date when memory should be hidden')
|
||||
.meta(new HistoryBuilder().added('v2.6.0').stable('v2.6.0').getExtensions()),
|
||||
})
|
||||
.meta({ id: 'MemoryCreateDto' });
|
||||
});
|
||||
|
||||
const MemoryCreateSchema = MemoryCreateBaseSchema.superRefine((dto, ctx) => {
|
||||
if (dto.type === MemoryType.Birthday) {
|
||||
for (const key of ['personId', 'personName'] as const) {
|
||||
if (dto.data[key] === undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['data', key],
|
||||
message: `Required for ${MemoryType.Birthday} memories`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}).meta({ id: 'MemoryCreateDto' });
|
||||
|
||||
const MemoryStatisticsResponseSchema = z
|
||||
.object({
|
||||
@@ -72,7 +86,7 @@ const MemoryResponseSchema = z
|
||||
hideAt: isoDatetimeToDate.optional().describe('Date when memory should be hidden'),
|
||||
ownerId: z.uuidv4().describe('Owner user ID'),
|
||||
type: MemoryTypeSchema,
|
||||
data: OnThisDaySchema,
|
||||
data: MemoryDataSchema,
|
||||
isSaved: z.boolean().describe('Is memory saved'),
|
||||
assets: z.array(AssetResponseSchema),
|
||||
})
|
||||
|
||||
@@ -89,6 +89,9 @@ export const AssetOrderBySchema = z.enum(AssetOrderBy).describe('Asset sorting p
|
||||
export enum MemoryType {
|
||||
/** pictures taken on this day X years ago */
|
||||
OnThisDay = 'on_this_day',
|
||||
|
||||
/** pictures of a person, shown leading up to their birthday */
|
||||
Birthday = 'birthday',
|
||||
}
|
||||
|
||||
export const MemoryTypeSchema = z.enum(MemoryType).describe('Memory type').meta({ id: 'MemoryType' });
|
||||
|
||||
@@ -203,6 +203,82 @@ order by
|
||||
"showAt" desc nulls last,
|
||||
"memoryAt" desc
|
||||
|
||||
-- MemoryRepository.getPersonBirthdayYears
|
||||
select distinct
|
||||
date_part(
|
||||
'year',
|
||||
(asset."localDateTime" at time zone 'UTC')::date
|
||||
)::int as "year"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = $1
|
||||
and "asset"."visibility" = $2
|
||||
and "asset"."deletedAt" is null
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset_face"
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."personGroupId" = $3
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
)
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $4
|
||||
)
|
||||
and date_part(
|
||||
'month',
|
||||
(asset."localDateTime" at time zone 'UTC')::date
|
||||
)::int = $5
|
||||
and date_part(
|
||||
'day',
|
||||
(asset."localDateTime" at time zone 'UTC')::date
|
||||
)::int = $6
|
||||
and (asset."localDateTime" at time zone 'UTC')::date >= make_date($7::int, $8::int, $9::int)
|
||||
and (asset."localDateTime" at time zone 'UTC')::date < make_date($10::int, $11::int, $12::int)
|
||||
order by
|
||||
year desc
|
||||
|
||||
-- MemoryRepository.getPersonAssetsByDate
|
||||
select
|
||||
"asset"."id"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = $1
|
||||
and "asset"."visibility" = $2
|
||||
and "asset"."deletedAt" is null
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset_face"
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."personGroupId" = $3
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
)
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $4
|
||||
)
|
||||
and (asset."localDateTime" at time zone 'UTC')::date = make_date($5::int, $6::int, $7::int)
|
||||
order by
|
||||
"asset"."localDateTime" desc
|
||||
limit
|
||||
$8
|
||||
|
||||
-- MemoryRepository.get
|
||||
select
|
||||
"memory".*,
|
||||
|
||||
@@ -75,6 +75,52 @@ where
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
|
||||
-- PersonRepository.forBirthdayMemories
|
||||
select
|
||||
"person"."personGroupId",
|
||||
"person"."name",
|
||||
date_part('year', person."birthDate")::int as "birthYear",
|
||||
date_part('month', person."birthDate")::int as "birthMonth",
|
||||
date_part('day', person."birthDate")::int as "birthDay"
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"person"."ownerId" = $1
|
||||
and "person"."isHidden" = $2
|
||||
and "person"."name" != $3
|
||||
and "person"."birthDate" is not null
|
||||
and (
|
||||
date_part('month', person."birthDate")::int = $4
|
||||
and date_part('day', person."birthDate")::int = $5
|
||||
)
|
||||
and date_part('year', person."birthDate")::int < $6
|
||||
|
||||
-- PersonRepository.forBirthdayMemories (leap day fallback)
|
||||
select
|
||||
"person"."personGroupId",
|
||||
"person"."name",
|
||||
date_part('year', person."birthDate")::int as "birthYear",
|
||||
date_part('month', person."birthDate")::int as "birthMonth",
|
||||
date_part('day', person."birthDate")::int as "birthDay"
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"person"."ownerId" = $1
|
||||
and "person"."isHidden" = $2
|
||||
and "person"."name" != $3
|
||||
and "person"."birthDate" is not null
|
||||
and (
|
||||
(
|
||||
date_part('month', person."birthDate")::int = $4
|
||||
and date_part('day', person."birthDate")::int = $5
|
||||
)
|
||||
or (
|
||||
date_part('month', person."birthDate")::int = $6
|
||||
and date_part('day', person."birthDate")::int = $7
|
||||
)
|
||||
)
|
||||
and date_part('year', person."birthDate")::int < $8
|
||||
|
||||
-- PersonRepository.getFileSamples
|
||||
select
|
||||
"ownerId",
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type Insertable, type Kysely, type OrderByDirection, type Updateable, sql } from 'kysely';
|
||||
import {
|
||||
type ExpressionBuilder,
|
||||
type Insertable,
|
||||
type Kysely,
|
||||
type OrderByDirection,
|
||||
type Updateable,
|
||||
sql,
|
||||
} from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { DateTime } from 'luxon';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import type { IBulkAsset } from 'src/types.js';
|
||||
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators.js';
|
||||
import { MemorySearchDto } from 'src/dtos/memory.dto.js';
|
||||
import { AssetOrderWithRandom, AssetVisibility } from 'src/enum.js';
|
||||
import { AssetFileType, AssetOrderWithRandom, AssetVisibility } from 'src/enum.js';
|
||||
import { type YearMonthDay } from 'src/repositories/asset.repository.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table.js';
|
||||
|
||||
const asMakeDate = (eb: ExpressionBuilder<DB, 'asset'>, { year, month, day }: YearMonthDay) =>
|
||||
eb.fn('make_date', [sql`${year}::int`, sql`${month}::int`, sql`${day}::int`]);
|
||||
|
||||
@Injectable()
|
||||
export class MemoryRepository implements IBulkAsset {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
@@ -112,6 +123,64 @@ export class MemoryRepository implements IBulkAsset {
|
||||
.execute();
|
||||
}
|
||||
|
||||
private personAssets(ownerId: string, personGroupId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.ownerId', '=', ownerId)
|
||||
.where('asset.visibility', '=', AssetVisibility.Timeline)
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.where((eb) =>
|
||||
eb.exists((qb) =>
|
||||
qb
|
||||
.selectFrom('asset_face')
|
||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||
.where('asset_face.personGroupId', '=', personGroupId)
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.where('asset_face.isVisible', 'is', true),
|
||||
),
|
||||
)
|
||||
.where((eb) =>
|
||||
eb.exists((qb) =>
|
||||
qb
|
||||
.selectFrom('asset_file')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', AssetFileType.Preview),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID, DummyValue.UUID, { year: 2000, month: 1, day: 1 }, { year: 2025, month: 1, day: 1 }],
|
||||
})
|
||||
async getPersonBirthdayYears(
|
||||
ownerId: string,
|
||||
personGroupId: string,
|
||||
birthDate: YearMonthDay,
|
||||
until: YearMonthDay,
|
||||
): Promise<number[]> {
|
||||
const rows = await this.personAssets(ownerId, personGroupId)
|
||||
.where(sql`date_part('month', (asset."localDateTime" at time zone 'UTC')::date)::int`, '=', birthDate.month)
|
||||
.where(sql`date_part('day', (asset."localDateTime" at time zone 'UTC')::date)::int`, '=', birthDate.day)
|
||||
.where((eb) => eb(sql`(asset."localDateTime" at time zone 'UTC')::date`, '>=', asMakeDate(eb, birthDate)))
|
||||
.where((eb) => eb(sql`(asset."localDateTime" at time zone 'UTC')::date`, '<', asMakeDate(eb, until)))
|
||||
.select(sql<number>`date_part('year', (asset."localDateTime" at time zone 'UTC')::date)::int`.as('year'))
|
||||
.distinct()
|
||||
.orderBy(sql`year`, 'desc')
|
||||
.execute();
|
||||
|
||||
return rows.map(({ year }) => year);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, { year: 2000, month: 1, day: 1 }, 5] })
|
||||
getPersonAssetsByDate(ownerId: string, personGroupId: string, date: YearMonthDay, limit: number) {
|
||||
return this.personAssets(ownerId, personGroupId)
|
||||
.select(['asset.id'])
|
||||
.where((eb) => eb(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', asMakeDate(eb, date)))
|
||||
.orderBy('asset.localDateTime', 'desc')
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
get(id: string) {
|
||||
return this.getByIdBuilder(id).executeTakeFirst();
|
||||
|
||||
@@ -5,12 +5,14 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AssetFace } from 'src/database.js';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators.js';
|
||||
import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum.js';
|
||||
import { type YearMonthDay } from 'src/repositories/asset.repository.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
|
||||
import { PersonGroupTable } from 'src/schema/tables/person-group.table.js';
|
||||
import { PersonTable } from 'src/schema/tables/person.table.js';
|
||||
import { asUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database.js';
|
||||
import { isLeapDayObserved } from 'src/utils/date.js';
|
||||
import { type PaginationOptions, paginationHelper } from 'src/utils/pagination.js';
|
||||
|
||||
export interface PersonSearchOptions {
|
||||
@@ -219,6 +221,42 @@ export class PersonRepository {
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql(
|
||||
{ params: [DummyValue.UUID, { year: 2025, month: 1, day: 1 }] },
|
||||
{ name: 'leap day fallback', params: [DummyValue.UUID, { year: 2025, month: 2, day: 28 }] },
|
||||
)
|
||||
async forBirthdayMemories(ownerId: string, { year, month, day }: YearMonthDay) {
|
||||
const isLeapDayBirthday = isLeapDayObserved({ year, month, day });
|
||||
|
||||
const people = await this.db
|
||||
.selectFrom('person')
|
||||
.select(['person.personGroupId', 'person.name'])
|
||||
.select(sql<number>`date_part('year', person."birthDate")::int`.as('birthYear'))
|
||||
.select(sql<number>`date_part('month', person."birthDate")::int`.as('birthMonth'))
|
||||
.select(sql<number>`date_part('day', person."birthDate")::int`.as('birthDay'))
|
||||
.where('person.ownerId', '=', ownerId)
|
||||
.where('person.isHidden', '=', false)
|
||||
.where('person.name', '!=', '')
|
||||
.where('person.birthDate', 'is not', null)
|
||||
.where((eb) => {
|
||||
const bornOn = (month: number, day: number) =>
|
||||
eb.and([
|
||||
eb(sql`date_part('month', person."birthDate")::int`, '=', month),
|
||||
eb(sql`date_part('day', person."birthDate")::int`, '=', day),
|
||||
]);
|
||||
|
||||
return isLeapDayBirthday ? eb.or([bornOn(month, day), bornOn(2, 29)]) : bornOn(month, day);
|
||||
})
|
||||
.where(sql`date_part('year', person."birthDate")::int`, '<', year)
|
||||
.execute();
|
||||
|
||||
return people.map(({ personGroupId, name, birthYear, birthMonth, birthDay }) => ({
|
||||
personGroupId,
|
||||
name,
|
||||
birthDate: { year: birthYear, month: birthMonth, day: birthDay },
|
||||
}));
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
getFileSamples() {
|
||||
return this.db
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { OnThisDayData } from 'src/types.js';
|
||||
import { MemoryType } from 'src/enum.js';
|
||||
import { MemoryService } from 'src/services/memory.service.js';
|
||||
import { OnThisDayData } from 'src/types.js';
|
||||
import { AssetFactory } from 'test/factories/asset.factory.js';
|
||||
import { MemoryFactory } from 'test/factories/memory.factory.js';
|
||||
import { getForMemory } from 'test/mappers.js';
|
||||
@@ -94,7 +95,7 @@ describe(MemoryService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth({ user: { id: userId } }), {
|
||||
type: memory.type,
|
||||
type: MemoryType.OnThisDay,
|
||||
data: memory.data as OnThisDayData,
|
||||
memoryAt: memory.memoryAt,
|
||||
isSaved: memory.isSaved,
|
||||
@@ -124,7 +125,7 @@ describe(MemoryService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth({ user: { id: userId } }), {
|
||||
type: memory.type,
|
||||
type: MemoryType.OnThisDay,
|
||||
data: memory.data as OnThisDayData,
|
||||
assetIds: memory.assets.map((asset) => asset.id),
|
||||
memoryAt: memory.memoryAt,
|
||||
@@ -144,7 +145,7 @@ describe(MemoryService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth(), {
|
||||
type: memory.type,
|
||||
type: MemoryType.OnThisDay,
|
||||
data: memory.data as OnThisDayData,
|
||||
memoryAt: memory.memoryAt,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { shuffle } from 'lodash-es';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Memory } from 'src/database.js';
|
||||
import { OnJob } from 'src/decorators.js';
|
||||
@@ -12,11 +13,15 @@ import {
|
||||
mapMemory,
|
||||
} from 'src/dtos/memory.dto.js';
|
||||
import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum.js';
|
||||
import { type YearMonthDay } from 'src/repositories/asset.repository.js';
|
||||
import { BaseService } from 'src/services/base.service.js';
|
||||
import { addAssets, removeAssets } from 'src/utils/asset.util.js';
|
||||
import { findOrFail } from 'src/utils/misc.js';
|
||||
|
||||
const DAYS = 3;
|
||||
const DAYS_UNTIL_BIRTHDAY = 3;
|
||||
const BIRTHDAY_MEMORY_ASSETS_PER_YEAR = 5;
|
||||
const MEMORY_ASSET_LIMIT = 25;
|
||||
|
||||
@Injectable()
|
||||
export class MemoryService extends BaseService {
|
||||
@@ -38,7 +43,14 @@ export class MemoryService extends BaseService {
|
||||
|
||||
this.logger.log(`Creating memories for ${target.toISO()}`);
|
||||
try {
|
||||
await Promise.all(users.map((owner) => this.createOnThisDayMemories(owner.id, target)));
|
||||
await Promise.all(
|
||||
users.map((owner) =>
|
||||
Promise.all([
|
||||
this.createOnThisDayMemories(owner.id, target),
|
||||
this.createBirthdayMemories(owner.id, target),
|
||||
]),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`);
|
||||
}
|
||||
@@ -72,6 +84,68 @@ export class MemoryService extends BaseService {
|
||||
);
|
||||
}
|
||||
|
||||
private async createBirthdayMemories(ownerId: string, target: DateTime) {
|
||||
const people = await this.personRepository.forBirthdayMemories(ownerId, target);
|
||||
if (people.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const showAt = target.minus({ days: DAYS_UNTIL_BIRTHDAY }).startOf('day').toISO();
|
||||
const hideAt = target.endOf('day').toISO();
|
||||
|
||||
await Promise.all(
|
||||
people.map(async ({ personGroupId, name: personName, birthDate }) => {
|
||||
const assets = await this.getBirthdayAssets(ownerId, personGroupId, birthDate, target);
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.memoryRepository.create(
|
||||
{
|
||||
ownerId,
|
||||
type: MemoryType.Birthday,
|
||||
data: { personId: personGroupId, personName, year: birthDate.year },
|
||||
memoryAt: target.startOf('day').toISO()!,
|
||||
showAt,
|
||||
hideAt,
|
||||
},
|
||||
new Set(assets.map(({ id }) => id)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async getBirthdayAssets(
|
||||
ownerId: string,
|
||||
personGroupId: string,
|
||||
birthDate: YearMonthDay,
|
||||
until: YearMonthDay,
|
||||
) {
|
||||
const years = await this.memoryRepository.getPersonBirthdayYears(ownerId, personGroupId, birthDate, until);
|
||||
if (years.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let birthdayYears = years;
|
||||
let assetsPerYear = Math.min(BIRTHDAY_MEMORY_ASSETS_PER_YEAR, Math.floor(MEMORY_ASSET_LIMIT / years.length));
|
||||
|
||||
// Select random birthdays if there are more than 25 birthdays with assets
|
||||
if (years.length > MEMORY_ASSET_LIMIT) {
|
||||
birthdayYears = shuffle(years)
|
||||
.slice(0, MEMORY_ASSET_LIMIT)
|
||||
.sort((a, b) => b - a);
|
||||
assetsPerYear = 1;
|
||||
}
|
||||
|
||||
const assets = await Promise.all(
|
||||
birthdayYears.map((year) =>
|
||||
this.memoryRepository.getPersonAssetsByDate(ownerId, personGroupId, { ...birthDate, year }, assetsPerYear),
|
||||
),
|
||||
);
|
||||
|
||||
return assets.flat();
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.MemoryCleanup, queue: QueueName.BackgroundTask })
|
||||
async onMemoriesCleanup() {
|
||||
await this.memoryRepository.cleanup();
|
||||
@@ -95,8 +169,6 @@ export class MemoryService extends BaseService {
|
||||
}
|
||||
|
||||
async create(auth: AuthDto, dto: MemoryCreateDto) {
|
||||
// TODO validate type/data combination
|
||||
|
||||
const assetIds = dto.assetIds || [];
|
||||
const allowedAssetIds = await this.checkAccess({
|
||||
auth,
|
||||
|
||||
@@ -521,9 +521,11 @@ export type StorageAsset = {
|
||||
};
|
||||
|
||||
export type OnThisDayData = { year: number };
|
||||
export type BirthdayData = { personId: string; personName: string; year: number };
|
||||
|
||||
export interface MemoryData {
|
||||
[MemoryType.OnThisDay]: OnThisDayData;
|
||||
[MemoryType.Birthday]: BirthdayData;
|
||||
}
|
||||
|
||||
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { asDateString, asDateTimeString } from 'src/utils/date.js';
|
||||
import { asDateString, asDateTimeString, isLeapDayObserved } from 'src/utils/date.js';
|
||||
|
||||
describe('asDateString', () => {
|
||||
it('should return null for null input', () => {
|
||||
@@ -20,6 +20,27 @@ describe('asDateString', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLeapDayObserved', () => {
|
||||
it('should return true on february 28th in a non-leap year', () => {
|
||||
expect(isLeapDayObserved({ year: 2025, month: 2, day: 28 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false on february 28th in a leap year', () => {
|
||||
expect(isLeapDayObserved({ year: 2024, month: 2, day: 28 })).toBe(false);
|
||||
expect(isLeapDayObserved({ year: 2000, month: 2, day: 28 })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true on february 28th in a century that is not a leap year', () => {
|
||||
expect(isLeapDayObserved({ year: 1900, month: 2, day: 28 })).toBe(true);
|
||||
expect(isLeapDayObserved({ year: 2100, month: 2, day: 28 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false on other days', () => {
|
||||
expect(isLeapDayObserved({ year: 2025, month: 2, day: 27 })).toBe(false);
|
||||
expect(isLeapDayObserved({ year: 2025, month: 3, day: 28 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('asDateTimeString', () => {
|
||||
it('should return null for null input', () => {
|
||||
expect(asDateTimeString(null)).toBeNull();
|
||||
|
||||
@@ -15,6 +15,13 @@ export const asDateString = (x: Date | string | null): string | null => {
|
||||
return x instanceof Date ? isoDateToDate.encode(x) : x;
|
||||
};
|
||||
|
||||
/**
|
||||
* People born on February 29th are celebrated on February 28th in non-leap years.
|
||||
*/
|
||||
export const isLeapDayObserved = ({ year, month, day }: { year: number; month: number; day: number }) => {
|
||||
return month === 2 && day === 28 && !DateTime.local(year).isInLeapYear;
|
||||
};
|
||||
|
||||
export const extractTimeZone = (dateTimeOriginal?: string | null) => {
|
||||
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
|
||||
return extractedTimeZone?.type === 'fixed' ? extractedTimeZone : undefined;
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetFileType, AssetVisibility } from 'src/enum.js';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository.js';
|
||||
import { MemoryRepository } from 'src/repositories/memory.repository.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
import { BaseService } from 'src/services/base.service.js';
|
||||
import { newMediumService } from 'test/medium.factory.js';
|
||||
import { getKyselyDB } from 'test/utils.js';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
return { ctx, sut: ctx.get(MemoryRepository) };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
const newPersonAsset = async (
|
||||
ctx: ReturnType<typeof setup>['ctx'],
|
||||
{ ownerId, personGroupId, localDateTime }: { ownerId: string; personGroupId: string; localDateTime: string },
|
||||
) => {
|
||||
const { asset } = await ctx.newAsset({ ownerId, localDateTime });
|
||||
await Promise.all([
|
||||
ctx.newAssetFace({ assetId: asset.id, personGroupId }),
|
||||
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: `/preview/${asset.id}.jpg` }),
|
||||
]);
|
||||
return asset;
|
||||
};
|
||||
|
||||
const newBirthdayPerson = async (ctx: ReturnType<typeof setup>['ctx']) => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
return { user, person };
|
||||
};
|
||||
|
||||
describe(MemoryRepository.name, () => {
|
||||
const birthDate = { year: 1990, month: 6, day: 13 };
|
||||
const until = { year: 2025, month: 6, day: 13 };
|
||||
|
||||
describe('getPersonBirthdayYears', () => {
|
||||
it('should return the distinct years with assets on the birthday, newest first', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
const args = { ownerId: user.id, personGroupId: person.personGroupId };
|
||||
await newPersonAsset(ctx, { ...args, localDateTime: '2020-06-13T10:00:00.000Z' });
|
||||
await newPersonAsset(ctx, { ...args, localDateTime: '2022-06-13T08:00:00.000Z' });
|
||||
await newPersonAsset(ctx, { ...args, localDateTime: '2022-06-13T09:00:00.000Z' });
|
||||
await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T12:00:00.000Z' });
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([2024, 2022, 2020]);
|
||||
});
|
||||
|
||||
it('should ignore assets taken on other days', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: '2024-06-14T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore assets of other people', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
const { person: otherPerson } = await ctx.newPerson({ ownerId: user.id, name: 'Bob' });
|
||||
await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: otherPerson.personGroupId,
|
||||
localDateTime: '2024-06-13T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore assets without a preview file', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime: '2024-06-13T12:00:00.000Z' });
|
||||
await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore trashed assets', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
const asset = await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: '2024-06-13T12:00:00.000Z',
|
||||
});
|
||||
await ctx.softDeleteAsset(asset.id);
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore assets that are not timeline-visible', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
localDateTime: '2024-06-13T12:00:00.000Z',
|
||||
visibility: AssetVisibility.Archive,
|
||||
});
|
||||
await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
|
||||
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: `/preview/${asset.id}.jpg` });
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore assets with a soft-deleted face', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime: '2024-06-13T12:00:00.000Z' });
|
||||
await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId, deletedAt: new Date() });
|
||||
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: `/preview/${asset.id}.jpg` });
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore assets with an invisible face', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime: '2024-06-13T12:00:00.000Z' });
|
||||
await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId, isVisible: false });
|
||||
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: `/preview/${asset.id}.jpg` });
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore assets taken before the person was born', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: '1985-06-13T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include assets taken on the day of birth', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: '1990-06-13T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([1990]);
|
||||
});
|
||||
|
||||
it('should ignore assets taken on or after the until date', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
const args = { ownerId: user.id, personGroupId: person.personGroupId };
|
||||
await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T12:00:00.000Z' });
|
||||
await newPersonAsset(ctx, { ...args, localDateTime: '2025-06-13T12:00:00.000Z' });
|
||||
|
||||
const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until);
|
||||
|
||||
expect(years).toEqual([2024]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPersonAssetsByDate', () => {
|
||||
it('should return the newest assets of the given date, newest first, up to the limit', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
const args = { ownerId: user.id, personGroupId: person.personGroupId };
|
||||
await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T01:00:00.000Z' });
|
||||
const nextNewest2024 = await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T02:00:00.000Z' });
|
||||
const newest2024 = await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T03:00:00.000Z' });
|
||||
await newPersonAsset(ctx, { ...args, localDateTime: '2023-06-13T01:00:00.000Z' });
|
||||
|
||||
const assets = await sut.getPersonAssetsByDate(
|
||||
user.id,
|
||||
person.personGroupId,
|
||||
{ year: 2024, month: 6, day: 13 },
|
||||
2,
|
||||
);
|
||||
|
||||
expect(assets.map(({ id }) => id)).toEqual([newest2024.id, nextNewest2024.id]);
|
||||
});
|
||||
|
||||
it('should return the single newest asset when the limit is 1', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user, person } = await newBirthdayPerson(ctx);
|
||||
const args = { ownerId: user.id, personGroupId: person.personGroupId };
|
||||
await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T01:00:00.000Z' });
|
||||
const newest2024 = await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T02:00:00.000Z' });
|
||||
|
||||
const assets = await sut.getPersonAssetsByDate(
|
||||
user.id,
|
||||
person.personGroupId,
|
||||
{ year: 2024, month: 6, day: 13 },
|
||||
1,
|
||||
);
|
||||
|
||||
expect(assets.map(({ id }) => id)).toEqual([newest2024.id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -229,6 +229,96 @@ describe(PersonRepository.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('forBirthdayMemories', () => {
|
||||
const target = { year: 2025, month: 6, day: 13 };
|
||||
|
||||
it('should return people with a birthday on the given day', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
await ctx.newPerson({ ownerId: user.id, name: 'Bob', birthDate: '1990-06-14' });
|
||||
|
||||
const people = await sut.forBirthdayMemories(user.id, target);
|
||||
|
||||
expect(people).toEqual([
|
||||
{ personGroupId: person.personGroupId, name: 'Alice', birthDate: { year: 1990, month: 6, day: 13 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not return hidden people, unnamed people, or people without a birth date', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13', isHidden: true });
|
||||
await ctx.newPerson({ ownerId: user.id, name: '', birthDate: '1990-06-13' });
|
||||
await ctx.newPerson({ ownerId: user.id, name: 'Carol', birthDate: null });
|
||||
|
||||
const people = await sut.forBirthdayMemories(user.id, target);
|
||||
|
||||
expect(people).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not return people belonging to another user', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: otherUser } = await ctx.newUser();
|
||||
await ctx.newPerson({ ownerId: otherUser.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
|
||||
const people = await sut.forBirthdayMemories(user.id, target);
|
||||
|
||||
expect(people).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not return people born in the target year', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '2025-06-13' });
|
||||
|
||||
const people = await sut.forBirthdayMemories(user.id, target);
|
||||
|
||||
expect(people).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include leap-day birthdays on february 28th of non-leap years', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1992-02-29' });
|
||||
|
||||
const people = await sut.forBirthdayMemories(user.id, { year: 2025, month: 2, day: 28 });
|
||||
|
||||
expect(people.map(({ personGroupId }) => personGroupId)).toEqual([person.personGroupId]);
|
||||
});
|
||||
|
||||
it('should include people born on february 28th on february 28th of non-leap years', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1991-02-28' });
|
||||
|
||||
const people = await sut.forBirthdayMemories(user.id, { year: 2025, month: 2, day: 28 });
|
||||
|
||||
expect(people.map(({ personGroupId }) => personGroupId)).toEqual([person.personGroupId]);
|
||||
});
|
||||
|
||||
it('should not include leap-day birthdays on february 28th of leap years', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1992-02-29' });
|
||||
|
||||
const people = await sut.forBirthdayMemories(user.id, { year: 2024, month: 2, day: 28 });
|
||||
|
||||
expect(people).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include leap-day birthdays on february 29th of leap years', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1992-02-29' });
|
||||
|
||||
const people = await sut.forBirthdayMemories(user.id, { year: 2024, month: 2, day: 29 });
|
||||
|
||||
expect(people.map(({ personGroupId }) => personGroupId)).toEqual([person.personGroupId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getForFeatureFaceUpdate', () => {
|
||||
it('should ignore soft deleted faces', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository.js';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository.js';
|
||||
import { MemoryRepository } from 'src/repositories/memory.repository.js';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository.js';
|
||||
import { PersonRepository } from 'src/repositories/person.repository.js';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository.js';
|
||||
import { UserRepository } from 'src/repositories/user.repository.js';
|
||||
import { DB } from 'src/schema/index.js';
|
||||
@@ -30,6 +31,7 @@ const setup = (db?: Kysely<DB>) => {
|
||||
SystemMetadataRepository,
|
||||
UserRepository,
|
||||
PartnerRepository,
|
||||
PersonRepository,
|
||||
],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
@@ -43,6 +45,24 @@ const create = async (ctx: ReturnType<typeof setup>['ctx']) => {
|
||||
return { memory, asset, user };
|
||||
};
|
||||
|
||||
const newPersonAsset = async (
|
||||
ctx: ReturnType<typeof setup>['ctx'],
|
||||
{ ownerId, personGroupId, localDateTime }: { ownerId: string; personGroupId: string; localDateTime?: string },
|
||||
) => {
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
const { asset } = await ctx.newAsset({ ownerId, localDateTime: localDateTime ?? '2024-06-13T12:00:00.000Z' });
|
||||
await Promise.all([
|
||||
ctx.newExif({ assetId: asset.id, make: 'Canon' }),
|
||||
ctx.newJobStatus({ assetId: asset.id }),
|
||||
ctx.newAssetFace({ assetId: asset.id, personGroupId }),
|
||||
assetRepo.upsertFiles([
|
||||
{ assetId: asset.id, type: AssetFileType.Preview, path: '/path/to/preview.jpg' },
|
||||
{ assetId: asset.id, type: AssetFileType.Thumbnail, path: '/path/to/thumbnail.jpg' },
|
||||
]),
|
||||
]);
|
||||
return asset;
|
||||
};
|
||||
|
||||
describe(MemoryService.name, () => {
|
||||
describe('get', () => {
|
||||
it('should return the memory', async () => {
|
||||
@@ -183,13 +203,17 @@ describe(MemoryService.name, () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await defaultDatabase.destroy();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new memory', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const dto = {
|
||||
type: MemoryType.OnThisDay,
|
||||
type: MemoryType.OnThisDay as const,
|
||||
data: { year: 2021 },
|
||||
memoryAt: new Date(2021),
|
||||
};
|
||||
@@ -214,7 +238,7 @@ describe(MemoryService.name, () => {
|
||||
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
|
||||
const auth = factory.auth({ user });
|
||||
const dto = {
|
||||
type: MemoryType.OnThisDay,
|
||||
type: MemoryType.OnThisDay as const,
|
||||
data: { year: 2021 },
|
||||
memoryAt: new Date(2021),
|
||||
assetIds: [asset1.id, asset2.id],
|
||||
@@ -228,6 +252,26 @@ describe(MemoryService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new birthday memory', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice' });
|
||||
const auth = factory.auth({ user });
|
||||
const dto = {
|
||||
type: MemoryType.Birthday as const,
|
||||
data: { personId: person.personGroupId, personName: 'Alice', year: 1990 },
|
||||
memoryAt: new Date(2025, 5, 13),
|
||||
};
|
||||
|
||||
await expect(sut.create(auth, dto)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
type: MemoryType.Birthday,
|
||||
data: dto.data,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new memory and ignore assets the user does not have access to', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user: user1 } = await ctx.newUser();
|
||||
@@ -236,7 +280,7 @@ describe(MemoryService.name, () => {
|
||||
const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id });
|
||||
const auth = factory.auth({ user: user1 });
|
||||
const dto = {
|
||||
type: MemoryType.OnThisDay,
|
||||
type: MemoryType.OnThisDay as const,
|
||||
data: { year: 2021 },
|
||||
memoryAt: new Date(2021),
|
||||
assetIds: [asset1.id, asset2.id],
|
||||
@@ -418,6 +462,285 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMemoryCreate (birthday)', () => {
|
||||
it('should create a memory for the 3 days leading up to a birthday', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const birthday = DateTime.fromObject({ year: 2025, month: 6, day: 13 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
const asset = await newPersonAsset(ctx, { ownerId: user.id, personGroupId: person.personGroupId });
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(1);
|
||||
expect(memories[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
ownerId: user.id,
|
||||
type: 'birthday',
|
||||
data: { personId: person.personGroupId, personName: 'Alice', year: 1990 },
|
||||
memoryAt: birthday.startOf('day').toJSDate(),
|
||||
showAt: birthday.minus({ days: 3 }).startOf('day').toJSDate(),
|
||||
hideAt: birthday.endOf('day').toJSDate(),
|
||||
assets: [expect.objectContaining({ id: asset.id })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a memory on the birthday itself', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 13 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
await newPersonAsset(ctx, { ownerId: user.id, personGroupId: person.personGroupId });
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(1);
|
||||
expect(memories[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
showAt: now.minus({ days: 3 }).startOf('day').toJSDate(),
|
||||
hideAt: now.endOf('day').toJSDate(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not create a birthday memory twice for the same birthday', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
await newPersonAsset(ctx, { ownerId: user.id, personGroupId: person.personGroupId });
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not create a birthday memory for a hidden person', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({
|
||||
ownerId: user.id,
|
||||
name: 'Alice',
|
||||
birthDate: '1990-06-13',
|
||||
isHidden: true,
|
||||
});
|
||||
await newPersonAsset(ctx, { ownerId: user.id, personGroupId: person.personGroupId });
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not create a birthday memory for an unnamed person', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: '', birthDate: '1990-06-13' });
|
||||
await newPersonAsset(ctx, { ownerId: user.id, personGroupId: person.personGroupId });
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should only include assets taken on the birthday itself', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
const onBirthday = await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: '2024-06-13T12:00:00.000Z',
|
||||
});
|
||||
await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: '2024-06-12T12:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(1);
|
||||
expect(memories[0].assets.map(({ id }) => id)).toEqual([onBirthday.id]);
|
||||
});
|
||||
|
||||
it('should not create a birthday memory for a person without any assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should include at most 5 assets from a single year, newest first', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
|
||||
const assetIds: string[] = [];
|
||||
for (let hour = 1; hour <= 7; hour++) {
|
||||
const asset = await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: `2024-06-13T${hour.toString().padStart(2, '0')}:00:00.000Z`,
|
||||
});
|
||||
assetIds.push(asset.id);
|
||||
}
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(1);
|
||||
const memoryAssetIds = memories[0].assets.map(({ id }) => id).sort();
|
||||
expect(memoryAssetIds).toEqual(assetIds.slice(-5).sort());
|
||||
});
|
||||
|
||||
it('should split the asset budget evenly across years, keeping the newest assets of each year', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
|
||||
const assetIdsByYear: Record<number, string[]> = {};
|
||||
for (let year = 2019; year <= 2024; year++) {
|
||||
assetIdsByYear[year] = [];
|
||||
for (let hour = 1; hour <= 5; hour++) {
|
||||
const asset = await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: `${year}-06-13T${hour.toString().padStart(2, '0')}:00:00.000Z`,
|
||||
});
|
||||
assetIdsByYear[year].push(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(1);
|
||||
// 6 birthdays share the budget of 25 assets, so each birthday includes its 4 newest assets
|
||||
expect(memories[0].assets.length).toBe(24);
|
||||
const memoryAssetIds = new Set(memories[0].assets.map(({ id }) => id));
|
||||
for (let year = 2019; year <= 2024; year++) {
|
||||
const [oldest, ...newest] = assetIdsByYear[year];
|
||||
for (const assetId of newest) {
|
||||
expect(memoryAssetIds.has(assetId)).toBe(true);
|
||||
}
|
||||
expect(memoryAssetIds.has(oldest)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include one asset from each of 25 random birthdays when there are more than 25', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
|
||||
|
||||
for (let year = 1997; year <= 2024; year++) {
|
||||
await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: `${year}-06-13T12:00:00.000Z`,
|
||||
});
|
||||
await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: `${year}-06-13T08:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(1);
|
||||
// 28 birthdays exist, so 25 of them are sampled with one asset each
|
||||
expect(memories[0].assets.length).toBe(25);
|
||||
const years = new Set(memories[0].assets.map(({ localDateTime }) => new Date(localDateTime).getUTCFullYear()));
|
||||
expect(years.size).toBe(25);
|
||||
});
|
||||
|
||||
it('should celebrate a leap-day birthday on February 28th in non-leap years', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 2, day: 25 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1992-02-29' });
|
||||
await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: '2024-02-29T12:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(1);
|
||||
expect(memories[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
data: { personId: person.personGroupId, personName: 'Alice', year: 1992 },
|
||||
memoryAt: DateTime.fromObject({ year: 2025, month: 2, day: 28 }, { zone: 'utc' }).toJSDate(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not create a birthday memory when the birthday is more than 3 days away', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-20' });
|
||||
await newPersonAsset(ctx, {
|
||||
ownerId: user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
localDateTime: '2024-06-20T12:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday });
|
||||
expect(memories.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMemoriesCleanup', () => {
|
||||
it('should run without error', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
@@ -397,6 +397,10 @@ export const memoryLaneTitle = derived(t, ($t) => {
|
||||
: memoryDate.toLocaleString(DateTime.DATE_MED, { locale: get(locale) });
|
||||
}
|
||||
|
||||
if (memory.type === MemoryType.Birthday && 'personName' in memory.data) {
|
||||
return $t('birthday_memory_title', { values: { name: memory.data.personName } });
|
||||
}
|
||||
|
||||
return $t('unknown');
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user