feat: partner sharing from specific date

This commit is contained in:
Julien Robert 2025-10-14 11:43:53 +02:00
parent dbee133764
commit f1390febf3
No known key found for this signature in database
GPG key ID: AEA8C21E608F1D02
10 changed files with 5218 additions and 3096 deletions

View file

@ -1,4 +1,4 @@
import { LoginResponseDto, createPartner } from '@immich/sdk'; import { LoginResponseDto, createPartner, getAssetInfo } from '@immich/sdk';
import { createUserDto } from 'src/fixtures'; import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils'; import { app, asBearerAuth, utils } from 'src/utils';
@ -101,6 +101,105 @@ describe('/partners', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false })); expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false }));
}); });
it('should update partner with startDate', async () => {
const startDate = '2024-01-01T00:00:00.000Z';
const { status, body } = await request(app)
.put(`/partners/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ inTimeline: true, startDate });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: true, startDate }));
});
it('should clear partner startDate when set to null', async () => {
const { status, body } = await request(app)
.put(`/partners/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ inTimeline: true, startDate: null });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: true }));
expect(body.startDate).toBeUndefined();
});
});
describe('POST /partners with startDate', () => {
it('should create partner with startDate', async () => {
const startDate = '2024-06-01T00:00:00.000Z';
const { status, body } = await request(app)
.post('/partners')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedWithId: user3.userId, startDate });
expect(status).toBe(201);
expect(body).toEqual(expect.objectContaining({ id: user3.userId, startDate }));
// Clean up
await request(app).delete(`/partners/${user3.userId}`).set('Authorization', `Bearer ${user1.accessToken}`);
});
});
describe('GET /partners with startDate', () => {
it('should return partner with startDate in response', async () => {
const startDate = '2023-12-01T00:00:00.000Z';
// Create partner with startDate
await request(app)
.post('/partners')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedWithId: user3.userId, startDate });
const { status, body } = await request(app)
.get('/partners')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: 'shared-by' });
expect(status).toBe(200);
const partner = body.find((p: any) => p.id === user3.userId);
expect(partner).toBeDefined();
expect(partner.startDate).toBe(startDate);
// Clean up
await request(app).delete(`/partners/${user3.userId}`).set('Authorization', `Bearer ${user1.accessToken}`);
});
});
describe('Partner asset access with startDate', () => {
it('should filter partner assets by startDate', async () => {
// Create assets with different dates for user2
const oldAsset = await utils.createAsset(user2.accessToken, {
fileCreatedAt: new Date('2023-01-01T00:00:00.000Z').toISOString(),
});
const newAsset = await utils.createAsset(user2.accessToken, {
fileCreatedAt: new Date('2024-06-01T00:00:00.000Z').toISOString(),
});
// Set partner startDate to 2024-01-01
const startDate = '2024-01-01T00:00:00.000Z';
await request(app)
.put(`/partners/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ inTimeline: true, startDate });
// User1 should be able to access the new asset
const newAssetInfo = await getAssetInfo(
{ id: newAsset.id },
{ headers: asBearerAuth(user1.accessToken) },
);
expect(newAssetInfo.id).toBe(newAsset.id);
// User1 should NOT be able to access the old asset (before startDate)
// Note: Access check happens at permission level, not returning the asset
// We verify by checking if it appears in timeline/search results
const { status: oldStatus } = await request(app)
.get(`/assets/${oldAsset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
// The old asset should be denied access (403) or not found (404) due to startDate filter
expect([403, 404]).toContain(oldStatus);
});
}); });
describe('DELETE /partners/:id', () => { describe('DELETE /partners/:id', () => {

File diff suppressed because it is too large Load diff

View file

@ -215,6 +215,7 @@ export type Partner = {
updatedAt: Date; updatedAt: Date;
updateId: string; updateId: string;
inTimeline: boolean; inTimeline: boolean;
startDate: Date | null;
}; };
export type Place = { export type Place = {

View file

@ -1,16 +1,22 @@
import { IsNotEmpty } from 'class-validator'; import { IsNotEmpty } from 'class-validator';
import { UserResponseDto } from 'src/dtos/user.dto'; import { UserResponseDto } from 'src/dtos/user.dto';
import { PartnerDirection } from 'src/repositories/partner.repository'; import { PartnerDirection } from 'src/repositories/partner.repository';
import { ValidateEnum, ValidateUUID } from 'src/validation'; import { ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
export class PartnerCreateDto { export class PartnerCreateDto {
@ValidateUUID() @ValidateUUID()
sharedWithId!: string; sharedWithId!: string;
@ValidateDate({ optional: true, nullable: true, format: 'date-time' })
startDate?: Date | null;
} }
export class PartnerUpdateDto { export class PartnerUpdateDto {
@IsNotEmpty() @IsNotEmpty()
inTimeline!: boolean; inTimeline!: boolean;
@ValidateDate({ optional: true, nullable: true, format: 'date-time' })
startDate?: Date | null;
} }
export class PartnerSearchDto { export class PartnerSearchDto {
@ -20,4 +26,5 @@ export class PartnerSearchDto {
export class PartnerResponseDto extends UserResponseDto { export class PartnerResponseDto extends UserResponseDto {
inTimeline?: boolean; inTimeline?: boolean;
startDate?: Date | null;
} }

View file

@ -207,7 +207,14 @@ class AssetAccess {
eb('asset.visibility', '=', sql.lit(AssetVisibility.Hidden)), eb('asset.visibility', '=', sql.lit(AssetVisibility.Hidden)),
]), ]),
) )
.$if(true, (qb) =>
qb.where((eb) =>
eb.or([
eb('partner.startDate', 'is', null),
eb('asset.localDateTime', '>=', eb.ref('partner.startDate')),
]),
),
)
.where('asset.id', 'in', [...assetIds]) .where('asset.id', 'in', [...assetIds])
.execute() .execute()
.then((assets) => new Set(assets.map((asset) => asset.id))); .then((assets) => new Set(assets.map((asset) => asset.id)));

View file

@ -542,6 +542,25 @@ export class AssetRepository {
.$call(withExif) .$call(withExif)
.$call(withDefaultVisibility) .$call(withDefaultVisibility)
.where('ownerId', '=', anyUuid(userIds)) .where('ownerId', '=', anyUuid(userIds))
.$if(userIds.length > 1, (qb) =>
qb
.leftJoin('partner', (join) =>
join
.onRef('partner.sharedById', '=', 'asset.ownerId')
.on((eb) =>
eb.or(
userIds.map((uid) => eb('partner.sharedWithId', '=', uid))
)
)
)
.where((eb) =>
eb.or([
eb('partner.startDate', 'is', null),
eb('partner.sharedById', 'is', null),
eb('asset.localDateTime', '>=', eb.ref('partner.startDate')),
])
)
)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.orderBy((eb) => eb.fn('random')) .orderBy((eb) => eb.fn('random'))
.limit(take) .limit(take)
@ -572,7 +591,29 @@ export class AssetRepository {
) )
.where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])), .where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])),
) )
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) .$if(!!options.userIds, (qb) =>
qb
.where('asset.ownerId', '=', anyUuid(options.userIds!))
.$if(options.userIds!.length > 1, (qb2) =>
qb2
.leftJoin('partner', (join) =>
join
.onRef('partner.sharedById', '=', 'asset.ownerId')
.on((eb) =>
eb.or(
options.userIds!.map((uid) => eb('partner.sharedWithId', '=', uid))
)
)
)
.where((eb) =>
eb.or([
eb('partner.startDate', 'is', null),
eb('partner.sharedById', 'is', null),
eb('asset.localDateTime', '>=', eb.ref('partner.startDate')),
])
)
)
)
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!)) .$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) => .$if(options.isDuplicate !== undefined, (qb) =>
@ -645,7 +686,29 @@ export class AssetRepository {
), ),
) )
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) .$if(!!options.userIds, (qb) =>
qb
.where('asset.ownerId', '=', anyUuid(options.userIds!))
.$if(options.userIds!.length > 1, (qb2) =>
qb2
.leftJoin('partner', (join) =>
join
.onRef('partner.sharedById', '=', 'asset.ownerId')
.on((eb) =>
eb.or(
options.userIds!.map((uid) => eb('partner.sharedWithId', '=', uid))
)
)
)
.where((eb) =>
eb.or([
eb('partner.startDate', 'is', null),
eb('partner.sharedById', 'is', null),
eb('asset.localDateTime', '>=', eb.ref('partner.startDate')),
])
)
)
)
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) => .$if(!!options.withStacked, (qb) =>
qb qb
@ -804,6 +867,25 @@ export class AssetRepository {
) )
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack')) .select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack'))
.where('asset.ownerId', '=', anyUuid(options.userIds)) .where('asset.ownerId', '=', anyUuid(options.userIds))
.$if(options.userIds.length > 1, (qb) =>
qb
.leftJoin('partner', (join) =>
join
.onRef('partner.sharedById', '=', 'asset.ownerId')
.on((eb) =>
eb.or(
options.userIds.map((uid) => eb('partner.sharedWithId', '=', uid))
)
)
)
.where((eb) =>
eb.or([
eb('partner.startDate', 'is', null),
eb('partner.sharedById', 'is', null),
eb('asset.localDateTime', '>=', eb.ref('partner.startDate')),
])
)
)
.where('asset.visibility', '!=', AssetVisibility.Hidden) .where('asset.visibility', '!=', AssetVisibility.Hidden)
.where('asset.updatedAt', '>', options.updatedAfter) .where('asset.updatedAt', '>', options.updatedAfter)
.limit(options.limit) .limit(options.limit)

