AI Implementation feature(820): Project Scaffold and Platform Foundations (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UserRole } from '../../database/entities/user.entity';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import {
|
||||
CreateUserDtoSchema,
|
||||
ListUsersQueryDtoSchema,
|
||||
UpdateUserRoleDtoSchema,
|
||||
UserIdParamDtoSchema,
|
||||
} from './dto/admin.dto';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
/**
|
||||
* All admin handlers use Zod validation via `ZodValidationPipe` and an
|
||||
* INLINE body/param/query type. Using inline types (rather than class
|
||||
* metatypes) avoids the Nest class-transformer pass which would otherwise
|
||||
* overwrite the raw body before our pipe sees it.
|
||||
*/
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/admin')
|
||||
@UseGuards(AdminGuard)
|
||||
@Roles('admin')
|
||||
export class AdminController {
|
||||
constructor(private readonly admin: AdminService) {}
|
||||
|
||||
@Get('users')
|
||||
@ApiOperation({ summary: 'List all users (admin only)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
|
||||
query: { limit?: number; cursor?: string; role?: UserRole },
|
||||
) {
|
||||
return this.admin.listUsers(query);
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
@ApiOperation({ summary: 'Update a user role (admin only)' })
|
||||
update(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole },
|
||||
) {
|
||||
return this.admin.updateUserRole(params.id, body.role);
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
@ApiOperation({ summary: 'Delete a user (admin only)' })
|
||||
async remove(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
): Promise<void> {
|
||||
await this.admin.deleteUser(params.id);
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
@ApiOperation({ summary: 'Create a user (admin only)' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(CreateUserDtoSchema)) body: { username: string; password: string; role: UserRole },
|
||||
) {
|
||||
return this.admin.createUser(body.username, body.password, body.role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), AuthModule, UsersModule],
|
||||
providers: [AdminService],
|
||||
controllers: [AdminController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as argon2 from 'argon2';
|
||||
import { UserEntity, UserRole } from '../../database/entities/user.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { ApiError } from '../../common/errors/api-error';
|
||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
||||
import { validatePassword } from '../../common/utils/password-policy';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface ListUsersQuery {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
role?: UserRole;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async listUsers(query: ListUsersQuery = {}): Promise<UserEntity[]> {
|
||||
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
|
||||
const repo = this.dataSource.getRepository(UserEntity);
|
||||
const qb = repo
|
||||
.createQueryBuilder('u')
|
||||
.orderBy('u.createdAt', 'ASC')
|
||||
.addOrderBy('u.id', 'ASC')
|
||||
.limit(limit + 1);
|
||||
if (query.role) qb.andWhere('u.role = :role', { role: query.role });
|
||||
if (query.cursor) qb.andWhere('u.id > :cursor', { cursor: query.cursor });
|
||||
const rows = await qb.getMany();
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
async updateUserRole(targetId: string, role: UserRole): Promise<UserEntity> {
|
||||
if (role !== 'admin' && role !== 'player') {
|
||||
throw ApiError.validation('Invalid role');
|
||||
}
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.usersService.enforceLastAdminInvariant(manager, targetId, role);
|
||||
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
|
||||
if (!user) throw ApiError.notFound('User not found');
|
||||
user.role = role;
|
||||
await manager.save(user);
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(targetId: string): Promise<void> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.usersService.enforceLastAdminInvariant(manager, targetId);
|
||||
const res = await manager.delete(UserEntity, { id: targetId });
|
||||
if (!res.affected) throw ApiError.notFound('User not found');
|
||||
});
|
||||
}
|
||||
|
||||
async createUser(username: string, password: string, role: UserRole): Promise<UserEntity> {
|
||||
validatePassword(password, this.config);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const existing = await manager.findOne(UserEntity, { where: { username } });
|
||||
if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken');
|
||||
const hash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
const user = manager.create(UserEntity, {
|
||||
id: uuid(),
|
||||
username,
|
||||
passwordHash: hash,
|
||||
role,
|
||||
status: 'enabled',
|
||||
});
|
||||
await manager.save(user);
|
||||
return user;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateUserDtoSchema = z.object({
|
||||
username: z.string().trim().min(3).max(64).regex(/^[a-zA-Z0-9._-]+$/, 'Username may contain letters, digits, dot, underscore, dash'),
|
||||
password: z.string().min(1).max(256),
|
||||
role: z.enum(['admin', 'player']),
|
||||
});
|
||||
export type CreateUserDto = z.infer<typeof CreateUserDtoSchema>;
|
||||
|
||||
export const UpdateUserRoleDtoSchema = z.object({
|
||||
role: z.enum(['admin', 'player']),
|
||||
});
|
||||
export type UpdateUserRoleDto = z.infer<typeof UpdateUserRoleDtoSchema>;
|
||||
|
||||
export const UserIdParamDtoSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
export type UserIdParamDto = z.infer<typeof UserIdParamDtoSchema>;
|
||||
|
||||
export const ListUsersQueryDtoSchema = z.object({
|
||||
limit: z.coerce.number().int().positive().max(200).optional().default(50),
|
||||
cursor: z.string().uuid().optional(),
|
||||
role: z.enum(['admin', 'player']).optional(),
|
||||
});
|
||||
export type ListUsersQueryDto = z.infer<typeof ListUsersQueryDtoSchema>;
|
||||
Reference in New Issue
Block a user