Menyelesaikan object user dan init base

This commit is contained in:
Mr. Rayyan
2026-07-30 22:47:18 +07:00
parent 3efac50f39
commit fdbfb34842
128 changed files with 6785 additions and 3156 deletions
+20
View File
@@ -0,0 +1,20 @@
modules/
Penjelasan singkat:
Folder modules berisi domain modules. Setiap module merepresentasikan satu domain bisnis dan mengikuti prinsip Clean Architecture dengan folder:
- presentation/ # controllers, dto, response
- application/ # use-cases, commands, queries
- domain/ # entities, value objects, repository interfaces, domain services, events
- infrastructure/ # prisma repositories, adapter implementations
Contoh module: identity
- src/modules/identity/presentation/controllers/users.controller.ts
- src/modules/identity/application/services/get-user.service.ts
- src/modules/identity/domain/entities/user.entity.ts
- src/modules/identity/infrastructure/repositories/user.repository.ts
Panduan singkat:
- Controller tipis: lakukan validasi dan panggil application service.
- Business logic harus berada di application/domain.
- Infrastruktur mengimplementasikan interface domain (dependency inversion).
- Gunakan event untuk komunikasi lintas module bila memungkinkan.
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const Permissions = (...permissions: string[]) =>
SetMetadata('permissions', permissions);
@@ -0,0 +1,36 @@
import {
Injectable,
CanActivate,
ExecutionContext,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
) {}
canActivate(context: ExecutionContext): boolean {
const permissions =
this.reflector.getAllAndOverride<string[]>(
'permissions',
[
context.getHandler(),
context.getClass(),
],
);
if (!permissions) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
return permissions.every(permission =>
user.permissions.includes(permission),
);
}
}
@@ -0,0 +1,12 @@
modules/identity/application/
Penjelasan:
Layer application berisi use-case (services/commands/queries) yang mengorkestrasi domain dan infrastruktur.
Contoh file:
- services/get-user.service.ts
- commands/create-user.command.ts
Aturan:
- Application service boleh memanggil repository interface, domain services, dan event publisher.
- Application menangani transaksi jika diperlukan.
@@ -0,0 +1,37 @@
import {
Injectable,
ConflictException,
} from '@nestjs/common';
import { CreateUserDto } from '../../presentation/dto/create-user.dto';
import { User } from '../../domain/entities/user.entity';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
@Injectable()
export class CreateUserHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(dto: CreateUserDto): Promise<User> {
const exists = await this.userRepository.existsByEmail(dto.email);
if (exists) {
throw new ConflictException('Email already exists.');
}
const user = User.create({
name: dto.name,
email: dto.email,
password: dto.password,
metadata: dto.metadata,
});
await this.userRepository.create(user);
// TODO:
// this.eventDispatcher.publish(new UserCreatedEvent(user));
return user;
}
}
@@ -0,0 +1,28 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
@Injectable()
export class DeleteUserHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(id: string): Promise<void> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException('User not found.');
}
user.delete();
await this.userRepository.update(user);
// TODO:
// Publish UserDeletedEvent
}
}
@@ -0,0 +1,24 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
@Injectable()
export class FindUserHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(id: string): Promise<User> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException('User not found.');
}
return user;
}
}
@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
@Injectable()
export class FindUsersHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(): Promise<User[]> {
return await this.userRepository.findAll();
}
}
@@ -0,0 +1,51 @@
import {
Injectable,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { UpdateUserDto } from '../../presentation/dto/update-user.dto';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
@Injectable()
export class UpdateUserHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(
id: string,
dto: UpdateUserDto,
): Promise<User> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException('User not found.');
}
if (
dto.email &&
dto.email !== user.email
) {
const exists =
await this.userRepository.existsByEmail(dto.email);
if (exists) {
throw new ConflictException(
'Email already exists.',
);
}
user.changeEmail(dto.email);
}
if (dto.name) {
user.changeName(dto.name);
}
await this.userRepository.update(user);
return user;
}
}
@@ -0,0 +1,12 @@
services/
Penjelasan:
Application services (use-cases) untuk module identity.
Contoh file:
- get-user.service.ts
- create-user.service.ts
Aturan:
- Application service mengorkestrasi domain services dan repository.
- Menangani transaction boundary jika perlu.
@@ -1,20 +0,0 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { UserService } from '../services/user.service';
@ApiTags('users')
@Controller('api/v1/users')
export class UsersController {
constructor(private readonly userService: UserService) {}
@Get(':id')
@ApiOperation({ summary: 'Get user by id' })
async findById(@Param('id') id: string) {
const data = await this.userService.findById(id);
return {
success: true,
data,
meta: {},
};
}
}
+13
View File
@@ -0,0 +1,13 @@
modules/identity/domain/
Penjelasan:
Layer domain berisi entity, value objects, repository interfaces, domain services, dan domain events.
Contoh file:
- entities/user.entity.ts
- events/user-created.event.ts
- repositories/user.repository.interface.ts
Aturan:
- Semua aturan bisnis inti berada di domain.
- Infrastruktur mengimplementasikan interface yang didefinisikan di domain.
@@ -0,0 +1,11 @@
entities/
Penjelasan:
Entity domain untuk identity, mis. User, Profile.
Contoh file:
- user.entity.ts
- profile.entity.ts
Aturan:
- Entity berisi atribut dan mungkin method domain kecil (invariants), bukan orchestration.
@@ -0,0 +1,52 @@
export class User {
private constructor(
public readonly id: string,
public name: string,
public email: string,
public password: string,
public metadata?: Record<string, any>,
) {}
static create(data: {
name: string;
email: string;
password: string;
metadata?: Record<string, any>;
}): User {
return new User(
crypto.randomUUID(),
data.name,
data.email,
data.password,
data.metadata,
);
}
delete() {
// Business Rule
}
changeEmail(email: string) {
this.email = email;
}
changeName(name: string) {
this.name = name;
}
static restore(data: {
id: string;
name: string;
email: string;
password: string;
metadata?: Record<string, any>;
}): User {
return new User(
data.id,
data.name,
data.email,
data.password,
data.metadata,
);
}
}
@@ -0,0 +1,6 @@
import { DomainEvent } from '../../../../core/events/event.interface';
export class UserCreatedEvent implements DomainEvent {
readonly name = 'UserCreated';
constructor(public readonly payload: any, public readonly occurredAt: Date = new Date()) {}
}
@@ -0,0 +1,6 @@
import { DomainEvent } from '../../../../core/events/event.interface';
export class UserDeletedEvent implements DomainEvent {
readonly name = 'UserDeleted';
constructor(public readonly payload: any, public readonly occurredAt: Date = new Date()) {}
}
@@ -0,0 +1,17 @@
import { User } from '../entities/user.entity';
export abstract class UserRepository {
abstract create(user: User): Promise<User>;
abstract update(user: User): Promise<User>;
abstract findById(
id: string,
): Promise<User | null>;
abstract findAll(): Promise<User[]>;
abstract existsByEmail(
email: string,
): Promise<boolean>;
}
@@ -1 +0,0 @@
// REMOVED: file content deleted per user request
@@ -1 +0,0 @@
// REMOVED: file content deleted per user request
@@ -1,9 +0,0 @@
export class UserEntity {
id: string;
email: string;
displayName: string;
metadata?: Record<string, any>;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date | null;
}
+52 -1
View File
@@ -1 +1,52 @@
// REMOVED: file content deleted per user request
import { Module } from '@nestjs/common';
import { UsersController } from './presentation/controllers/users.controller';
import { PrismaService } from '../../shared/prisma.service';
import { PrismaUserRepository } from './infrastructure/repositories/prisma-user.repository';
import { UserRepository } from './domain/repositories/user.repository.interface';
import { EventDispatcher } from '../../core/events/event-dispatcher';
import { JwtAuthGuard } from '../../core/auth/guards/jwt-auth.guard';
import { JwtModule } from '@nestjs/jwt';
import { CreateUserHandler } from './application/handlers/create-user.handler';
import { FindUserHandler } from './application/handlers/find-user.handler';
import { DeleteUserHandler } from './application/handlers/delete-user.handler';
import { FindUsersHandler } from './application/handlers/find-users.handler';
import { UpdateUserHandler } from './application/handlers/update-user.handler';
import { PermissionGuard } from '../authorization/presentation/guards/permission.guard';
import { Reflector } from '@nestjs/core';
@Module({
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: {
expiresIn: '1d',
},
}),
],
controllers: [UsersController],
providers: [
//#region User
CreateUserHandler,
FindUserHandler,
FindUsersHandler,
UpdateUserHandler,
DeleteUserHandler,
//#endregion
JwtAuthGuard,
PermissionGuard,
Reflector,
PrismaService,
PrismaUserRepository,
{
provide: UserRepository,
useClass: PrismaUserRepository,
},
{
provide: 'EVENT_DISPATCHER',
useValue: new EventDispatcher(),
},
],
})
export class IdentityModule {}
@@ -0,0 +1,12 @@
modules/identity/infrastructure/
Penjelasan:
Implementasi teknis untuk module identity, seperti Prisma repository, adapter implementations, dan data mappers.
Contoh file:
- prisma/user.repository.ts (mengimplementasikan domain repository interface)
- adapter/identity-adapter.ts
Aturan:
- Infrastruktur hanya mengimplementasikan interface domain; jangan memuat business rules.
- Import dari infrastructure ke domain harus satu arah: infrastructure -> domain (implementasi).
@@ -0,0 +1,27 @@
import { User } from '../../domain/entities/user.entity';
export class PrismaUserMapper {
static toDomain(model: any): User | null {
if (!model) {
return null;
}
return User.restore({
id: model.id,
name: model.name,
email: model.email,
password: model.password,
metadata: model.metadata,
});
}
static toPersistence(user: User) {
return {
id: user.id,
name: user.name,
email: user.email,
password: user.password,
metadata: user.metadata ?? {},
};
}
}
@@ -0,0 +1,11 @@
repositories/
Penjelasan:
Implementasi repository di layer infrastructure. Biasanya berisi Prisma queries dan mapping antara DB model dan domain entity.
Contoh file:
- prisma/user.repository.ts
Aturan:
- Repository mengimplementasikan interface di domain layer.
- Hindari business logic di repository.
@@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../../shared/prisma.service';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
import { PrismaUserMapper } from '../mappers/prisma-user.mapper';
@Injectable()
export class PrismaUserRepository implements UserRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
});
return PrismaUserMapper.toDomain(user);
}
async findAll(): Promise<User[]> {
const users = await this.prisma.user.findMany({
where: { deleted_at: null },
orderBy: { created_at: 'desc' },
});
return users.map(PrismaUserMapper.toDomain);
}
async create(user: User): Promise<User> {
const created = await this.prisma.user.create({
data: PrismaUserMapper.toPersistence(user),
});
return PrismaUserMapper.toDomain(created)!;
}
async update(user: User): Promise<User> {
const updated = await this.prisma.user.update({
where: {
id: user.id,
},
data: PrismaUserMapper.toPersistence(user),
});
return PrismaUserMapper.toDomain(updated)!;
}
async delete(id: string) {
const user = await this.prisma.user.update({
where: { id },
data: { deleted_at: new Date() },
});
return PrismaUserMapper.toDomain(user);
}
async existsByEmail(email: string, excludeId?: string | null) {
const where: any = { email };
if (excludeId) {
where.id = { not: excludeId };
}
const user = await this.prisma.user.findFirst({ where });
return !!user;
}
}
@@ -1,3 +0,0 @@
export interface IUserService {
findById(id: string): Promise<any | null>;
}
@@ -0,0 +1,13 @@
modules/identity/presentation/
Penjelasan:
Layer presentation untuk module identity. Berisi controller, request/response DTO, dan mapping ke HTTP.
Contoh file:
- controllers/users.controller.ts
- dto/create-user.dto.ts
- dto/update-user.dto.ts
Aturan:
- Controller tipis: lakukan validasi DTO dan panggil application service.
- Jangan menaruh business logic di controller.
@@ -0,0 +1,10 @@
controllers/
Penjelasan:
Folder untuk controller HTTP endpoint module identity.
Contoh file:
- users.controller.ts
Aturan:
- Controller hanya menerima request, validasi DTO, panggil application service, kembalikan response.
@@ -0,0 +1,101 @@
import {
Controller,
Get,
Post,
Delete,
Param,
UseGuards,
Body,
Patch
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../core/auth/guards/jwt-auth.guard';
import { PermissionGuard } from '../../../authorization/presentation/guards/permission.guard';
import { Permissions } from '../../../authorization/presentation/decorators/permission.decorator';
import { FindUserHandler } from '../../application/handlers/find-user.handler';
import { FindUsersHandler } from '../../application/handlers/find-users.handler';
import { CreateUserHandler } from '../../application/handlers/create-user.handler';
import { UpdateUserHandler } from '../../application/handlers/update-user.handler';
import { DeleteUserHandler } from '../../application/handlers/delete-user.handler';
import { CreateUserDto } from '../dto/create-user.dto';
import { UpdateUserDto } from '../dto/update-user.dto';
@ApiTags('Users')
@Controller('api/v1/users')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class UsersController {
constructor(
private readonly createUserHandler: CreateUserHandler,
private readonly findUserHandler: FindUserHandler,
private readonly findUsersHandler: FindUsersHandler,
private readonly updateUserHandler: UpdateUserHandler,
private readonly deleteUserHandler: DeleteUserHandler,
) {}
@Post()
@Permissions('USER_CREATE')
@ApiOperation({ summary: 'Create user' })
async create(@Body() dto: CreateUserDto) {
const data = await this.createUserHandler.execute(dto);
return {
success: true,
data,
meta: {},
}
}
@Get(':id')
@Permissions('USER_READ')
@ApiOperation({ summary: 'Get user by id' })
async getById(@Param('id') id: string) {
const data = await this.findUserHandler.execute(id);
return {
success: true,
data,
meta: {},
};
}
@Get()
@Permissions('USER_READ_ADMIN')
@ApiOperation({ summary: 'Get All User' })
async getAll() {
const data = await this.findUsersHandler.execute();
return {
success: true,
data,
meta: {},
};
}
@Patch(':id')
@Permissions('USER_UPDATE')
@ApiOperation({ summary: 'Update User' })
async update(@Param('id') id:string, @Body() dto:UpdateUserDto) {
const data = await this.updateUserHandler.execute(id, dto);
return {
success: true,
data,
meta: {},
}
}
@Delete(':id')
@Permissions('USER_DELETE')
@ApiOperation({ summary: 'Delete user by id' })
async delete(@Param('id') id: string) {
await this.deleteUserHandler.execute(id);
return {
success: true,
data: null,
meta: {},
};
}
}
@@ -0,0 +1,21 @@
import { IsEmail, IsNotEmpty, IsString, Length } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ example: 'John Doe' })
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email!: string;
@ApiProperty({ example: 'strongpassword' })
@IsString()
@Length(8, 128)
password!: string;
@ApiProperty({ required: false })
metadata?: Record<string, any>;
}
@@ -0,0 +1,25 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsEmail, Length } from 'class-validator';
export class UpdateUserDto extends PartialType(CreateUserDto) {
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional()
@IsOptional()
@Length(8, 128)
password?: string;
@ApiPropertyOptional()
@IsOptional()
metadata?: Record<string, any>;
}
@@ -1,26 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma.service';
@Injectable()
export class UserRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
});
if (!user) return null;
// map DB fields (snake_case) to camelCase entity
return {
id: user.id,
email: user.email,
displayName: (user as any).display_name ?? (user as any).displayName ?? null,
metadata: (user as any).metadata ?? {},
createdAt: (user as any).created_at ?? (user as any).createdAt,
updatedAt: (user as any).updated_at ?? (user as any).updatedAt,
deletedAt: (user as any).deleted_at ?? (user as any).deletedAt,
};
}
}
@@ -1,16 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { UserRepository } from '../repositories/user.repository';
import { IUserService } from '../interfaces/iuser.service';
@Injectable()
export class UserService implements IUserService {
constructor(private readonly userRepository: UserRepository) {}
async findById(id: string) {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
}
@@ -0,0 +1,11 @@
modules/workflow/application/
Penjelasan:
Use-cases yang mengorkestrasi proses lintas module, mis. CreateUserWorkflowService.
Contoh file:
- services/create-user-workflow.service.ts
Aturan:
- Workflow service mem-publish domain events dan/atau menjadwalkan job di queue.
- Workflow tidak boleh berada di controller.
+10
View File
@@ -0,0 +1,10 @@
modules/workflow/domain/
Penjelasan:
Domain objects & events yang berkaitan dengan workflow orchestration.
Contoh file:
- events/workflow-executed.event.ts
Aturan:
- Domain tetap fokus pada konsep bisnis workflow.
@@ -0,0 +1,10 @@
modules/workflow/infrastructure/
Penjelasan:
Implementasi teknis untuk workflow: scheduler, job enqueueing, worker integrations.
Contoh file:
- queue/workflow-processor.ts
Aturan:
- Infrastruktur harus mengimplementasikan contract yang didefinisikan application/domain.
@@ -0,0 +1,10 @@
modules/workflow/presentation/
Penjelasan:
Controller atau API endpoint yang memicu atau mengecek status workflow.
Contoh file:
- controllers/workflows.controller.ts
Aturan:
- Presentation hanya memicu workflow request, detail orchestrasi tetap di application layer.