feat: add partner create endpoint (#21625)

This commit is contained in:
Jason Rasmussen 2025-09-05 17:59:11 -04:00 committed by GitHub
parent db0ea0f3a8
commit 5a7042364b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 233 additions and 41 deletions

View file

@ -23,8 +23,8 @@ describe('/partners', () => {
]); ]);
await Promise.all([ await Promise.all([
createPartner({ id: user2.userId }, { headers: asBearerAuth(user1.accessToken) }), createPartner({ partnerCreateDto: { sharedWithId: user2.userId } }, { headers: asBearerAuth(user1.accessToken) }),
createPartner({ id: user1.userId }, { headers: asBearerAuth(user2.accessToken) }), createPartner({ partnerCreateDto: { sharedWithId: user1.userId } }, { headers: asBearerAuth(user2.accessToken) }),
]); ]);
}); });

View file

@ -462,7 +462,8 @@ export const utils = {
updateLibrary: (accessToken: string, id: string, dto: UpdateLibraryDto) => updateLibrary: (accessToken: string, id: string, dto: UpdateLibraryDto) =>
updateLibrary({ id, updateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }), updateLibrary({ id, updateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }), createPartner: (accessToken: string, id: string) =>
createPartner({ partnerCreateDto: { sharedWithId: id } }, { headers: asBearerAuth(accessToken) }),
updateMyPreferences: (accessToken: string, userPreferencesUpdateDto: UserPreferencesUpdateDto) => updateMyPreferences: (accessToken: string, userPreferencesUpdateDto: UserPreferencesUpdateDto) =>
updateMyPreferences({ userPreferencesUpdateDto }, { headers: asBearerAuth(accessToken) }), updateMyPreferences({ userPreferencesUpdateDto }, { headers: asBearerAuth(accessToken) }),

View file

@ -22,14 +22,14 @@ class PartnerApiRepository extends ApiRepository {
} }
Future<UserDto> create(String id) async { Future<UserDto> create(String id) async {
final dto = await checkNull(_api.createPartner(id)); final dto = await checkNull(_api.createPartnerDeprecated(id));
return UserConverter.fromPartnerDto(dto); return UserConverter.fromPartnerDto(dto);
} }
Future<void> delete(String id) => _api.removePartner(id); Future<void> delete(String id) => _api.removePartner(id);
Future<UserDto> update(String id, {required bool inTimeline}) async { Future<UserDto> update(String id, {required bool inTimeline}) async {
final dto = await checkNull(_api.updatePartner(id, UpdatePartnerDto(inTimeline: inTimeline))); final dto = await checkNull(_api.updatePartner(id, PartnerUpdateDto(inTimeline: inTimeline)));
return UserConverter.fromPartnerDto(dto); return UserConverter.fromPartnerDto(dto);
} }
} }

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -4994,6 +4994,48 @@
], ],
"x-immich-permission": "partner.read", "x-immich-permission": "partner.read",
"description": "This endpoint requires the `partner.read` permission." "description": "This endpoint requires the `partner.read` permission."
},
"post": {
"operationId": "createPartner",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PartnerCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PartnerResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Partners"
],
"x-immich-permission": "partner.create",
"description": "This endpoint requires the `partner.create` permission."
} }
}, },
"/partners/{id}": { "/partners/{id}": {
@ -5033,7 +5075,9 @@
"description": "This endpoint requires the `partner.delete` permission." "description": "This endpoint requires the `partner.delete` permission."
}, },
"post": { "post": {
"operationId": "createPartner", "deprecated": true,
"description": "This property was deprecated in v1.141.0. This endpoint requires the `partner.create` permission.",
"operationId": "createPartnerDeprecated",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
@ -5069,10 +5113,13 @@
} }
], ],
"tags": [ "tags": [
"Partners" "Partners",
"Deprecated"
], ],
"x-immich-permission": "partner.create", "x-immich-lifecycle": {
"description": "This endpoint requires the `partner.create` permission." "deprecatedAt": "v1.141.0"
},
"x-immich-permission": "partner.create"
}, },
"put": { "put": {
"operationId": "updatePartner", "operationId": "updatePartner",
@ -5091,7 +5138,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UpdatePartnerDto" "$ref": "#/components/schemas/PartnerUpdateDto"
} }
} }
}, },
@ -12853,6 +12900,18 @@
], ],
"type": "object" "type": "object"
}, },
"PartnerCreateDto": {
"properties": {
"sharedWithId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"sharedWithId"
],
"type": "object"
},
"PartnerDirection": { "PartnerDirection": {
"enum": [ "enum": [
"shared-by", "shared-by",
@ -12899,6 +12958,17 @@
], ],
"type": "object" "type": "object"
}, },
"PartnerUpdateDto": {
"properties": {
"inTimeline": {
"type": "boolean"
}
},
"required": [
"inTimeline"
],
"type": "object"
},
"PeopleResponse": { "PeopleResponse": {
"properties": { "properties": {
"enabled": { "enabled": {
@ -17241,17 +17311,6 @@
}, },
"type": "object" "type": "object"
}, },
"UpdatePartnerDto": {
"properties": {
"inTimeline": {
"type": "boolean"
}
},
"required": [
"inTimeline"
],
"type": "object"
},
"UsageByUserDto": { "UsageByUserDto": {
"properties": { "properties": {
"photos": { "photos": {

View file

@ -811,7 +811,10 @@ export type PartnerResponseDto = {
profileChangedAt: string; profileChangedAt: string;
profileImagePath: string; profileImagePath: string;
}; };
export type UpdatePartnerDto = { export type PartnerCreateDto = {
sharedWithId: string;
};
export type PartnerUpdateDto = {
inTimeline: boolean; inTimeline: boolean;
}; };
export type PeopleResponseDto = { export type PeopleResponseDto = {
@ -3122,6 +3125,21 @@ export function getPartners({ direction }: {
...opts ...opts
})); }));
} }
/**
* This endpoint requires the `partner.create` permission.
*/
export function createPartner({ partnerCreateDto }: {
partnerCreateDto: PartnerCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: PartnerResponseDto;
}>("/partners", oazapfts.json({
...opts,
method: "POST",
body: partnerCreateDto
})));
}
/** /**
* This endpoint requires the `partner.delete` permission. * This endpoint requires the `partner.delete` permission.
*/ */
@ -3134,9 +3152,9 @@ export function removePartner({ id }: {
})); }));
} }
/** /**
* This endpoint requires the `partner.create` permission. * This property was deprecated in v1.141.0. This endpoint requires the `partner.create` permission.
*/ */
export function createPartner({ id }: { export function createPartnerDeprecated({ id }: {
id: string; id: string;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
@ -3150,9 +3168,9 @@ export function createPartner({ id }: {
/** /**
* This endpoint requires the `partner.update` permission. * This endpoint requires the `partner.update` permission.
*/ */
export function updatePartner({ id, updatePartnerDto }: { export function updatePartner({ id, partnerUpdateDto }: {
id: string; id: string;
updatePartnerDto: UpdatePartnerDto; partnerUpdateDto: PartnerUpdateDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
@ -3160,7 +3178,7 @@ export function updatePartner({ id, updatePartnerDto }: {
}>(`/partners/${encodeURIComponent(id)}`, oazapfts.json({ }>(`/partners/${encodeURIComponent(id)}`, oazapfts.json({
...opts, ...opts,
method: "PUT", method: "PUT",
body: updatePartnerDto body: partnerUpdateDto
}))); })));
} }
/** /**

View file

@ -0,0 +1,101 @@
import { PartnerController } from 'src/controllers/partner.controller';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PartnerService } from 'src/services/partner.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(PartnerController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(PartnerService);
beforeAll(async () => {
ctx = await controllerSetup(PartnerController, [
{ provide: PartnerService, useValue: service },
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /partners', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/partners');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require a direction`, async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'direction should not be empty',
expect.stringContaining('direction must be one of the following values:'),
]),
);
});
it(`should require direction to be an enum`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/partners`)
.query({ direction: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('direction must be one of the following values:')]),
);
});
});
describe('POST /partners', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/partners');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require sharedWithId to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.post(`/partners`)
.send({ sharedWithId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
describe('PUT /partners/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/partners/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/partners/invalid`)
.send({ inTimeline: true })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
describe('DELETE /partners/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/partners/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete(`/partners/invalid`)
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
});

View file

@ -1,7 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { EndpointLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PartnerService } from 'src/services/partner.service'; import { PartnerService } from 'src/services/partner.service';
@ -18,10 +19,17 @@ export class PartnerController {
return this.service.search(auth, dto); return this.service.search(auth, dto);
} }
@Post(':id') @Post()
@Authenticated({ permission: Permission.PartnerCreate }) @Authenticated({ permission: Permission.PartnerCreate })
createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> { createPartner(@Auth() auth: AuthDto, @Body() dto: PartnerCreateDto): Promise<PartnerResponseDto> {
return this.service.create(auth, id); return this.service.create(auth, dto);
}
@Post(':id')
@EndpointLifecycle({ deprecatedAt: 'v1.141.0' })
@Authenticated({ permission: Permission.PartnerCreate })
createPartnerDeprecated(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
return this.service.create(auth, { sharedWithId: id });
} }
@Put(':id') @Put(':id')
@ -29,7 +37,7 @@ export class PartnerController {
updatePartner( updatePartner(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Body() dto: UpdatePartnerDto, @Body() dto: PartnerUpdateDto,
): Promise<PartnerResponseDto> { ): Promise<PartnerResponseDto> {
return this.service.update(auth, id, dto); return this.service.update(auth, id, dto);
} }

View file

@ -1,9 +1,14 @@
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 } from 'src/validation'; import { ValidateEnum, ValidateUUID } from 'src/validation';
export class UpdatePartnerDto { export class PartnerCreateDto {
@ValidateUUID()
sharedWithId!: string;
}
export class PartnerUpdateDto {
@IsNotEmpty() @IsNotEmpty()
inTimeline!: boolean; inTimeline!: boolean;
} }

View file

@ -53,7 +53,7 @@ describe(PartnerService.name, () => {
mocks.partner.get.mockResolvedValue(void 0); mocks.partner.get.mockResolvedValue(void 0);
mocks.partner.create.mockResolvedValue(partner); mocks.partner.create.mockResolvedValue(partner);
await expect(sut.create(auth, user2.id)).resolves.toBeDefined(); await expect(sut.create(auth, { sharedWithId: user2.id })).resolves.toBeDefined();
expect(mocks.partner.create).toHaveBeenCalledWith({ expect(mocks.partner.create).toHaveBeenCalledWith({
sharedById: partner.sharedById, sharedById: partner.sharedById,
@ -69,7 +69,7 @@ describe(PartnerService.name, () => {
mocks.partner.get.mockResolvedValue(partner); mocks.partner.get.mockResolvedValue(partner);
await expect(sut.create(auth, user2.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.create(auth, { sharedWithId: user2.id })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.partner.create).not.toHaveBeenCalled(); expect(mocks.partner.create).not.toHaveBeenCalled();
}); });

View file

@ -1,7 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { Partner } from 'src/database'; import { Partner } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
import { mapUser } from 'src/dtos/user.dto'; import { mapUser } from 'src/dtos/user.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository'; import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository';
@ -9,7 +9,7 @@ import { BaseService } from 'src/services/base.service';
@Injectable() @Injectable()
export class PartnerService extends BaseService { export class PartnerService extends BaseService {
async create(auth: AuthDto, sharedWithId: string): Promise<PartnerResponseDto> { async create(auth: AuthDto, { sharedWithId }: 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) {
@ -39,7 +39,7 @@ export class PartnerService extends BaseService {
.map((partner) => this.mapPartner(partner, direction)); .map((partner) => this.mapPartner(partner, direction));
} }
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> { async update(auth: AuthDto, sharedById: string, dto: PartnerUpdateDto): Promise<PartnerResponseDto> {
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 };

View file

@ -104,7 +104,7 @@
try { try {
for (const user of users) { for (const user of users) {
await createPartner({ id: user.id }); await createPartner({ partnerCreateDto: { sharedWithId: user.id } });
} }
await refreshPartners(); await refreshPartners();
@ -115,7 +115,7 @@
const handleShowOnTimelineChanged = async (partner: PartnerSharing, inTimeline: boolean) => { const handleShowOnTimelineChanged = async (partner: PartnerSharing, inTimeline: boolean) => {
try { try {
await updatePartner({ id: partner.user.id, updatePartnerDto: { inTimeline } }); await updatePartner({ id: partner.user.id, partnerUpdateDto: { inTimeline } });
partner.inTimeline = inTimeline; partner.inTimeline = inTimeline;
} catch (error) { } catch (error) {