View file

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "partner" ADD "startDate" timestamp with time zone;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "partner" DROP COLUMN "startDate";`.execute(db);
}

View file

@ -44,6 +44,9 @@ export class PartnerTable {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
inTimeline!: Generated<boolean>; inTimeline!: Generated<boolean>;
@Column({ type: 'timestamp with time zone', nullable: true })
startDate!: Timestamp | null;
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
} }

View file

@ -61,6 +61,25 @@ describe(PartnerService.name, () => {
}); });
}); });
it('should create a new partner with startDate', async () => {
const user1 = factory.user();
const user2 = factory.user();
const startDate = new Date('2024-01-01T00:00:00.000Z');
const partner = factory.partner({ sharedBy: user1, sharedWith: user2, startDate });
const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.get.mockResolvedValue(void 0);
mocks.partner.create.mockResolvedValue(partner);
await expect(sut.create(auth, { sharedWithId: user2.id, startDate })).resolves.toBeDefined();
expect(mocks.partner.create).toHaveBeenCalledWith({
sharedById: partner.sharedById,
sharedWithId: partner.sharedWithId,
startDate,
});
});
it('should throw an error when the partner already exists', async () => { it('should throw an error when the partner already exists', async () => {
const user1 = factory.user(); const user1 = factory.user();
const user2 = factory.user(); const user2 = factory.user();
@ -124,5 +143,22 @@ describe(PartnerService.name, () => {
{ inTimeline: true }, { inTimeline: true },
); );
}); });
it('should update partner with startDate', async () => {
const user1 = factory.user();
const user2 = factory.user();
const startDate = new Date('2024-01-01T00:00:00.000Z');
const partner = factory.partner({ sharedBy: user1, sharedWith: user2, startDate });
const auth = factory.auth({ user: { id: user1.id } });
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id]));
mocks.partner.update.mockResolvedValue(partner);
await expect(sut.update(auth, user2.id, { inTimeline: true, startDate })).resolves.toBeDefined();
expect(mocks.partner.update).toHaveBeenCalledWith(
{ sharedById: user2.id, sharedWithId: user1.id },
{ inTimeline: true, startDate },
);
});
}); });
}); });

View file

@ -9,14 +9,14 @@ import { BaseService } from 'src/services/base.service';
@Injectable() @Injectable()
export class PartnerService extends BaseService { export class PartnerService extends BaseService {
async create(auth: AuthDto, { sharedWithId }: PartnerCreateDto): Promise<PartnerResponseDto> { async create(auth: AuthDto, { sharedWithId, startDate }: PartnerCreateDto): Promise<PartnerResponseDto> {
const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId }; const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId };
const exists = await this.partnerRepository.get(partnerId); const exists = await this.partnerRepository.get(partnerId);
if (exists) { if (exists) {
throw new BadRequestException(`Partner already exists`); throw new BadRequestException(`Partner already exists`);
} }
const partner = await this.partnerRepository.create(partnerId); const partner = await this.partnerRepository.create({ ...partnerId, startDate: startDate || null });
return this.mapPartner(partner, PartnerDirection.SharedBy); return this.mapPartner(partner, PartnerDirection.SharedBy);
} }
@ -43,7 +43,12 @@ export class PartnerService extends BaseService {
await this.requireAccess({ auth, permission: Permission.PartnerUpdate, ids: [sharedById] }); await this.requireAccess({ auth, permission: Permission.PartnerUpdate, ids: [sharedById] });
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
const entity = await this.partnerRepository.update(partnerId, { inTimeline: dto.inTimeline }); const updateData: { inTimeline: boolean; startDate?: Date | null } = { inTimeline: dto.inTimeline };
if (dto.startDate !== undefined) {
updateData.startDate = dto.startDate;
}
const entity = await this.partnerRepository.update(partnerId, updateData);
return this.mapPartner(entity, PartnerDirection.SharedWith); return this.mapPartner(entity, PartnerDirection.SharedWith);
} }
@ -53,6 +58,6 @@ export class PartnerService extends BaseService {
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy, direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,
) as PartnerResponseDto; ) as PartnerResponseDto;
return { ...user, inTimeline: partner.inTimeline }; return { ...user, inTimeline: partner.inTimeline, startDate: partner.startDate };
} }
} }