- Query DTO properties with a default value (limit, offset, days, perColumn, assignedToMe, search, archived, requiresReason, force) were documented as `object` in openapi.json, which broke generated clients. They now declare their type explicitly. - Reading a private project, ticket, release or its comments without access answered "Project <internal id> not found", confirming the project exists. ReadAccess now reports what the caller asked for (e2e covered). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
2.5 KiB
TypeScript
106 lines
2.5 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
NotFoundException,
|
|
Param,
|
|
Query,
|
|
} from '@nestjs/common';
|
|
import {
|
|
ApiOkResponse,
|
|
ApiOperation,
|
|
ApiProperty,
|
|
ApiPropertyOptional,
|
|
ApiTags,
|
|
} from '@nestjs/swagger';
|
|
import { IdentityService } from '@task-stream/api/identity/application';
|
|
import { User } from '@task-stream/api/identity/domain';
|
|
import { ApiReadErrors } from '@task-stream/api/shared/presenters';
|
|
import { Type } from 'class-transformer';
|
|
import {
|
|
IsInt,
|
|
IsOptional,
|
|
IsString,
|
|
Max,
|
|
MaxLength,
|
|
Min,
|
|
} from 'class-validator';
|
|
import {
|
|
AccessToken,
|
|
ApiPireonAuth,
|
|
CurrentUser,
|
|
} from '../auth/auth.decorators';
|
|
|
|
export class UserDto {
|
|
@ApiProperty({ description: 'Pireon user id' }) id!: string;
|
|
@ApiProperty() name!: string;
|
|
@ApiProperty() email!: string;
|
|
@ApiProperty() isActive!: boolean;
|
|
}
|
|
|
|
export class MeDto extends UserDto {
|
|
@ApiProperty({ type: [String], description: 'Global roles in Pireon' })
|
|
roles!: string[];
|
|
@ApiProperty({
|
|
type: [String],
|
|
description: 'Roles inside the task-stream OAuth client',
|
|
})
|
|
appRoles!: string[];
|
|
}
|
|
|
|
export class UserSearchQueryDto {
|
|
@ApiPropertyOptional({
|
|
type: String,
|
|
description: 'Name or email',
|
|
default: '',
|
|
})
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(100)
|
|
search = '';
|
|
|
|
@ApiPropertyOptional({ type: Number, default: 20, minimum: 1, maximum: 100 })
|
|
@IsOptional()
|
|
@Type(() => Number)
|
|
@IsInt()
|
|
@Min(1)
|
|
@Max(100)
|
|
limit = 20;
|
|
}
|
|
|
|
/** REST adapter of identity: who I am and Pireon's user directory. */
|
|
@ApiTags('Users')
|
|
@ApiPireonAuth()
|
|
@Controller('users')
|
|
export class UsersController {
|
|
constructor(private readonly identity: IdentityService) {}
|
|
|
|
@Get('me')
|
|
@ApiOperation({ summary: 'The user behind the access token' })
|
|
@ApiOkResponse({ type: MeDto })
|
|
@ApiReadErrors()
|
|
me(@CurrentUser() user: User): MeDto {
|
|
return user.toSnapshot();
|
|
}
|
|
|
|
@Get()
|
|
@ApiOperation({
|
|
summary: 'Search Pireon users (to assign tickets or add members)',
|
|
description: 'Needs the `users:read` scope in the access token.',
|
|
})
|
|
@ApiOkResponse({ type: [UserDto] })
|
|
@ApiReadErrors()
|
|
search(@Query() query: UserSearchQueryDto, @AccessToken() token: string) {
|
|
return this.identity.searchUsers(query.search, token, query.limit);
|
|
}
|
|
|
|
@Get(':id')
|
|
@ApiOperation({ summary: 'A Pireon user' })
|
|
@ApiOkResponse({ type: UserDto })
|
|
@ApiReadErrors()
|
|
async findOne(@Param('id') id: string, @AccessToken() token: string) {
|
|
const user = await this.identity.findUser(id, token);
|
|
if (!user) throw new NotFoundException('User not found');
|
|
return user;
|
|
}
|
|
}
|