mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
feat: partner sharing from specific date
This commit is contained in:
parent
dbee133764
commit
f1390febf3
10 changed files with 5218 additions and 3096 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { LoginResponseDto, createPartner } from '@immich/sdk';
|
||||
import { LoginResponseDto, createPartner, getAssetInfo } from '@immich/sdk';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, asBearerAuth, utils } from 'src/utils';
|
||||
|
|
@ -101,6 +101,105 @@ describe('/partners', () => {
|
|||
expect(status).toBe(200);
|
||||
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', () => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -215,6 +215,7 @@ export type Partner = {
|
|||
updatedAt: Date;
|
||||
updateId: string;
|
||||
inTimeline: boolean;
|
||||
startDate: Date | null;
|
||||
};
|
||||
|
||||
export type Place = {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { IsNotEmpty } from 'class-validator';
|
||||
import { UserResponseDto } from 'src/dtos/user.dto';
|
||||
import { PartnerDirection } from 'src/repositories/partner.repository';
|
||||
import { ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
import { ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class PartnerCreateDto {
|
||||
@ValidateUUID()
|
||||
sharedWithId!: string;
|
||||
|
||||
@ValidateDate({ optional: true, nullable: true, format: 'date-time' })
|
||||
startDate?: Date | null;
|
||||
}
|
||||
|
||||
export class PartnerUpdateDto {
|
||||
@IsNotEmpty()
|
||||
inTimeline!: boolean;
|
||||
|
||||
@ValidateDate({ optional: true, nullable: true, format: 'date-time' })
|
||||
startDate?: Date | null;
|
||||
}
|
||||
|
||||
export class PartnerSearchDto {
|
||||
|
|
@ -20,4 +26,5 @@ export class PartnerSearchDto {
|
|||
|
||||
export class PartnerResponseDto extends UserResponseDto {
|
||||
inTimeline?: boolean;
|
||||
startDate?: Date | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,7 +207,14 @@ class AssetAccess {
|
|||
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])
|
||||
.execute()
|
||||
.then((assets) => new Set(assets.map((asset) => asset.id)));
|
||||
|
|
|
|||
|
|
@ -542,6 +542,25 @@ export class AssetRepository {
|
|||
.$call(withExif)
|
||||
.$call(withDefaultVisibility)
|
||||
.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)
|
||||
.orderBy((eb) => eb.fn('random'))
|
||||
.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)])),
|
||||
)
|
||||
.$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.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
|
||||
.$if(options.isDuplicate !== undefined, (qb) =>
|
||||
|
|
@ -645,7 +686,29 @@ export class AssetRepository {
|
|||
),
|
||||
)
|
||||
.$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.withStacked, (qb) =>
|
||||
qb
|
||||
|
|
@ -804,6 +867,25 @@ export class AssetRepository {
|
|||
)
|
||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack'))
|
||||
.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.updatedAt', '>', options.updatedAfter)
|
||||
.limit(options.limit)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -44,6 +44,9 @@ export class PartnerTable {
|
|||
@Column({ type: 'boolean', default: false })
|
||||
inTimeline!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||
startDate!: Timestamp | null;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
|
|
@ -124,5 +143,22 @@ describe(PartnerService.name, () => {
|
|||
{ 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ import { BaseService } from 'src/services/base.service';
|
|||
|
||||
@Injectable()
|
||||
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 exists = await this.partnerRepository.get(partnerId);
|
||||
if (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);
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +43,12 @@ export class PartnerService extends BaseService {
|
|||
await this.requireAccess({ auth, permission: Permission.PartnerUpdate, ids: [sharedById] });
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +58,6 @@ export class PartnerService extends BaseService {
|
|||
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,
|
||||
) as PartnerResponseDto;
|
||||
|
||||
return { ...user, inTimeline: partner.inTimeline };
|
||||
return { ...user, inTimeline: partner.inTimeline, startDate: partner.startDate };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue