base con autenticacion, registro, modulo encuestas
This commit is contained in:
61
apps/api/src/features/auth/auth.controller.ts
Normal file
61
apps/api/src/features/auth/auth.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Public } from '@/common/decorators';
|
||||
import { JwtRefreshGuard } from '@/common/guards/jwt-refresh.guard';
|
||||
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
|
||||
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
|
||||
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
|
||||
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@Post('sing-up')
|
||||
// @ApiOperation({ summary: 'Create a new user' })
|
||||
// @ApiResponse({ status: 201, description: 'User created successfully.' })
|
||||
async singUp(@Body() payload: SingUpUserDto) {
|
||||
const data = await this.authService.singUp(payload)
|
||||
return { message: 'User created successfully', data};
|
||||
// return { message: 'User created successfully', data };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@Post('sign-in')
|
||||
async signIn(@Body() signInUserDto: SignInUserDto) {
|
||||
return await this.authService.signIn(signInUserDto);
|
||||
}
|
||||
|
||||
@Post('sign-out')
|
||||
//@RequirePermissions('auth:sign-out')
|
||||
async signOut(@Body() signOutUserDto: SignOutUserDto) {
|
||||
await this.authService.signOut(signOutUserDto);
|
||||
return { message: 'User signed out successfully' };
|
||||
}
|
||||
|
||||
// @Post('forgot-password')
|
||||
// async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
|
||||
// await this.authService.forgotPassword(forgotPasswordDto);
|
||||
// return { message: 'Password reset link sent to your email' };
|
||||
// }
|
||||
|
||||
@UseGuards(JwtRefreshGuard)
|
||||
@Patch('refresh-token')
|
||||
//@RequirePermissions('auth:refresh-token')
|
||||
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return await this.authService.refreshToken(refreshTokenDto);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
12
apps/api/src/features/auth/auth.module.ts
Normal file
12
apps/api/src/features/auth/auth.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { MailModule } from '@/features/mail/mail.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { DrizzleModule } from '@/database/drizzle.module';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule, MailModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
368
apps/api/src/features/auth/auth.service.ts
Normal file
368
apps/api/src/features/auth/auth.service.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { envs } from '@/common/config/envs';
|
||||
import { Env, validateString } from '@/common/utils';
|
||||
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
|
||||
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
|
||||
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
|
||||
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
|
||||
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
|
||||
import { ValidateUserDto } from '@/features/auth/dto/validate-user.dto';
|
||||
import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface';
|
||||
import {
|
||||
LoginUserInterface,
|
||||
Roles,
|
||||
} from '@/features/auth/interfaces/login-user.interface';
|
||||
import RefreshTokenInterface from '@/features/auth/interfaces/refresh-token.interface';
|
||||
import { MailService } from '@/features/mail/mail.service';
|
||||
import { User } from '@/features/users/entities/user.entity';
|
||||
import {
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import crypto from 'crypto';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from 'src/database/index';
|
||||
import { sessions, users, roles, usersRole } from 'src/database/index';
|
||||
import { Session } from './interfaces/session.interface';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService<Env>,
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
private readonly mailService: MailService,
|
||||
) {}
|
||||
|
||||
//Decode Tokens
|
||||
// Método para decodificar el token y obtener los datos completos
|
||||
private decodeToken(token: string): {
|
||||
sub: number;
|
||||
username?: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
} {
|
||||
try {
|
||||
const decoded = this.jwtService.decode(token) as {
|
||||
sub: number;
|
||||
username?: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
// Validar que contiene los datos esenciales
|
||||
if (!decoded || !decoded.exp || !decoded.iat) {
|
||||
throw new Error('Token lacks required fields');
|
||||
}
|
||||
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
// Manejo seguro del tipo unknown
|
||||
let errorMessage = 'Failed to decode token';
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
console.error('Error decoding token:', errorMessage);
|
||||
} else {
|
||||
console.error('Unknown error type:', error);
|
||||
}
|
||||
|
||||
throw new HttpException(errorMessage, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
//Generate Tokens
|
||||
async generateTokens(user: User): Promise<AuthTokensInterface> {
|
||||
const [access_token, refresh_token] = await Promise.all([
|
||||
this.jwtService.signAsync(
|
||||
{
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
},
|
||||
{
|
||||
secret: envs.access_token_secret,
|
||||
expiresIn: envs.access_token_expiration,
|
||||
},
|
||||
),
|
||||
this.jwtService.signAsync(
|
||||
{
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
},
|
||||
{
|
||||
secret: envs.refresh_token_secret,
|
||||
expiresIn: envs.refresh_token_expiration,
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
access_token,
|
||||
refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
//Generate OTP Code For Email Confirmation
|
||||
async generateOTP(length = 6): Promise<string> {
|
||||
return crypto
|
||||
.randomInt(0, 10 ** length)
|
||||
.toString()
|
||||
.padStart(length, '0');
|
||||
}
|
||||
|
||||
// metodo para crear una session
|
||||
private async createSession(sessionInput: Session): Promise<string> {
|
||||
const { userId } = sessionInput;
|
||||
const activeSessionsCount = await this.drizzle
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, parseInt(userId)));
|
||||
|
||||
if (activeSessionsCount.length !== 0) {
|
||||
// Elimina sessiones viejsas
|
||||
await this.drizzle
|
||||
.delete(sessions)
|
||||
.where(eq(sessions.userId, parseInt(userId)));
|
||||
}
|
||||
|
||||
const session = await this.drizzle.insert(sessions).values({
|
||||
sessionToken: sessionInput.sessionToken,
|
||||
userId: parseInt(userId),
|
||||
expiresAt: sessionInput.expiresAt,
|
||||
});
|
||||
if (session.rowCount === 0) throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND);
|
||||
|
||||
return 'Session created successfully';
|
||||
}
|
||||
|
||||
//Find User
|
||||
async findUser(username: string): Promise<User | null> {
|
||||
const user = await this.drizzle
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
return user[0];
|
||||
}
|
||||
|
||||
//Find User
|
||||
async findUserById(id: number): Promise<User | null> {
|
||||
const user = await this.drizzle
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id));
|
||||
return user[0];
|
||||
}
|
||||
|
||||
//Check User Is Already Exists
|
||||
async validateUser(dto: ValidateUserDto): Promise<User> {
|
||||
const user = await this.findUser(dto.username);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
const isValid = await validateString(
|
||||
dto.password,
|
||||
user?.password as string,
|
||||
);
|
||||
if (!isValid) throw new UnauthorizedException('Invalid credentials');
|
||||
return user;
|
||||
}
|
||||
|
||||
//Find rol user
|
||||
async findUserRol(id: number): Promise<Roles[]> {
|
||||
const roles = await this.drizzle
|
||||
.select({
|
||||
id: schema.roles.id,
|
||||
role: schema.roles.name,
|
||||
})
|
||||
.from(schema.usersRole)
|
||||
.leftJoin(schema.roles, eq(schema.roles.id, schema.usersRole.roleId))
|
||||
.where(eq(schema.usersRole.userId, id));
|
||||
|
||||
if (roles.length === 0) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Aseguramos que no haya valores nulos
|
||||
return roles.map((role) => ({
|
||||
id: role.id ?? 0, // Asignamos un valor por defecto (0) si es null
|
||||
rol: role.role ?? '', // Asignamos un valor por defecto (cadena vacía) si es null
|
||||
}));
|
||||
}
|
||||
|
||||
//Sign In User Account
|
||||
async signIn(dto: SignInUserDto): Promise<LoginUserInterface> {
|
||||
|
||||
const user = await this.validateUser(dto);
|
||||
const tokens = await this.generateTokens(user);
|
||||
const decodeAccess = this.decodeToken(tokens.access_token);
|
||||
const decodeRefresh = this.decodeToken(tokens.refresh_token);
|
||||
const rol = await this.findUserRol(user?.id as number);
|
||||
|
||||
await this.createSession({
|
||||
userId: String(user?.id), // Convert number to string
|
||||
sessionToken: tokens.refresh_token,
|
||||
expiresAt: decodeRefresh.exp,
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'User signed in successfully',
|
||||
user: {
|
||||
id: user?.id as number,
|
||||
username: user?.username,
|
||||
fullname: user?.fullname,
|
||||
email: user?.email,
|
||||
rol: rol,
|
||||
},
|
||||
tokens: {
|
||||
access_token: tokens.access_token,
|
||||
access_expire_in: decodeAccess.exp,
|
||||
refresh_token: tokens.refresh_token,
|
||||
refresh_expire_in: decodeRefresh.exp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// //Forgot Password
|
||||
// async forgotPassword(dto: ForgotPasswordDto): Promise<void> {
|
||||
// const user = await this.findUser(dto.username);
|
||||
// if (!user) throw new NotFoundException('User not found');
|
||||
// const passwordResetToken = await this.generateOTP();
|
||||
// user.passwordResetToken = passwordResetToken;
|
||||
// user.passwordResetTokenExpires = new Date(
|
||||
// Date.now() + 1000 * 60 * 60 * 24, // 1 day
|
||||
// );
|
||||
// await this.UserRepository.save(user);
|
||||
// await this.mailService.sendEmail({
|
||||
// to: [user.email],
|
||||
// subject: 'Reset Password',
|
||||
// html: ForgotPasswordMail({
|
||||
// name: user.name,
|
||||
// code: passwordResetToken,
|
||||
// }),
|
||||
// });
|
||||
// }
|
||||
|
||||
//Sign Out User Account
|
||||
async signOut(dto: SignOutUserDto): Promise<void> {
|
||||
const { user_id } = dto;
|
||||
const user = await this.drizzle
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, parseInt(user_id)));
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
await this.drizzle
|
||||
.delete(sessions)
|
||||
.where(eq(sessions.userId, parseInt(user_id)));
|
||||
}
|
||||
|
||||
//Refresh User Access Token
|
||||
async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
|
||||
const { user_id } = dto;
|
||||
|
||||
const session = await this.drizzle
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.userId, user_id) &&
|
||||
eq(sessions.sessionToken, dto.refresh_token),
|
||||
),
|
||||
);
|
||||
|
||||
if (session.length === 0) throw new NotFoundException('session not found');
|
||||
const user = await this.findUserById(dto.user_id);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
const tokens = await this.generateTokens(user);
|
||||
const decodeAccess = this.decodeToken(tokens.access_token);
|
||||
const decodeRefresh = this.decodeToken(tokens.refresh_token);
|
||||
await this.drizzle
|
||||
.update(sessions)
|
||||
.set({ sessionToken: tokens.refresh_token, expiresAt: decodeRefresh.exp })
|
||||
.where(eq(sessions.userId, dto.user_id));
|
||||
|
||||
return {
|
||||
access_token: tokens.access_token,
|
||||
access_expire_in: decodeAccess.exp,
|
||||
refresh_token: tokens.refresh_token,
|
||||
refresh_expire_in: decodeRefresh.exp,
|
||||
};
|
||||
}
|
||||
|
||||
async singUp(createUserDto: SingUpUserDto): Promise<User> {
|
||||
// Check if username or email exists
|
||||
const data = await this.drizzle
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email
|
||||
})
|
||||
.from(users)
|
||||
.where(or(eq(users.username, createUserDto.username), eq(users.email, createUserDto.email)));
|
||||
|
||||
if (data.length > 0) {
|
||||
if (data[0].username === createUserDto.username) {
|
||||
throw new HttpException('Username already exists', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (data[0].email === createUserDto.email) {
|
||||
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
|
||||
|
||||
// Start a transaction
|
||||
return await this.drizzle.transaction(async (tx) => {
|
||||
// Create the user
|
||||
const [newUser] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
username: createUserDto.username,
|
||||
email: createUserDto.email,
|
||||
password: hashedPassword,
|
||||
fullname: createUserDto.fullname,
|
||||
isActive: true,
|
||||
state: createUserDto.state,
|
||||
municipality: createUserDto.municipality,
|
||||
parish: createUserDto.parish,
|
||||
phone: createUserDto.phone,
|
||||
isEmailVerified: false,
|
||||
isTwoFactorEnabled: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// check if user role is admin
|
||||
const role = createUserDto.role <= 2 ? 5 : createUserDto.role;
|
||||
|
||||
// Assign role to user
|
||||
await tx.insert(usersRole).values({
|
||||
userId: newUser.id,
|
||||
roleId: role,
|
||||
});
|
||||
|
||||
// Return the created user with role
|
||||
const [userWithRole] = await tx
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
fullname: users.fullname,
|
||||
phone: users.phone,
|
||||
isActive: users.isActive,
|
||||
role: roles.name,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(usersRole, eq(usersRole.userId, users.id))
|
||||
.leftJoin(roles, eq(roles.id, usersRole.roleId))
|
||||
.where(eq(users.id, newUser.id));
|
||||
|
||||
return userWithRole;
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
22
apps/api/src/features/auth/dto/change-password.dto.ts
Normal file
22
apps/api/src/features/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Identifier must be a string',
|
||||
})
|
||||
username: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Password must be a string',
|
||||
})
|
||||
password: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'New password must be a string',
|
||||
})
|
||||
newPassword: string;
|
||||
}
|
||||
14
apps/api/src/features/auth/dto/confirm-email.dto.ts
Normal file
14
apps/api/src/features/auth/dto/confirm-email.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class ConfirmEmailDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(6)
|
||||
@MinLength(6)
|
||||
code: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
29
apps/api/src/features/auth/dto/create-user.dto.ts
Normal file
29
apps/api/src/features/auth/dto/create-user.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
username: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
fullname: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Phone must be a string',
|
||||
})
|
||||
@IsOptional()
|
||||
phone: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Password must be a string',
|
||||
})
|
||||
password: string;
|
||||
}
|
||||
10
apps/api/src/features/auth/dto/forgot-password.dto.ts
Normal file
10
apps/api/src/features/auth/dto/forgot-password.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class ForgotPasswordDto {
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Identifier must be a string',
|
||||
})
|
||||
username: string;
|
||||
}
|
||||
14
apps/api/src/features/auth/dto/refresh-token.dto.ts
Normal file
14
apps/api/src/features/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Refresh token must be a string',
|
||||
})
|
||||
refresh_token: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
user_id: number;
|
||||
}
|
||||
22
apps/api/src/features/auth/dto/reset-password.dto.ts
Normal file
22
apps/api/src/features/auth/dto/reset-password.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Identifier must be a string',
|
||||
})
|
||||
username: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Reset Token must be a string',
|
||||
})
|
||||
resetToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'New password must be a string',
|
||||
})
|
||||
newPassword: string;
|
||||
}
|
||||
16
apps/api/src/features/auth/dto/signIn-user.dto.ts
Normal file
16
apps/api/src/features/auth/dto/signIn-user.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class SignInUserDto {
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Identifier must be a string',
|
||||
})
|
||||
username: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Password must be a string',
|
||||
})
|
||||
password: string;
|
||||
}
|
||||
10
apps/api/src/features/auth/dto/signOut-user.dto.ts
Normal file
10
apps/api/src/features/auth/dto/signOut-user.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class SignOutUserDto {
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'User Id must be a string',
|
||||
})
|
||||
user_id: string;
|
||||
}
|
||||
45
apps/api/src/features/auth/dto/signUp-user.dto.ts
Normal file
45
apps/api/src/features/auth/dto/signUp-user.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsInt, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class SingUpUserDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
fullname: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Phone must be a string',
|
||||
})
|
||||
@IsOptional()
|
||||
phone: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Password must be a string',
|
||||
})
|
||||
password: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
state: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
municipality: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
parish: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
role: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { User } from '@/features/users/entities/user.entity';
|
||||
|
||||
export class UpdateRefreshTokenDto {
|
||||
user: User;
|
||||
refresh_token: string;
|
||||
}
|
||||
16
apps/api/src/features/auth/dto/validate-user.dto.ts
Normal file
16
apps/api/src/features/auth/dto/validate-user.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class ValidateUserDto {
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'First name must be a string',
|
||||
})
|
||||
username: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Password must be a string',
|
||||
})
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
interface AuthTokensInterface {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export default AuthTokensInterface;
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface LoginUserInterface {
|
||||
message: string;
|
||||
user: User;
|
||||
tokens: Tokens;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
fullname: string;
|
||||
email?: string;
|
||||
rol: Roles[];
|
||||
}
|
||||
|
||||
export interface Roles {
|
||||
id: number;
|
||||
rol: string;
|
||||
}
|
||||
|
||||
interface Tokens {
|
||||
access_token: string;
|
||||
access_expire_in: number;
|
||||
refresh_token: string;
|
||||
refresh_expire_in: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
interface RefreshTokenInterface {
|
||||
access_token: string;
|
||||
access_expire_in: number;
|
||||
refresh_token: string;
|
||||
refresh_expire_in: number;
|
||||
}
|
||||
|
||||
export default RefreshTokenInterface;
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface Session {
|
||||
userId: string;
|
||||
sessionToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Roles } from '@/common/decorators';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CategoryTypesService } from './category-types.service';
|
||||
import { CreateCategoryTypeDto } from './dto/create-category-type.dto';
|
||||
import { UpdateCategoryTypeDto } from './dto/update-category-type.dto';
|
||||
import { CategoryType } from './entities/category-type.entity';
|
||||
|
||||
@ApiTags('Category Types')
|
||||
@Controller('configurations/category-types')
|
||||
export class CategoryTypesController {
|
||||
constructor(private readonly categoryTypesService: CategoryTypesService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get all category types' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Return all category types',
|
||||
type: [CategoryType],
|
||||
})
|
||||
findAll() {
|
||||
return this.categoryTypesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get a category type by id' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Return a category type',
|
||||
type: CategoryType,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Category type not found' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.categoryTypesService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('group/:group')
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get category types by group' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Return category types by group',
|
||||
type: [CategoryType],
|
||||
})
|
||||
findByGroup(@Param('group') group: string) {
|
||||
return this.categoryTypesService.findByGroup(group);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Create a new category type' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Category type created',
|
||||
type: CategoryType,
|
||||
})
|
||||
create(@Body() createCategoryTypeDto: CreateCategoryTypeDto) {
|
||||
return this.categoryTypesService.create(createCategoryTypeDto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Update a category type' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Category type updated',
|
||||
type: CategoryType,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Category type not found' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateCategoryTypeDto: UpdateCategoryTypeDto,
|
||||
) {
|
||||
return this.categoryTypesService.update(id, updateCategoryTypeDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Delete a category type' })
|
||||
@ApiResponse({ status: 200, description: 'Category type deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Category type not found' })
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.categoryTypesService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DrizzleModule } from '@/database/drizzle.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoryTypesController } from './category-types.controller';
|
||||
import { CategoryTypesService } from './category-types.service';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule],
|
||||
controllers: [CategoryTypesController],
|
||||
providers: [CategoryTypesService],
|
||||
exports: [CategoryTypesService],
|
||||
})
|
||||
export class CategoryTypesModule {}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
|
||||
import * as schema from '@/database/index';
|
||||
import { categoryType } from '@/database/schema/general';
|
||||
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { CreateCategoryTypeDto } from './dto/create-category-type.dto';
|
||||
import { UpdateCategoryTypeDto } from './dto/update-category-type.dto';
|
||||
import { CategoryType } from './entities/category-type.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CategoryTypesService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<CategoryType[]> {
|
||||
return await this.drizzle.select().from(categoryType);
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<CategoryType> {
|
||||
const category = await this.drizzle
|
||||
.select()
|
||||
.from(categoryType)
|
||||
.where(eq(categoryType.id, id));
|
||||
|
||||
if (category.length === 0) {
|
||||
throw new HttpException('Category type not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return category[0];
|
||||
}
|
||||
|
||||
async findByGroup(group: string): Promise<CategoryType[]> {
|
||||
return await this.drizzle
|
||||
.select()
|
||||
.from(categoryType)
|
||||
.where(eq(categoryType.group, group));
|
||||
}
|
||||
|
||||
async create(
|
||||
createCategoryTypeDto: CreateCategoryTypeDto,
|
||||
): Promise<CategoryType> {
|
||||
const [category] = await this.drizzle
|
||||
.insert(categoryType)
|
||||
.values({
|
||||
group: createCategoryTypeDto.group,
|
||||
description: createCategoryTypeDto.description,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
updateCategoryTypeDto: UpdateCategoryTypeDto,
|
||||
): Promise<CategoryType> {
|
||||
// Check if category type exists
|
||||
await this.findOne(id);
|
||||
|
||||
await this.drizzle
|
||||
.update(categoryType)
|
||||
.set({
|
||||
group: updateCategoryTypeDto.group,
|
||||
description: updateCategoryTypeDto.description,
|
||||
})
|
||||
.where(eq(categoryType.id, id));
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<{ message: string }> {
|
||||
// Check if category type exists
|
||||
await this.findOne(id);
|
||||
|
||||
await this.drizzle.delete(categoryType).where(eq(categoryType.id, id));
|
||||
|
||||
return { message: 'Category type deleted successfully' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateCategoryTypeDto {
|
||||
@ApiProperty({
|
||||
description: 'The group of the category type',
|
||||
example: 'PAYROLL_TYPE',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
group: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The description of the category type',
|
||||
example: 'Quincenal',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCategoryTypeDto } from './create-category-type.dto';
|
||||
|
||||
export class UpdateCategoryTypeDto extends PartialType(CreateCategoryTypeDto) {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CategoryType {
|
||||
@ApiProperty({
|
||||
description: 'The unique identifier of the category type',
|
||||
example: 1,
|
||||
})
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The group of the category type',
|
||||
example: 'PAYROLL_TYPE',
|
||||
})
|
||||
group: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The description of the category type',
|
||||
example: 'Quincenal',
|
||||
})
|
||||
description: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The date when the category type was created',
|
||||
example: '2023-01-01T00:00:00.000Z',
|
||||
})
|
||||
created_at?: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The date when the category type was last updated',
|
||||
example: '2023-01-01T00:00:00.000Z',
|
||||
})
|
||||
updated_at?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoryTypesModule } from './category-types/category-types.module';
|
||||
import { MunicipalitiesModule } from './municipalities/municipalities.module';
|
||||
import { ParishesModule } from './parishes/parishes.module';
|
||||
import { StatesModule } from './states/states.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
StatesModule,
|
||||
MunicipalitiesModule,
|
||||
ParishesModule,
|
||||
CategoryTypesModule,
|
||||
],
|
||||
})
|
||||
export class ConfigurationsModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class CreateMunicipalityDto {
|
||||
@ApiProperty({
|
||||
description: 'The name of the municipality',
|
||||
example: 'Los Angeles',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The ID of the state this municipality belongs to',
|
||||
example: 1,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
stateId: number;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateMunicipalityDto } from './create-municipality.dto';
|
||||
|
||||
export class UpdateMunicipalityDto extends PartialType(CreateMunicipalityDto) {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class Municipality {
|
||||
@ApiProperty({
|
||||
description: 'The unique identifier of the municipality',
|
||||
example: 1,
|
||||
})
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The name of the municipality',
|
||||
example: 'Los Angeles',
|
||||
})
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The ID of the state this municipality belongs to',
|
||||
example: 1,
|
||||
})
|
||||
stateId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The name of the state this municipality belongs to',
|
||||
example: 'California',
|
||||
})
|
||||
stateName?: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The date when the municipality was created',
|
||||
example: '2023-01-01T00:00:00.000Z',
|
||||
})
|
||||
created_at?: Date | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The date when the municipality was last updated',
|
||||
example: '2023-01-01T00:00:00.000Z',
|
||||
})
|
||||
updated_at?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Roles } from '@/common/decorators';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateMunicipalityDto } from './dto/create-municipality.dto';
|
||||
import { UpdateMunicipalityDto } from './dto/update-municipality.dto';
|
||||
import { Municipality } from './entities/municipality.entity';
|
||||
import { MunicipalitiesService } from './municipalities.service';
|
||||
|
||||
@ApiTags('Municipalities')
|
||||
@Controller('configurations/municipalities')
|
||||
export class MunicipalitiesController {
|
||||
constructor(private readonly municipalitiesService: MunicipalitiesService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get all municipalities' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Return all municipalities',
|
||||
type: [Municipality],
|
||||
})
|
||||
findAll() {
|
||||
return this.municipalitiesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get a municipality by id' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Return a municipality',
|
||||
type: Municipality,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Municipality not found' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.municipalitiesService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('state/:stateId')
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get municipalities by state id' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Return municipalities by state',
|
||||
type: [Municipality],
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'State not found' })
|
||||
findByState(@Param('stateId', ParseIntPipe) stateId: number) {
|
||||
return this.municipalitiesService.findByState(stateId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Create a new municipality' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Municipality created',
|
||||
type: Municipality,
|
||||
})
|
||||
create(@Body() createMunicipalityDto: CreateMunicipalityDto) {
|
||||
return this.municipalitiesService.create(createMunicipalityDto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Update a municipality' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Municipality updated',
|
||||
type: Municipality,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Municipality not found' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateMunicipalityDto: UpdateMunicipalityDto,
|
||||
) {
|
||||
return this.municipalitiesService.update(id, updateMunicipalityDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Delete a municipality' })
|
||||
@ApiResponse({ status: 200, description: 'Municipality deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Municipality not found' })
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.municipalitiesService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { DrizzleModule } from '@/database/drizzle.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StatesModule } from '../states/states.module';
|
||||
import { MunicipalitiesController } from './municipalities.controller';
|
||||
import { MunicipalitiesService } from './municipalities.service';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule, StatesModule],
|
||||
controllers: [MunicipalitiesController],
|
||||
providers: [MunicipalitiesService],
|
||||
exports: [MunicipalitiesService],
|
||||
})
|
||||
export class MunicipalitiesModule {}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
|
||||
import * as schema from '@/database/index';
|
||||
import { municipalities, states } from '@/database/schema/general';
|
||||
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { StatesService } from '../states/states.service';
|
||||
import { CreateMunicipalityDto } from './dto/create-municipality.dto';
|
||||
import { UpdateMunicipalityDto } from './dto/update-municipality.dto';
|
||||
import { Municipality } from './entities/municipality.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MunicipalitiesService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
private statesService: StatesService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Municipality[]> {
|
||||
return await this.drizzle
|
||||
.select({
|
||||
id: municipalities.id,
|
||||
name: municipalities.name,
|
||||
stateId: municipalities.stateId,
|
||||
stateName: states.name,
|
||||
created_at: municipalities.created_at,
|
||||
updated_at: municipalities.updated_at,
|
||||
})
|
||||
.from(municipalities)
|
||||
.leftJoin(states, eq(municipalities.stateId, states.id));
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<Municipality> {
|
||||
const municipality = await this.drizzle
|
||||
.select({
|
||||
id: municipalities.id,
|
||||
name: municipalities.name,
|
||||
stateId: municipalities.stateId,
|
||||
stateName: states.name,
|
||||
created_at: municipalities.created_at,
|
||||
updated_at: municipalities.updated_at,
|
||||
})
|
||||
.from(municipalities)
|
||||
.leftJoin(states, eq(municipalities.stateId, states.id))
|
||||
.where(eq(municipalities.id, id));
|
||||
|
||||
if (municipality.length === 0) {
|
||||
throw new HttpException('Municipality not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return municipality[0];
|
||||
}
|
||||
|
||||
async findByState(stateId: number): Promise<Municipality[]> {
|
||||
// Verify state exists
|
||||
await this.statesService.findOne(stateId);
|
||||
|
||||
return await this.drizzle
|
||||
.select({
|
||||
id: municipalities.id,
|
||||
name: municipalities.name,
|
||||
stateId: municipalities.stateId,
|
||||
stateName: states.name,
|
||||
created_at: municipalities.created_at,
|
||||
updated_at: municipalities.updated_at,
|
||||
})
|
||||
.from(municipalities)
|
||||
.leftJoin(states, eq(municipalities.stateId, states.id))
|
||||
.where(eq(municipalities.stateId, stateId));
|
||||
}
|
||||
|
||||
async create(
|
||||
createMunicipalityDto: CreateMunicipalityDto,
|
||||
): Promise<Municipality> {
|
||||
// Verify state exists
|
||||
await this.statesService.findOne(createMunicipalityDto.stateId);
|
||||
|
||||
const [municipality] = await this.drizzle
|
||||
.insert(municipalities)
|
||||
.values({
|
||||
name: createMunicipalityDto.name,
|
||||
stateId: createMunicipalityDto.stateId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return this.findOne(municipality.id);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
updateMunicipalityDto: UpdateMunicipalityDto,
|
||||
): Promise<Municipality> {
|
||||
// Check if municipality exists
|
||||
await this.findOne(id);
|
||||
|
||||
// If stateId is provided, verify it exists
|
||||
if (updateMunicipalityDto.stateId) {
|
||||
await this.statesService.findOne(updateMunicipalityDto.stateId);
|
||||
}
|
||||
|
||||
await this.drizzle
|
||||
.update(municipalities)
|
||||
.set({
|
||||
name: updateMunicipalityDto.name,
|
||||
stateId: updateMunicipalityDto.stateId,
|
||||
})
|
||||
.where(eq(municipalities.id, id));
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<{ message: string }> {
|
||||
// Check if municipality exists
|
||||
await this.findOne(id);
|
||||
|
||||
await this.drizzle.delete(municipalities).where(eq(municipalities.id, id));
|
||||
|
||||
return { message: 'Municipality deleted successfully' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class CreateParishDto {
|
||||
@ApiProperty({
|
||||
description: 'The name of the parish',
|
||||
example: 'Downtown',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The ID of the municipality this parish belongs to',
|
||||
example: 1,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
municipalityId: number;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateParishDto } from './create-parish.dto';
|
||||
|
||||
export class UpdateParishDto extends PartialType(CreateParishDto) {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class Parish {
|
||||
@ApiProperty({
|
||||
description: 'The unique identifier of the parish',
|
||||
example: 1,
|
||||
})
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The name of the parish',
|
||||
example: 'Downtown',
|
||||
})
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The ID of the municipality this parish belongs to',
|
||||
example: 1,
|
||||
})
|
||||
municipalityId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The name of the municipality this parish belongs to',
|
||||
example: 'Los Angeles',
|
||||
})
|
||||
municipalityName?: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The date when the parish was created',
|
||||
example: '2023-01-01T00:00:00.000Z',
|
||||
})
|
||||
created_at?: Date | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The date when the parish was last updated',
|
||||
example: '2023-01-01T00:00:00.000Z',
|
||||
})
|
||||
updated_at: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Roles } from '@/common/decorators';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateParishDto } from './dto/create-parish.dto';
|
||||
import { UpdateParishDto } from './dto/update-parish.dto';
|
||||
import { Parish } from './entities/parish.entity';
|
||||
import { ParishesService } from './parishes.service';
|
||||
|
||||
@ApiTags('Parishes')
|
||||
@Controller('configurations/parishes')
|
||||
export class ParishesController {
|
||||
constructor(private readonly parishesService: ParishesService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get all parishes' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Return all parishes',
|
||||
type: [Parish],
|
||||
})
|
||||
findAll() {
|
||||
return this.parishesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get a parish by id' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Return a parish',
|
||||
type: Parish,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Parish not found' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.parishesService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('municipality/:municipalityId')
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get parishes by municipality id' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Return parishes by municipality',
|
||||
type: [Parish],
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Municipality not found' })
|
||||
findByMunicipality(
|
||||
@Param('municipalityId', ParseIntPipe) municipalityId: number,
|
||||
) {
|
||||
return this.parishesService.findByMunicipality(municipalityId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Create a new parish' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Parish created',
|
||||
type: Parish,
|
||||
})
|
||||
create(@Body() createParishDto: CreateParishDto) {
|
||||
return this.parishesService.create(createParishDto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Update a parish' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Parish updated',
|
||||
type: Parish,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Parish not found' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateParishDto: UpdateParishDto,
|
||||
) {
|
||||
return this.parishesService.update(id, updateParishDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Delete a parish' })
|
||||
@ApiResponse({ status: 200, description: 'Parish deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Parish not found' })
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.parishesService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { DrizzleModule } from '@/database/drizzle.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MunicipalitiesModule } from '../municipalities/municipalities.module';
|
||||
import { ParishesController } from './parishes.controller';
|
||||
import { ParishesService } from './parishes.service';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule, MunicipalitiesModule],
|
||||
controllers: [ParishesController],
|
||||
providers: [ParishesService],
|
||||
exports: [ParishesService],
|
||||
})
|
||||
export class ParishesModule {}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
|
||||
import * as schema from '@/database/index';
|
||||
import { municipalities, parishes } from '@/database/schema/general';
|
||||
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { MunicipalitiesService } from '../municipalities/municipalities.service';
|
||||
import { CreateParishDto } from './dto/create-parish.dto';
|
||||
import { UpdateParishDto } from './dto/update-parish.dto';
|
||||
import { Parish } from './entities/parish.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ParishesService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
private municipalitiesService: MunicipalitiesService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Parish[]> {
|
||||
return await this.drizzle
|
||||
.select({
|
||||
id: parishes.id,
|
||||
name: parishes.name,
|
||||
municipalityId: parishes.municipalityId,
|
||||
municipalityName: municipalities.name,
|
||||
created_at: parishes.created_at,
|
||||
updated_at: parishes.updated_at,
|
||||
})
|
||||
.from(parishes)
|
||||
.leftJoin(municipalities, eq(parishes.municipalityId, municipalities.id));
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<Parish> {
|
||||
const parish = await this.drizzle
|
||||
.select({
|
||||
id: parishes.id,
|
||||
name: parishes.name,
|
||||
municipalityId: parishes.municipalityId,
|
||||
municipalityName: municipalities.name,
|
||||
created_at: parishes.created_at,
|
||||
updated_at: parishes.updated_at,
|
||||
})
|
||||
.from(parishes)
|
||||
.leftJoin(municipalities, eq(parishes.municipalityId, municipalities.id))
|
||||
.where(eq(parishes.id, id));
|
||||
|
||||
if (parish.length === 0) {
|
||||
throw new HttpException('Parish not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return parish[0];
|
||||
}
|
||||
|
||||
async findByMunicipality(municipalityId: number): Promise<Parish[]> {
|
||||
// Verify municipality exists
|
||||
await this.municipalitiesService.findOne(municipalityId);
|
||||
|
||||
return await this.drizzle
|
||||
.select({
|
||||
id: parishes.id,
|
||||
name: parishes.name,
|
||||
municipalityId: parishes.municipalityId,
|
||||
municipalityName: municipalities.name,
|
||||
created_at: parishes.created_at,
|
||||
updated_at: parishes.updated_at,
|
||||
})
|
||||
.from(parishes)
|
||||
.leftJoin(municipalities, eq(parishes.municipalityId, municipalities.id))
|
||||
.where(eq(parishes.municipalityId, municipalityId));
|
||||
}
|
||||
|
||||
async create(createParishDto: CreateParishDto): Promise<Parish> {
|
||||
// Verify municipality exists
|
||||
await this.municipalitiesService.findOne(createParishDto.municipalityId);
|
||||
|
||||
const [parish] = await this.drizzle
|
||||
.insert(parishes)
|
||||
.values({
|
||||
name: createParishDto.name,
|
||||
municipalityId: createParishDto.municipalityId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return this.findOne(parish.id);
|
||||
}
|
||||
|
||||
async update(id: number, updateParishDto: UpdateParishDto): Promise<Parish> {
|
||||
// Check if parish exists
|
||||
await this.findOne(id);
|
||||
|
||||
// If municipalityId is provided, verify it exists
|
||||
if (updateParishDto.municipalityId) {
|
||||
await this.municipalitiesService.findOne(updateParishDto.municipalityId);
|
||||
}
|
||||
|
||||
await this.drizzle
|
||||
.update(parishes)
|
||||
.set({
|
||||
name: updateParishDto.name,
|
||||
municipalityId: updateParishDto.municipalityId,
|
||||
})
|
||||
.where(eq(parishes.id, id));
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<{ message: string }> {
|
||||
// Check if parish exists
|
||||
await this.findOne(id);
|
||||
|
||||
await this.drizzle.delete(parishes).where(eq(parishes.id, id));
|
||||
|
||||
return { message: 'Parish deleted successfully' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateStateDto {
|
||||
@ApiProperty({
|
||||
description: 'The name of the state',
|
||||
example: 'California',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateStateDto } from './create-state.dto';
|
||||
|
||||
export class UpdateStateDto extends PartialType(CreateStateDto) {}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class State {
|
||||
@ApiProperty({
|
||||
description: 'The unique identifier of the state',
|
||||
example: 1,
|
||||
})
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The name of the state',
|
||||
example: 'California',
|
||||
})
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The date when the state was created',
|
||||
example: '2023-01-01T00:00:00.000Z',
|
||||
})
|
||||
created_at?: Date | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'The date when the state was last updated',
|
||||
example: '2023-01-01T00:00:00.000Z',
|
||||
})
|
||||
updated_at?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Roles } from '@/common/decorators';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { UpdateStateDto } from './dto//update-state.dto';
|
||||
import { CreateStateDto } from './dto/create-state.dto';
|
||||
import { State } from './entities/state.entity';
|
||||
import { StatesService } from './states.service';
|
||||
|
||||
@ApiTags('States')
|
||||
@Controller('configurations/states')
|
||||
export class StatesController {
|
||||
constructor(private readonly statesService: StatesService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get all states' })
|
||||
@ApiResponse({ status: 200, description: 'Return all states', type: [State] })
|
||||
findAll() {
|
||||
return this.statesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('admin', 'user')
|
||||
@ApiOperation({ summary: 'Get a state by id' })
|
||||
@ApiResponse({ status: 200, description: 'Return a state', type: State })
|
||||
@ApiResponse({ status: 404, description: 'State not found' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.statesService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Create a new state' })
|
||||
@ApiResponse({ status: 201, description: 'State created', type: State })
|
||||
create(@Body() createStateDto: CreateStateDto) {
|
||||
return this.statesService.create(createStateDto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Update a state' })
|
||||
@ApiResponse({ status: 200, description: 'State updated', type: State })
|
||||
@ApiResponse({ status: 404, description: 'State not found' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateStateDto: UpdateStateDto,
|
||||
) {
|
||||
return this.statesService.update(id, updateStateDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Delete a state' })
|
||||
@ApiResponse({ status: 200, description: 'State deleted' })
|
||||
@ApiResponse({ status: 404, description: 'State not found' })
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.statesService.remove(id);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/features/configurations/states/states.module.ts
Normal file
12
apps/api/src/features/configurations/states/states.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DrizzleModule } from '@/database/drizzle.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StatesController } from './states.controller';
|
||||
import { StatesService } from './states.service';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule],
|
||||
controllers: [StatesController],
|
||||
providers: [StatesService],
|
||||
exports: [StatesService],
|
||||
})
|
||||
export class StatesModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
|
||||
import * as schema from '@/database/index';
|
||||
import { states } from '@/database/schema/general';
|
||||
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { CreateStateDto } from './dto/create-state.dto';
|
||||
import { UpdateStateDto } from './dto/update-state.dto';
|
||||
import { State } from './entities/state.entity';
|
||||
|
||||
@Injectable()
|
||||
export class StatesService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<State[]> {
|
||||
return await this.drizzle.select().from(states);
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<State> {
|
||||
const state = await this.drizzle
|
||||
.select()
|
||||
.from(states)
|
||||
.where(eq(states.id, id));
|
||||
|
||||
if (state.length === 0) {
|
||||
throw new HttpException('State not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return state[0];
|
||||
}
|
||||
|
||||
async create(createStateDto: CreateStateDto): Promise<State> {
|
||||
const [state] = await this.drizzle
|
||||
.insert(states)
|
||||
.values({
|
||||
name: createStateDto.name,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async update(id: number, updateStateDto: UpdateStateDto): Promise<State> {
|
||||
// Check if state exists
|
||||
await this.findOne(id);
|
||||
|
||||
await this.drizzle
|
||||
.update(states)
|
||||
.set({
|
||||
name: updateStateDto.name,
|
||||
})
|
||||
.where(eq(states.id, id));
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<{ message: string }> {
|
||||
// Check if state exists
|
||||
await this.findOne(id);
|
||||
|
||||
await this.drizzle.delete(states).where(eq(states.id, id));
|
||||
|
||||
return { message: 'State deleted successfully' };
|
||||
}
|
||||
}
|
||||
16
apps/api/src/features/location/entities/user.entity.ts
Normal file
16
apps/api/src/features/location/entities/user.entity.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export class State {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class Municipality {
|
||||
id: number;
|
||||
stateId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class Parish {
|
||||
id: number;
|
||||
municipalityId: number;
|
||||
name: string;
|
||||
}
|
||||
41
apps/api/src/features/location/location.controller.ts
Normal file
41
apps/api/src/features/location/location.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common';
|
||||
import { UsersService } from './location.service';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '@/common/decorators';
|
||||
// import { Roles } from '../../common/decorators/roles.decorator';
|
||||
// import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
@Public()
|
||||
@ApiTags('location')
|
||||
@Controller('location')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get("state")
|
||||
// @Roles('admin')
|
||||
// @ApiOperation({ summary: 'Get all users with pagination and filters' })
|
||||
// @ApiResponse({ status: 200, description: 'Return paginated users.' })
|
||||
async findState() {
|
||||
const data = await this.usersService.StateAll();
|
||||
return { message: 'Data fetched successfully', data};
|
||||
}
|
||||
|
||||
@Get('municipality/:id')
|
||||
// @Roles('admin')
|
||||
// @ApiOperation({ summary: 'Get a user by ID' })
|
||||
// @ApiResponse({ status: 200, description: 'Return the user.' })
|
||||
// @ApiResponse({ status: 404, description: 'User not found.' })
|
||||
async findMunicipality(@Param('id') id: string) {
|
||||
const data = await this.usersService.MunicioalityAll(id);
|
||||
return { message: 'Data fetched successfully', data };
|
||||
}
|
||||
|
||||
@Get('parish/:id')
|
||||
// @Roles('admin')
|
||||
// @ApiOperation({ summary: 'Get a user by ID' })
|
||||
// @ApiResponse({ status: 200, description: 'Return the user.' })
|
||||
// @ApiResponse({ status: 404, description: 'User not found.' })
|
||||
async findParish(@Param('id') id: string) {
|
||||
const data = await this.usersService.ParishAll(id);
|
||||
return { message: 'Data fetched successfully', data };
|
||||
}
|
||||
}
|
||||
11
apps/api/src/features/location/location.module.ts
Normal file
11
apps/api/src/features/location/location.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './location.controller';
|
||||
import { UsersService } from './location.service';
|
||||
import { DrizzleModule } from '@/database/drizzle.module';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
})
|
||||
export class LocationModule {}
|
||||
44
apps/api/src/features/location/location.service.ts
Normal file
44
apps/api/src/features/location/location.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
|
||||
// import { Env, validateString } from '@/common/utils';
|
||||
import { Inject, Injectable, HttpException, HttpStatus, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from 'src/database/index';
|
||||
import { states, municipalities, parishes } from 'src/database/index';
|
||||
import { eq, like, or, SQL, sql, and, not } from 'drizzle-orm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { State, Municipality, Parish } from './entities/user.entity';
|
||||
// import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
) { }
|
||||
|
||||
async StateAll(): Promise< State[]> {
|
||||
const find = await this.drizzle
|
||||
.select()
|
||||
.from(states)
|
||||
|
||||
return find;
|
||||
}
|
||||
|
||||
async MunicioalityAll(id: string): Promise< Municipality[]> {
|
||||
const find = await this.drizzle
|
||||
.select()
|
||||
.from(municipalities)
|
||||
.where(eq(municipalities.stateId, parseInt(id)));
|
||||
|
||||
return find;
|
||||
}
|
||||
|
||||
async ParishAll(id: string): Promise< Parish[]> {
|
||||
const find = await this.drizzle
|
||||
.select()
|
||||
.from(parishes)
|
||||
.where(eq(parishes.municipalityId, parseInt(id)));
|
||||
|
||||
return find;
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/api/src/features/mail/mail.module.ts
Normal file
11
apps/api/src/features/mail/mail.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
@Module({
|
||||
imports: [MailerModule, ConfigModule],
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
18
apps/api/src/features/mail/mail.service.spec.ts
Normal file
18
apps/api/src/features/mail/mail.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
describe('MailService', () => {
|
||||
let service: MailService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [MailService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MailService>(MailService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
18
apps/api/src/features/mail/mail.service.ts
Normal file
18
apps/api/src/features/mail/mail.service.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async sendEmail(mailOptions: ISendMailOptions): Promise<void> {
|
||||
await this.mailerService.sendMail({
|
||||
from: `Turbo NPN <${this.config.get('MAIL_USERNAME')}>`,
|
||||
...mailOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
177
apps/api/src/features/mail/templates/change-password.mail.ts
Normal file
177
apps/api/src/features/mail/templates/change-password.mail.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
const ChangePasswordMail = ({ name }: { name: string }) => {
|
||||
return `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<div
|
||||
style="
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
line-height: 1px;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
"
|
||||
>
|
||||
Join Turbo NPN
|
||||
</div>
|
||||
<body
|
||||
style="
|
||||
background-color: rgb(255, 255, 255);
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(234, 234, 234);
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 20px;
|
||||
max-width: 465px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="margin-top: 32px"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
alt="Vercel"
|
||||
height="37"
|
||||
src="https://react-email-demo-2jmpohtxd-resend.vercel.app/static/vercel-logo.png"
|
||||
style="
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
"
|
||||
width="40"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h1
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
padding: 0px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
"
|
||||
>
|
||||
<strong>Turbo NPN</strong>
|
||||
</h1>
|
||||
<p
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
"
|
||||
>
|
||||
Hello, ${name}
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
"
|
||||
>
|
||||
Turbo NPN send your sign in notification email
|
||||
</p>
|
||||
<div style="display: flex;justify-content: center; align-items: center;">
|
||||
<p
|
||||
style="
|
||||
background-color: rgb(0, 0, 0);
|
||||
border-radius: 0.25rem;
|
||||
color: rgb(255, 255, 255);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
line-height: 100%;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
New Sign In Detected
|
||||
</p>
|
||||
</div>
|
||||
<hr
|
||||
style="
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(234, 234, 234);
|
||||
margin-top: 26px;
|
||||
margin-bottom: 26px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top: 1px solid #eaeaea;
|
||||
"
|
||||
/>
|
||||
<p
|
||||
style="
|
||||
color: rgb(102, 102, 102);
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
Turbo NPN is a free and open source prodcut
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
export default ChangePasswordMail;
|
||||
177
apps/api/src/features/mail/templates/confirm-email.mail.ts
Normal file
177
apps/api/src/features/mail/templates/confirm-email.mail.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
const SignInMail = ({ name }: { name: string }) => {
|
||||
return `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<div
|
||||
style="
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
line-height: 1px;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
"
|
||||
>
|
||||
Join Turbo NPN
|
||||
</div>
|
||||
<body
|
||||
style="
|
||||
background-color: rgb(255, 255, 255);
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(234, 234, 234);
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 20px;
|
||||
max-width: 465px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="margin-top: 32px"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
alt="Vercel"
|
||||
height="37"
|
||||
src="https://react-email-demo-2jmpohtxd-resend.vercel.app/static/vercel-logo.png"
|
||||
style="
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
"
|
||||
width="40"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h1
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
padding: 0px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
"
|
||||
>
|
||||
<strong>Turbo NPN</strong>
|
||||
</h1>
|
||||
<p
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
"
|
||||
>
|
||||
Hello, ${name}
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
"
|
||||
>
|
||||
Turbo NPN send your email verification success message
|
||||
</p>
|
||||
<div style="display: flex;justify-content: center; align-items: center;">
|
||||
<p
|
||||
style="
|
||||
background-color: rgb(0, 0, 0);
|
||||
border-radius: 0.25rem;
|
||||
color: rgb(255, 255, 255);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
line-height: 100%;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
Success
|
||||
</p>
|
||||
</div>
|
||||
<hr
|
||||
style="
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(234, 234, 234);
|
||||
margin-top: 26px;
|
||||
margin-bottom: 26px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top: 1px solid #eaeaea;
|
||||
"
|
||||
/>
|
||||
<p
|
||||
style="
|
||||
color: rgb(102, 102, 102);
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
Turbo NPN is a free and open source prodcut
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
export default SignInMail;
|
||||
183
apps/api/src/features/mail/templates/forgot-password.mail.ts
Normal file
183
apps/api/src/features/mail/templates/forgot-password.mail.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
const ForgotPasswordMail = ({
|
||||
name,
|
||||
code,
|
||||
}: {
|
||||
name: string;
|
||||
code: string | number;
|
||||
}) => {
|
||||
return `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<div
|
||||
style="
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
line-height: 1px;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
"
|
||||
>
|
||||
Join Turbo NPN
|
||||
</div>
|
||||
<body
|
||||
style="
|
||||
background-color: rgb(255, 255, 255);
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(234, 234, 234);
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 20px;
|
||||
max-width: 465px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="margin-top: 32px"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
alt="Vercel"
|
||||
height="37"
|
||||
src="https://react-email-demo-2jmpohtxd-resend.vercel.app/static/vercel-logo.png"
|
||||
style="
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
"
|
||||
width="40"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h1
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
padding: 0px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
"
|
||||
>
|
||||
<strong>Turbo NPN</strong>
|
||||
</h1>
|
||||
<p
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
"
|
||||
>
|
||||
Hello, ${name}
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
"
|
||||
>
|
||||
Turbo NPN send your password reset code
|
||||
</p>
|
||||
<div style="display: flex;justify-content: center; align-items: center;">
|
||||
<p
|
||||
style="
|
||||
background-color: rgb(0, 0, 0);
|
||||
border-radius: 0.25rem;
|
||||
color: rgb(255, 255, 255);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
line-height: 100%;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
${code}
|
||||
</p>
|
||||
</div>
|
||||
<hr
|
||||
style="
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(234, 234, 234);
|
||||
margin-top: 26px;
|
||||
margin-bottom: 26px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top: 1px solid #eaeaea;
|
||||
"
|
||||
/>
|
||||
<p
|
||||
style="
|
||||
color: rgb(102, 102, 102);
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
Turbo NPN is a free and open source prodcut
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
export default ForgotPasswordMail;
|
||||
195
apps/api/src/features/mail/templates/index.ts
Normal file
195
apps/api/src/features/mail/templates/index.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
const EmailTemplate = ({
|
||||
name = '',
|
||||
action = 'sign in',
|
||||
timestamp = new Date().toLocaleString(),
|
||||
location = 'Unknown',
|
||||
device = 'Unknown Device',
|
||||
ipAddress = 'Unknown IP',
|
||||
}) => {
|
||||
return `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<div style="display: none; overflow: hidden; line-height: 1px; opacity: 0; max-height: 0; max-width: 0;">
|
||||
Security Alert - New ${action} detected for your Turbo NPN account
|
||||
</div>
|
||||
<body style="
|
||||
background-color: #f5f5f5;
|
||||
margin: 0 auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
padding: 1rem;
|
||||
">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 8px;
|
||||
margin: 40px auto;
|
||||
padding: 30px;
|
||||
max-width: 520px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
alt="Turbo NPN Logo"
|
||||
height="45"
|
||||
src="https://your-logo-url.com/logo.png"
|
||||
style="
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
"
|
||||
width="48"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h1 style="
|
||||
color: #000000;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
">
|
||||
Security Alert
|
||||
</h1>
|
||||
|
||||
<p style="
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
">
|
||||
Hello ${name},
|
||||
</p>
|
||||
|
||||
<p style="
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
">
|
||||
We detected a new ${action} to your Turbo NPN account. Here are the details:
|
||||
</p>
|
||||
|
||||
<div style="
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 24px 0;
|
||||
">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 12px 0;">
|
||||
<tr>
|
||||
<td style="color: #666666; font-size: 14px; padding: 8px 0;">Time:</td>
|
||||
<td style="color: #333333; font-size: 14px; text-align: right;">${timestamp}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color: #666666; font-size: 14px; padding: 8px 0;">Location:</td>
|
||||
<td style="color: #333333; font-size: 14px; text-align: right;">${location}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color: #666666; font-size: 14px; padding: 8px 0;">Device:</td>
|
||||
<td style="color: #333333; font-size: 14px; text-align: right;">${device}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color: #666666; font-size: 14px; padding: 8px 0;">IP Address:</td>
|
||||
<td style="color: #333333; font-size: 14px; text-align: right;">${ipAddress}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="https://turbo-npn.com/security-settings" style="
|
||||
background-color: #000000;
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
">
|
||||
Review Security Settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="
|
||||
color: #666666;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 24px 0;
|
||||
">
|
||||
If you don't recognize this activity, please <a href="https://turbo-npn.com/support" style="color: #000000; text-decoration: underline;">contact our support team</a> immediately and change your password.
|
||||
</p>
|
||||
|
||||
<hr style="
|
||||
border: none;
|
||||
border-top: 1px solid #eaeaea;
|
||||
margin: 26px 0;
|
||||
width: 100%;
|
||||
" />
|
||||
|
||||
<div style="text-align: center;">
|
||||
<p style="
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
">
|
||||
Turbo NPN - Secure, Fast, Reliable
|
||||
</p>
|
||||
|
||||
<div style="margin: 20px 0;">
|
||||
<a href="https://twitter.com/turbonpn" style="text-decoration: none; margin: 0 8px;">
|
||||
<img src="https://your-assets.com/twitter.png" alt="Twitter" width="20" height="20" />
|
||||
</a>
|
||||
<a href="https://facebook.com/turbonpn" style="text-decoration: none; margin: 0 8px;">
|
||||
<img src="https://your-assets.com/facebook.png" alt="Facebook" width="20" height="20" />
|
||||
</a>
|
||||
<a href="https://linkedin.com/company/turbonpn" style="text-decoration: none; margin: 0 8px;">
|
||||
<img src="https://your-assets.com/linkedin.png" alt="LinkedIn" width="20" height="20" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
">
|
||||
© 2025 Turbo NPN. All rights reserved.
|
||||
</p>
|
||||
|
||||
<p style="
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
">
|
||||
<a href="https://turbo-npn.com/privacy" style="color: #666666; text-decoration: underline;">Privacy Policy</a> •
|
||||
<a href="https://turbo-npn.com/terms" style="color: #666666; text-decoration: underline;">Terms of Service</a> •
|
||||
<a href="https://turbo-npn.com/unsubscribe" style="color: #666666; text-decoration: underline;">Unsubscribe</a>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
export default EmailTemplate;
|
||||
183
apps/api/src/features/mail/templates/register.mail.ts
Normal file
183
apps/api/src/features/mail/templates/register.mail.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
const RegisterMail = ({
|
||||
name,
|
||||
code,
|
||||
}: {
|
||||
name: string;
|
||||
code: string | number;
|
||||
}) => {
|
||||
return `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<div
|
||||
style="
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
line-height: 1px;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
"
|
||||
>
|
||||
Join Turbo NPN
|
||||
</div>
|
||||
<body
|
||||
style="
|
||||
background-color: rgb(255, 255, 255);
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(234, 234, 234);
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 20px;
|
||||
max-width: 465px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="margin-top: 32px"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
alt="Vercel"
|
||||
height="37"
|
||||
src="https://react-email-demo-2jmpohtxd-resend.vercel.app/static/vercel-logo.png"
|
||||
style="
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
"
|
||||
width="40"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h1
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
padding: 0px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
"
|
||||
>
|
||||
Join <strong>Turbo NPN</strong>
|
||||
</h1>
|
||||
<p
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
"
|
||||
>
|
||||
Hello, ${name}
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
"
|
||||
>
|
||||
Turbo NPN send your email verification code
|
||||
</p>
|
||||
<div style="display: flex;justify-content: center; align-items: center;">
|
||||
<p
|
||||
style="
|
||||
background-color: rgb(0, 0, 0);
|
||||
border-radius: 0.25rem;
|
||||
color: rgb(255, 255, 255);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
line-height: 100%;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
${code}
|
||||
</p>
|
||||
</div>
|
||||
<hr
|
||||
style="
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(234, 234, 234);
|
||||
margin-top: 26px;
|
||||
margin-bottom: 26px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top: 1px solid #eaeaea;
|
||||
"
|
||||
/>
|
||||
<p
|
||||
style="
|
||||
color: rgb(102, 102, 102);
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
Turbo NPN is a free and open source prodcut
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
export default RegisterMail;
|
||||
177
apps/api/src/features/mail/templates/sign-in.mail.ts
Normal file
177
apps/api/src/features/mail/templates/sign-in.mail.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
const SignInMail = ({ name }: { name: string }) => {
|
||||
return `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<div
|
||||
style="
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
line-height: 1px;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
"
|
||||
>
|
||||
Join Turbo NPN
|
||||
</div>
|
||||
<body
|
||||
style="
|
||||
background-color: rgb(255, 255, 255);
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(234, 234, 234);
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 20px;
|
||||
max-width: 465px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="margin-top: 32px"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
alt="Vercel"
|
||||
height="37"
|
||||
src="https://react-email-demo-2jmpohtxd-resend.vercel.app/static/vercel-logo.png"
|
||||
style="
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
"
|
||||
width="40"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h1
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
padding: 0px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
"
|
||||
>
|
||||
<strong>Turbo NPN</strong>
|
||||
</h1>
|
||||
<p
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
"
|
||||
>
|
||||
Hello, ${name}
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
color: rgb(0, 0, 0);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
"
|
||||
>
|
||||
Turbo NPN send your sign in notification email
|
||||
</p>
|
||||
<div style="display: flex;justify-content: center; align-items: center;">
|
||||
<p
|
||||
style="
|
||||
background-color: rgb(0, 0, 0);
|
||||
border-radius: 0.25rem;
|
||||
color: rgb(255, 255, 255);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
line-height: 100%;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
New Sign In Detected
|
||||
</p>
|
||||
</div>
|
||||
<hr
|
||||
style="
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(234, 234, 234);
|
||||
margin-top: 26px;
|
||||
margin-bottom: 26px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top: 1px solid #eaeaea;
|
||||
"
|
||||
/>
|
||||
<p
|
||||
style="
|
||||
color: rgb(102, 102, 102);
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
Turbo NPN is a free and open source prodcut
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
export default SignInMail;
|
||||
9
apps/api/src/features/roles/dto/create-role.dto.ts
Normal file
9
apps/api/src/features/roles/dto/create-role.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
}
|
||||
4
apps/api/src/features/roles/dto/update-role.dto.ts
Normal file
4
apps/api/src/features/roles/dto/update-role.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateRoleDto } from './create-role.dto';
|
||||
|
||||
export class UpdateRoleDto extends PartialType(CreateRoleDto) {}
|
||||
6
apps/api/src/features/roles/entities/role.entity.ts
Normal file
6
apps/api/src/features/roles/entities/role.entity.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class Role {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date | null;
|
||||
}
|
||||
59
apps/api/src/features/roles/roles.controller.ts
Normal file
59
apps/api/src/features/roles/roles.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Roles } from '@/common/decorators/roles.decorator';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
@ApiTags('roles')
|
||||
@Controller('roles')
|
||||
export class RolesController {
|
||||
constructor(private readonly rolesService: RolesService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Get all roles' })
|
||||
@ApiResponse({ status: 200, description: 'Return all roles.' })
|
||||
async findAll() {
|
||||
const data = await this.rolesService.findAll();
|
||||
return { message: 'Roles fetched successfully', data };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Get a role by ID' })
|
||||
@ApiResponse({ status: 200, description: 'Return the role.' })
|
||||
@ApiResponse({ status: 404, description: 'Role not found.' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
const data = await this.rolesService.findOne(+id);
|
||||
return { message: 'Role fetched successfully', data };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Create a new role' })
|
||||
@ApiResponse({ status: 201, description: 'Role created successfully.' })
|
||||
async create(@Body() createRoleDto: CreateRoleDto) {
|
||||
const data = await this.rolesService.create(createRoleDto);
|
||||
return { message: 'Role created successfully', data };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Update a role' })
|
||||
@ApiResponse({ status: 200, description: 'Role updated successfully.' })
|
||||
@ApiResponse({ status: 404, description: 'Role not found.' })
|
||||
async update(@Param('id') id: string, @Body() updateRoleDto: UpdateRoleDto) {
|
||||
const data = await this.rolesService.update(+id, updateRoleDto);
|
||||
return { message: 'Role updated successfully', data };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Delete a role' })
|
||||
@ApiResponse({ status: 200, description: 'Role deleted successfully.' })
|
||||
@ApiResponse({ status: 404, description: 'Role not found.' })
|
||||
async remove(@Param('id') id: string) {
|
||||
return await this.rolesService.remove(+id);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/features/roles/roles.module.ts
Normal file
12
apps/api/src/features/roles/roles.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RolesController } from './roles.controller';
|
||||
import { RolesService } from './roles.service';
|
||||
import { DrizzleModule } from '@/database/drizzle.module';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule],
|
||||
controllers: [RolesController],
|
||||
providers: [RolesService],
|
||||
exports: [RolesService],
|
||||
})
|
||||
export class RolesModule {}
|
||||
67
apps/api/src/features/roles/roles.service.ts
Normal file
67
apps/api/src/features/roles/roles.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
|
||||
import * as schema from 'src/database/index';
|
||||
import { roles } from 'src/database/index';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
import { Role } from './entities/role.entity';
|
||||
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Role[]> {
|
||||
return await this.drizzle.select().from(roles);
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<Role> {
|
||||
const role = await this.drizzle
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.id, id));
|
||||
|
||||
if (role.length === 0) {
|
||||
throw new HttpException('Role not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return role[0];
|
||||
}
|
||||
|
||||
async create(createRoleDto: CreateRoleDto): Promise<Role> {
|
||||
const [role] = await this.drizzle
|
||||
.insert(roles)
|
||||
.values({
|
||||
name: createRoleDto.name,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
async update(id: number, updateRoleDto: UpdateRoleDto): Promise<Role> {
|
||||
// Check if role exists
|
||||
await this.findOne(id);
|
||||
|
||||
await this.drizzle
|
||||
.update(roles)
|
||||
.set({
|
||||
name: updateRoleDto.name,
|
||||
})
|
||||
.where(eq(roles.id, id));
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<{ message: string }> {
|
||||
// Check if role exists
|
||||
await this.findOne(id);
|
||||
|
||||
await this.drizzle.delete(roles).where(eq(roles.id, id));
|
||||
|
||||
return { message: 'Role deleted successfully' };
|
||||
}
|
||||
}
|
||||
76
apps/api/src/features/surveys/Untitled-1.json
Normal file
76
apps/api/src/features/surveys/Untitled-1.json
Normal file
@@ -0,0 +1,76 @@
|
||||
[
|
||||
{
|
||||
"id": "q-1",
|
||||
"type": "simple",
|
||||
"position": 0,
|
||||
"question": "Pregunta N° 1 Simple",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "q-2",
|
||||
"type": "multiple_choice",
|
||||
"options": [
|
||||
{
|
||||
"id": "1",
|
||||
"text": "Opcion Prueba 1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"text": "Opcion Prueba 2 "
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"text": "Opcion Prueba 3"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"text": "Opcion Prueba 4"
|
||||
}
|
||||
],
|
||||
"position": 1,
|
||||
"question": "Pregunta de Multiples Opciones N°2 ",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "q-3",
|
||||
"type": "single_choice",
|
||||
"options": [
|
||||
{
|
||||
"id": "1",
|
||||
"text": "Opcion Unica Prueba 1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"text": "Opcion Unica Prueba 2"
|
||||
}
|
||||
],
|
||||
"position": 2,
|
||||
"question": "Preguntas de una sola opcion N°3 ",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "q-4",
|
||||
"type": "select",
|
||||
"options": [
|
||||
{
|
||||
"id": "1",
|
||||
"text": "Seleccion 1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"text": "Seleccion 2 "
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"text": "Seleccion 3"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"text": "Seleccion 4"
|
||||
}
|
||||
],
|
||||
"position": 3,
|
||||
"question": "Pregunta seleccion N° 4",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
73
apps/api/src/features/surveys/dto/create-survey.dto.ts
Normal file
73
apps/api/src/features/surveys/dto/create-survey.dto.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsDate, IsInt, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
|
||||
export class CreateSurveyDto {
|
||||
@ApiProperty({ description: 'Survey title' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: 'Survey description' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ description: 'Target audience' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
targetAudience: string;
|
||||
|
||||
@ApiProperty({ description: 'Closing date' })
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
closingDate?: Date;
|
||||
|
||||
@ApiProperty({ description: 'Publication status' })
|
||||
@IsBoolean()
|
||||
published: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Survey questions' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true }) // Asegura que cada elemento sea validado individualmente
|
||||
@Type(() => QuestionDto) // Evita que se envuelva en otro array
|
||||
questions: any[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
class QuestionDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsInt()
|
||||
position: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
question?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsBoolean()
|
||||
required: boolean;
|
||||
|
||||
@IsString()
|
||||
type: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OptionsDto)
|
||||
options?: OptionsDto[];
|
||||
}
|
||||
|
||||
class OptionsDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
text: string;
|
||||
}
|
||||
10
apps/api/src/features/surveys/dto/find-for-user.dto.ts
Normal file
10
apps/api/src/features/surveys/dto/find-for-user.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class FindForUserDto {
|
||||
@ApiProperty({ description: 'Survey rol' })
|
||||
@IsArray()
|
||||
@IsNotEmpty()
|
||||
rol: any;
|
||||
|
||||
}
|
||||
15
apps/api/src/features/surveys/dto/response-survey.dto.ts
Normal file
15
apps/api/src/features/surveys/dto/response-survey.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class AnswersSurveyDto {
|
||||
@ApiProperty({ description: 'Survey id' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
surveyId: string;
|
||||
|
||||
@ApiProperty({ description: 'Survey answers' })
|
||||
@IsArray()
|
||||
@IsNotEmpty()
|
||||
answers: any;
|
||||
|
||||
}
|
||||
63
apps/api/src/features/surveys/dto/statistics-response.dto.ts
Normal file
63
apps/api/src/features/surveys/dto/statistics-response.dto.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class QuestionStatDto {
|
||||
@ApiProperty({ description: 'Question identifier' })
|
||||
questionId: string;
|
||||
|
||||
@ApiProperty({ description: 'Question label' })
|
||||
label: string;
|
||||
|
||||
@ApiProperty({ description: 'Count of responses for this option' })
|
||||
count: number;
|
||||
}
|
||||
|
||||
export class SurveyDetailDto {
|
||||
@ApiProperty({ description: 'Survey ID' })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: 'Survey title' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: 'Survey description' })
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ description: 'Total responses received' })
|
||||
totalResponses: number;
|
||||
|
||||
@ApiProperty({ description: 'Target audience' })
|
||||
targetAudience: any;
|
||||
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt: string;
|
||||
|
||||
@ApiProperty({ description: 'Closing date' })
|
||||
closingDate?: string | null;
|
||||
|
||||
@ApiProperty({ description: 'Question statistics', type: [QuestionStatDto] })
|
||||
// @ApiProperty({ description: 'Question statistics' })
|
||||
questionStats: QuestionStatDto[];
|
||||
// questionStats: any;
|
||||
}
|
||||
|
||||
export class SurveyStatisticsResponseDto {
|
||||
@ApiProperty({ description: 'Total number of surveys' })
|
||||
totalSurveys: number;
|
||||
|
||||
@ApiProperty({ description: 'Total number of responses across all surveys' })
|
||||
totalResponses: number;
|
||||
|
||||
@ApiProperty({ description: 'Completion rate percentage' })
|
||||
completionRate: number;
|
||||
|
||||
@ApiProperty({ description: 'Surveys created by month' })
|
||||
surveysByMonth: { month: string; count: number }[];
|
||||
|
||||
@ApiProperty({ description: 'Responses by audience type' })
|
||||
responsesByAudience: { name: any; value: number }[];
|
||||
|
||||
@ApiProperty({ description: 'Response distribution by survey' })
|
||||
responseDistribution: { title: string; responses: number }[];
|
||||
|
||||
@ApiProperty({ description: 'Detailed statistics for each survey', type: [SurveyDetailDto] })
|
||||
surveyDetails: SurveyDetailDto[];
|
||||
}
|
||||
5
apps/api/src/features/surveys/dto/update-survey.dto.ts
Normal file
5
apps/api/src/features/surveys/dto/update-survey.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateSurveyDto } from './create-survey.dto';
|
||||
|
||||
|
||||
export class UpdateSurveyDto extends PartialType(CreateSurveyDto) {}
|
||||
30
apps/api/src/features/surveys/entities/survey.entity.ts
Normal file
30
apps/api/src/features/surveys/entities/survey.entity.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class Survey {
|
||||
@ApiProperty({ description: 'Survey ID' })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: 'Survey title' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: 'Survey description' })
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ description: 'Target audience for the survey' })
|
||||
targetAudience: string;
|
||||
|
||||
@ApiProperty({ description: 'Survey closing date' })
|
||||
closingDate?: Date;
|
||||
|
||||
@ApiProperty({ description: 'Survey publication status' })
|
||||
published: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Survey questions' })
|
||||
questions: any[];
|
||||
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
created_at?: Date;
|
||||
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updated_at?: Date;
|
||||
}
|
||||
125
apps/api/src/features/surveys/surveys.controller.ts
Normal file
125
apps/api/src/features/surveys/surveys.controller.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Roles } from '@/common/decorators/roles.decorator';
|
||||
import { PaginationDto } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateSurveyDto } from './dto/create-survey.dto';
|
||||
import { UpdateSurveyDto } from './dto/update-survey.dto';
|
||||
import { SurveysService } from './surveys.service';
|
||||
import { AnswersSurveyDto } from './dto/response-survey.dto';
|
||||
import { Request } from 'express';
|
||||
import { FindForUserDto } from './dto/find-for-user.dto';
|
||||
import { SurveyStatisticsResponseDto } from './dto/statistics-response.dto';
|
||||
|
||||
@ApiTags('surveys')
|
||||
@Controller('surveys')
|
||||
export class SurveysController {
|
||||
constructor(private readonly surveysService: SurveysService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Get all surveys with pagination and filters' })
|
||||
@ApiResponse({ status: 200, description: 'Return paginated surveys.' })
|
||||
async findAll(@Query() paginationDto: PaginationDto) {
|
||||
const result = await this.surveysService.findAll(paginationDto);
|
||||
return {
|
||||
message: 'Surveys fetched successfully',
|
||||
data: result.data,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Post('for-user')
|
||||
@ApiOperation({ summary: 'Get all surveys with pagination and filters for user' })
|
||||
@ApiResponse({ status: 200, description: 'Return paginated surveys for user.' })
|
||||
async findAllForUser(@Req() req: Request, @Query() paginationDto: PaginationDto, @Body() findForUserDto: FindForUserDto) {
|
||||
|
||||
const userId = req['user'].id;
|
||||
|
||||
const result = await this.surveysService.findAllForUser(paginationDto, userId, findForUserDto);
|
||||
|
||||
return {
|
||||
message: 'Surveys fetched successfully',
|
||||
data: result.data,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
@Roles('admin', 'superadmin', 'autoridad')
|
||||
@ApiOperation({ summary: 'Get survey statistics' })
|
||||
|
||||
@ApiResponse({ status: 200, description: 'Return survey statistics.'})
|
||||
|
||||
async getStatistics() {
|
||||
const data = await this.surveysService.getStatistics();
|
||||
return {
|
||||
message: 'Survey statistics fetched successfully',
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a survey by ID' })
|
||||
@ApiResponse({ status: 200, description: 'Return the survey.' })
|
||||
@ApiResponse({ status: 404, description: 'Survey not found.' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
const data = await this.surveysService.findOne(id);
|
||||
return { message: 'Survey fetched successfully', data };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Create a new survey' })
|
||||
@ApiResponse({ status: 201, description: 'Survey created successfully.' })
|
||||
async create(@Body() createSurveyDto: CreateSurveyDto) {
|
||||
console.log(createSurveyDto);
|
||||
|
||||
const data = await this.surveysService.create(createSurveyDto);
|
||||
return { message: 'Survey created successfully', data };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Update a survey' })
|
||||
@ApiResponse({ status: 200, description: 'Survey updated successfully.' })
|
||||
@ApiResponse({ status: 404, description: 'Survey not found.' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateSurveyDto: UpdateSurveyDto,
|
||||
) {
|
||||
const data = await this.surveysService.update(id, updateSurveyDto);
|
||||
return { message: 'Survey updated successfully', data };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Delete a survey' })
|
||||
@ApiResponse({ status: 200, description: 'Survey deleted successfully.' })
|
||||
@ApiResponse({ status: 404, description: 'Survey not found.' })
|
||||
async remove(@Param('id') id: string) {
|
||||
return await this.surveysService.remove(id);
|
||||
}
|
||||
|
||||
@Post('answers')
|
||||
@ApiOperation({ summary: 'Create a new answers' })
|
||||
@ApiResponse({ status: 201, description: 'Survey answers successfully.' })
|
||||
async answers(@Req() req: Request, @Body() answersSurveyDto: AnswersSurveyDto) {
|
||||
const userId = (req as any).user?.id;
|
||||
const data = await this.surveysService.answers(Number(userId),answersSurveyDto);
|
||||
return { message: 'Survey answers created successfully', data };
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
10
apps/api/src/features/surveys/surveys.module.ts
Normal file
10
apps/api/src/features/surveys/surveys.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SurveysService } from './surveys.service';
|
||||
import { SurveysController } from './surveys.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [SurveysController],
|
||||
providers: [SurveysService],
|
||||
exports: [SurveysService],
|
||||
})
|
||||
export class SurveysModule {}
|
||||
535
apps/api/src/features/surveys/surveys.service.ts
Normal file
535
apps/api/src/features/surveys/surveys.service.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
|
||||
import { surveys, answersSurveys, viewSurveys } from '@/database/index';
|
||||
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from '@/database/index';
|
||||
import { CreateSurveyDto } from './dto/create-survey.dto';
|
||||
import { UpdateSurveyDto } from './dto/update-survey.dto';
|
||||
import { and, count, eq, ilike, isNull, or, sql } from 'drizzle-orm';
|
||||
import { SurveyDetailDto, SurveyStatisticsResponseDto } from './dto/statistics-response.dto';
|
||||
import { PaginationDto } from '@/common/dto/pagination.dto';
|
||||
import { AnswersSurveyDto } from './dto/response-survey.dto';
|
||||
import { FindForUserDto } from './dto/find-for-user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SurveysService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
) { }
|
||||
|
||||
async findAll(paginationDto: PaginationDto) {
|
||||
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build search condition
|
||||
const searchCondition = ilike(surveys.title, `%${search}%`);
|
||||
|
||||
|
||||
// Build sort condition
|
||||
const orderBy = sortOrder === 'asc'
|
||||
? sql`${surveys[sortBy as keyof typeof surveys]} asc`
|
||||
: sql`${surveys[sortBy as keyof typeof surveys]} desc`;
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCountResult = await this.drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(surveys)
|
||||
.where(searchCondition);
|
||||
|
||||
const totalCount = Number(totalCountResult[0].count);
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
|
||||
|
||||
// Get paginated data
|
||||
const data = await this.drizzle
|
||||
.select()
|
||||
.from(surveys)
|
||||
.where(searchCondition)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const dataSurvey = data.map((survey) => {
|
||||
return {
|
||||
...survey,
|
||||
closingDate: survey.closingDate ? new Date(survey.closingDate) : null,
|
||||
};
|
||||
});
|
||||
|
||||
// Build pagination metadata
|
||||
const meta = {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
nextPage: page < totalPages ? page + 1 : null,
|
||||
previousPage: page > 1 ? page - 1 : null,
|
||||
};
|
||||
|
||||
return { data: dataSurvey, meta };
|
||||
|
||||
}
|
||||
|
||||
async findAllForUser(paginationDto: PaginationDto, userId: number, findForUserDto: FindForUserDto) {
|
||||
const { page = 1, limit = 10, search = '', sortBy = 'created_at', sortOrder = 'asc' } = paginationDto || {};
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let searchCondition : any = false
|
||||
|
||||
// Build search condition
|
||||
// if (findForUserDto.rol[0].rol === 'superadmin' || findForUserDto.rol[0].rol == 'admin') {
|
||||
// searchCondition = and(
|
||||
// or(eq(viewSurveys.targetAudience, 'producers'), eq(viewSurveys.targetAudience, 'organization'), eq(viewSurveys.targetAudience, 'all')),
|
||||
// or(eq(viewSurveys.user_id, userId), isNull(viewSurveys.user_id))
|
||||
// );
|
||||
// } else {
|
||||
// searchCondition = and(
|
||||
// or(eq(viewSurveys.targetAudience, findForUserDto.rol[0].rol), eq(viewSurveys.targetAudience, 'all')),
|
||||
// or(eq(viewSurveys.user_id, userId), isNull(viewSurveys.user_id))
|
||||
// );
|
||||
// }
|
||||
|
||||
if (findForUserDto.rol[0].rol !== 'superadmin' && findForUserDto.rol[0].rol !== 'admin') {
|
||||
searchCondition = or(eq(surveys.targetAudience, findForUserDto.rol[0].rol), eq(surveys.targetAudience, 'all'))
|
||||
}
|
||||
|
||||
// console.log(searchCondition);
|
||||
|
||||
// Build sort condition
|
||||
const orderBy = sortOrder === 'asc'
|
||||
? sql`${surveys[sortBy as keyof typeof surveys]} asc`
|
||||
: sql`${surveys[sortBy as keyof typeof surveys]} desc`;
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCountResult = await this.drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(surveys)
|
||||
.leftJoin(answersSurveys, and(eq(answersSurveys.surveyId,surveys.id),eq(answersSurveys.userId,userId)))
|
||||
.where(searchCondition);
|
||||
|
||||
const totalCount = Number(totalCountResult[0].count);
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
|
||||
// Get paginated data
|
||||
const data = await this.drizzle
|
||||
.select()
|
||||
.from(surveys)
|
||||
.leftJoin(answersSurveys, and(eq(answersSurveys.surveyId,surveys.id),eq(answersSurveys.userId,userId)))
|
||||
.where(searchCondition)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
// Build pagination metadata
|
||||
const meta = {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
nextPage: page < totalPages ? page + 1 : null,
|
||||
previousPage: page > 1 ? page - 1 : null,
|
||||
};
|
||||
|
||||
return { data, meta };
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const survey = await this.drizzle
|
||||
.select()
|
||||
.from(surveys)
|
||||
.where(eq(surveys.id, parseInt(id)));
|
||||
|
||||
if (survey.length === 0) {
|
||||
throw new HttpException('Survey not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const dataSurvey = survey.map((survey) => {
|
||||
return {
|
||||
...survey,
|
||||
closingDate: survey.closingDate ? new Date(survey.closingDate) : null,
|
||||
};
|
||||
});
|
||||
|
||||
return dataSurvey[0];
|
||||
}
|
||||
|
||||
async findByTitle(title: string) {
|
||||
return await this.drizzle
|
||||
.select()
|
||||
.from(surveys)
|
||||
.where(eq(surveys.title, title));
|
||||
}
|
||||
|
||||
async create(createSurveyDto: CreateSurveyDto) {
|
||||
|
||||
const find = await this.findByTitle(createSurveyDto.title);
|
||||
|
||||
if (find.length !== 0) {
|
||||
throw new HttpException('Survey already exists', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const survey = await this.drizzle
|
||||
.insert(surveys)
|
||||
.values({
|
||||
...createSurveyDto,
|
||||
closingDate: createSurveyDto.closingDate?.toISOString(),
|
||||
})
|
||||
.returning();
|
||||
const dataSurvey = survey.map((survey) => {
|
||||
return {
|
||||
...survey,
|
||||
closingDate: survey.closingDate ? new Date(survey.closingDate) : null,
|
||||
};
|
||||
});
|
||||
|
||||
return dataSurvey[0];
|
||||
}
|
||||
|
||||
async update(id: string, updateSurveyDto: UpdateSurveyDto) {
|
||||
|
||||
const find = await this.findOne(id)
|
||||
if (!find) {
|
||||
throw new HttpException('Survey not found', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
const find2 = await this.findByTitle(updateSurveyDto.title ?? '');
|
||||
|
||||
if (find2.length !== 0 && find2[0].id !== parseInt(id)) {
|
||||
throw new HttpException('Survey already exists', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const survey = await this.drizzle
|
||||
.update(surveys)
|
||||
.set({
|
||||
...updateSurveyDto,
|
||||
closingDate: updateSurveyDto.closingDate?.toISOString(),
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where(eq(surveys.id, parseInt(id)))
|
||||
.returning();
|
||||
|
||||
const dataSurvey = survey.map((survey) => {
|
||||
return {
|
||||
...survey,
|
||||
closingDate: survey.closingDate ? new Date(survey.closingDate) : null,
|
||||
};
|
||||
});
|
||||
|
||||
return dataSurvey[0];
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const find = await this.findOne(id);
|
||||
if (!find) {
|
||||
throw new HttpException('Survey not found', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
await this.drizzle
|
||||
.delete(surveys)
|
||||
.where(eq(surveys.id, parseInt(id)));
|
||||
|
||||
return { message: 'Survey deleted successfully' };
|
||||
}
|
||||
|
||||
async answers(userId: number, answersSurveyDto: AnswersSurveyDto) {
|
||||
|
||||
const find = await this.drizzle.select()
|
||||
.from(answersSurveys)
|
||||
.where(and(eq(answersSurveys.surveyId, Number(answersSurveyDto.surveyId)), (eq(answersSurveys.userId, userId))));
|
||||
|
||||
|
||||
if (find.length !== 0) {
|
||||
throw new HttpException('Survey answers already exists', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const survey = await this.drizzle
|
||||
.insert(answersSurveys)
|
||||
.values({
|
||||
...answersSurveyDto,
|
||||
surveyId: Number(answersSurveyDto.surveyId),
|
||||
userId: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return survey[0];
|
||||
}
|
||||
|
||||
async getStatistics(): Promise<SurveyStatisticsResponseDto> {
|
||||
// Obtener el número total de encuestas
|
||||
const totalSurveys = await this.getTotalSurveysCount();
|
||||
// Obtener el número total de respuestas
|
||||
const totalResponses = await this.getTotalResponsesCount();
|
||||
|
||||
// Calcular la tasa de finalización
|
||||
const completionRate = totalSurveys > 0 ? Math.round((totalResponses / totalSurveys) * 100) : 0;
|
||||
|
||||
// Obtener las encuestas por mes
|
||||
const surveysByMonth = await this.getSurveysByMonth();
|
||||
|
||||
// Obtener las respuestas por audiencia
|
||||
const responsesByAudience = await this.getResponsesByAudience();
|
||||
|
||||
// Obtener la distribución de respuestas por encuesta
|
||||
const responseDistribution = await this.getResponseDistribution();
|
||||
|
||||
// Obtener las estadísticas detalladas de las encuestas
|
||||
const surveyDetails = await this.getSurveyDetails();
|
||||
|
||||
return {
|
||||
totalSurveys,
|
||||
totalResponses,
|
||||
completionRate,
|
||||
surveysByMonth,
|
||||
responsesByAudience,
|
||||
responseDistribution,
|
||||
surveyDetails,
|
||||
}
|
||||
}
|
||||
|
||||
private async getTotalSurveysCount(): Promise<number> {
|
||||
const result = await this.drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(surveys);
|
||||
return Number(result[0].count);
|
||||
}
|
||||
|
||||
private async getTotalResponsesCount(): Promise<number> {
|
||||
const result = await this.drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(answersSurveys);
|
||||
return Number(result[0].count);
|
||||
}
|
||||
|
||||
private async getSurveysByMonth(): Promise<{ month: string; count: number }[]> {
|
||||
const result = await this.drizzle
|
||||
.select({
|
||||
month: sql<string>`to_char(created_at, 'YYYY-MM')`,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(surveys)
|
||||
.groupBy(sql`to_char(created_at, 'YYYY-MM')`)
|
||||
.orderBy(sql`to_char(created_at, 'YYYY-MM')`);
|
||||
return result.map(item => ({ month: item.month, count: Number(item.count) }));
|
||||
}
|
||||
|
||||
private async getResponsesByAudience(): Promise<{ name: any; value: number }[]> {
|
||||
const result = await this.drizzle
|
||||
.select({
|
||||
audience: surveys.targetAudience,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(answersSurveys)
|
||||
.leftJoin(surveys, eq(answersSurveys.surveyId, surveys.id))
|
||||
.groupBy(surveys.targetAudience);
|
||||
return result.map(item =>
|
||||
{
|
||||
let audience = 'Sin definir'
|
||||
if (item.audience == 'all') {
|
||||
audience = 'General'
|
||||
} else if (item.audience == 'organization') {
|
||||
audience = 'Organización'
|
||||
} else if (item.audience == 'producers') {
|
||||
audience = 'Productores'
|
||||
}
|
||||
return ({ name: audience, value: Number(item.count) })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async getResponseDistribution(): Promise<{ title: string; responses: number }[]> {
|
||||
const result = await this.drizzle
|
||||
.select({
|
||||
id: surveys.id,
|
||||
title: surveys.title,
|
||||
responses: sql<number>`count(${answersSurveys.id})`,
|
||||
})
|
||||
.from(surveys)
|
||||
.leftJoin(answersSurveys, eq(surveys.id, answersSurveys.surveyId))
|
||||
.groupBy(surveys.id, surveys.title)
|
||||
.orderBy(sql`count(${answersSurveys.id}) desc`)
|
||||
.limit(10);
|
||||
return result.map(item => ({ title: item.title, responses: Number(item.responses) }));
|
||||
}
|
||||
|
||||
private async getSurveyDetails(): Promise<SurveyDetailDto[]> {
|
||||
const allSurveys = await this.drizzle
|
||||
.select({
|
||||
id: surveys.id,
|
||||
title: surveys.title,
|
||||
description: surveys.description,
|
||||
targetAudience: surveys.targetAudience,
|
||||
createdAt: surveys.created_at,
|
||||
closingDate: surveys.closingDate,
|
||||
questions: surveys.questions,
|
||||
})
|
||||
.from(surveys);
|
||||
|
||||
|
||||
return await Promise.all(
|
||||
allSurveys.map(async (survey) => {
|
||||
// Obtener el número total de respuestas para esta encuesta
|
||||
const totalSurveyResponses = await this.getTotalSurveyResponses(survey.id);
|
||||
|
||||
// Obtener todas las respuestas para esta encuesta
|
||||
const answersResult = await this.drizzle
|
||||
.select({ answers: answersSurveys.answers })
|
||||
.from(answersSurveys)
|
||||
.where(eq(answersSurveys.surveyId, survey.id));
|
||||
|
||||
let audience = 'Sin definir'
|
||||
if (survey.targetAudience == 'all') {
|
||||
audience = 'General'
|
||||
} else if (survey.targetAudience == 'organization') {
|
||||
audience = 'Organización'
|
||||
} else if (survey.targetAudience == 'producers') {
|
||||
audience = 'Productores'
|
||||
}
|
||||
|
||||
// Procesar las estadísticas de las preguntas
|
||||
const questionStats = this.processQuestionStats(survey.questions as any[], answersResult);
|
||||
|
||||
return {
|
||||
id: survey.id,
|
||||
title: survey.title,
|
||||
description: survey.description,
|
||||
totalResponses: totalSurveyResponses,
|
||||
// targetAudience: survey.targetAudience,
|
||||
targetAudience: audience,
|
||||
createdAt: survey.createdAt.toISOString(),
|
||||
closingDate: survey.closingDate ? new Date(survey.closingDate).toISOString() : undefined,
|
||||
questionStats,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async getTotalSurveyResponses(surveyId: number): Promise<number> {
|
||||
const result = await this.drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(answersSurveys)
|
||||
.where(eq(answersSurveys.surveyId, surveyId));
|
||||
return Number(result[0].count);
|
||||
}
|
||||
|
||||
// ==================================
|
||||
|
||||
private processQuestionStats(questions: any[], answersResult: { answers: any }[]) {
|
||||
// Initialize counters for each question option
|
||||
const questionStats: Array<{ questionId: string; label: string; count: number }> = [];
|
||||
|
||||
// Skip title questions (type: 'title')
|
||||
const surveyQuestions = questions.filter(q => q.type !== 'title');
|
||||
|
||||
// console.log(surveyQuestions);
|
||||
// console.log('Se llamo a processQuestionStats()');
|
||||
|
||||
for (const question of surveyQuestions) {
|
||||
// console.log('Bucle1 se ejecuto');
|
||||
|
||||
// For single choice, multiple choice, and select questions
|
||||
// if (['single_choice', 'multiple_choice', 'select'].includes(question.type)) {
|
||||
const optionCounts: Record<string, number> = {};
|
||||
|
||||
// // Initialize counts for each option
|
||||
// for (const option of question.options) {
|
||||
// optionCounts[option.text] = 0;
|
||||
// }
|
||||
|
||||
// // Count responses for each option
|
||||
// for (const answerObj of answersResult) {
|
||||
// const answer = answerObj.answers.find(a => a.questionId === question.id);
|
||||
|
||||
// if (answer) {
|
||||
// if (Array.isArray(answer.value)) {
|
||||
// // For multiple choice questions
|
||||
// for (const value of answer.value) {
|
||||
// if (optionCounts[value] !== undefined) {
|
||||
// optionCounts[value]++;
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // For single choice questions
|
||||
// if (optionCounts[answer.value] !== undefined) {
|
||||
// optionCounts[answer.value]++;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Convert to the required format
|
||||
// for (const option of question.options) {
|
||||
// questionStats.push({
|
||||
// questionId: String(question.id),
|
||||
// label: option.text,
|
||||
// count: optionCounts[option.value] || 0,
|
||||
// });
|
||||
// }
|
||||
|
||||
if (question.type == 'multiple_choice') {
|
||||
for (const option of question.options) {
|
||||
console.log(option);
|
||||
let count :number = 0
|
||||
// Count responses for each option
|
||||
for (const obj of answersResult) {
|
||||
console.log(obj.answers)
|
||||
const resp = obj.answers.find(a => a.questionId == question.id)
|
||||
const respArray = resp.value.split(",")
|
||||
// console.log();
|
||||
|
||||
if (respArray[option.id] == 'true') {
|
||||
count++
|
||||
}
|
||||
}
|
||||
optionCounts[option.text] = count
|
||||
// Convert to the required format
|
||||
questionStats.push({
|
||||
questionId: String(question.id),
|
||||
label: `${question.question} ${option.text}`,
|
||||
count: optionCounts[option.text] || 0,
|
||||
})
|
||||
}
|
||||
|
||||
} else if (question.type == 'single_choice' || question.type == 'select') {
|
||||
for (const option of question.options) {
|
||||
let count :number = 0
|
||||
// Count responses for each option
|
||||
for (const obj of answersResult) {
|
||||
const resp = obj.answers.find(a => a.questionId == question.id)
|
||||
if (resp.value == option.id) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
optionCounts[option.text] = count
|
||||
// Convert to the required format
|
||||
questionStats.push({
|
||||
questionId: String(question.id),
|
||||
label: `${question.question} ${option.text}`,
|
||||
count: optionCounts[option.text] || 0,
|
||||
})
|
||||
}
|
||||
} else if (question.type === 'simple') {
|
||||
// For simple text questions, just count how many responses
|
||||
let responseCount = 0;
|
||||
|
||||
for (const answerObj of answersResult) {
|
||||
const answer = answerObj.answers.find(a => a.questionId === question.id);
|
||||
if (answer && answer.value) {
|
||||
responseCount++;
|
||||
}
|
||||
}
|
||||
|
||||
questionStats.push({
|
||||
questionId: String(question.id),
|
||||
label: question.question,
|
||||
count: responseCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return questionStats;
|
||||
}
|
||||
}
|
||||
12
apps/api/src/features/user-roles/dto/assign-role.dto.ts
Normal file
12
apps/api/src/features/user-roles/dto/assign-role.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber } from 'class-validator';
|
||||
|
||||
export class AssignRoleDto {
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
userId: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
roleId: number;
|
||||
}
|
||||
46
apps/api/src/features/user-roles/user-roles.controller.ts
Normal file
46
apps/api/src/features/user-roles/user-roles.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Roles } from '@/common/decorators/roles.decorator';
|
||||
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AssignRoleDto } from './dto/assign-role.dto';
|
||||
import { UserRolesService } from './user-roles.service';
|
||||
|
||||
@ApiTags('user-roles')
|
||||
@Controller('user-roles')
|
||||
export class UserRolesController {
|
||||
constructor(private readonly userRolesService: UserRolesService) {}
|
||||
|
||||
@Get('user/:userId')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Get roles by user ID' })
|
||||
@ApiResponse({ status: 200, description: 'Return roles for the user.' })
|
||||
@ApiResponse({ status: 404, description: 'User not found.' })
|
||||
async getRolesByUserId(@Param('userId') userId: string) {
|
||||
const data = await this.userRolesService.getRolesByUserId(+userId);
|
||||
return { message: 'Roles fetched successfully', data };
|
||||
}
|
||||
|
||||
@Post('assign')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Assign a role to a user' })
|
||||
@ApiResponse({ status: 200, description: 'Role assigned successfully.' })
|
||||
@ApiResponse({ status: 404, description: 'User or role not found.' })
|
||||
async assignRoleToUser(@Body() assignRoleDto: AssignRoleDto) {
|
||||
const data = await this.userRolesService.assignRoleToUser(assignRoleDto);
|
||||
return { message: 'Role assigned successfully', data };
|
||||
}
|
||||
|
||||
@Delete('user/:userId/role/:roleId')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Remove a role from a user' })
|
||||
@ApiResponse({ status: 200, description: 'Role removed successfully.' })
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'User-role relationship not found.',
|
||||
})
|
||||
async removeRoleFromUser(
|
||||
@Param('userId') userId: string,
|
||||
@Param('roleId') roleId: string,
|
||||
) {
|
||||
return await this.userRolesService.removeRoleFromUser(+userId, +roleId);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/features/user-roles/user-roles.module.ts
Normal file
12
apps/api/src/features/user-roles/user-roles.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserRolesController } from './user-roles.controller';
|
||||
import { UserRolesService } from './user-roles.service';
|
||||
import { DrizzleModule } from '@/database/drizzle.module';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule],
|
||||
controllers: [UserRolesController],
|
||||
providers: [UserRolesService],
|
||||
exports: [UserRolesService],
|
||||
})
|
||||
export class UserRolesModule {}
|
||||
118
apps/api/src/features/user-roles/user-roles.service.ts
Normal file
118
apps/api/src/features/user-roles/user-roles.service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
|
||||
import * as schema from 'src/database/index';
|
||||
import { users, roles, usersRole } from 'src/database/index';
|
||||
import { AssignRoleDto } from './dto/assign-role.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UserRolesService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
) {}
|
||||
|
||||
async getRolesByUserId(userId: number) {
|
||||
// Check if user exists
|
||||
const user = await this.drizzle
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
if (user.length === 0) {
|
||||
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Get roles for the user
|
||||
const userRoles = await this.drizzle
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
})
|
||||
.from(usersRole)
|
||||
.leftJoin(roles, eq(roles.id, usersRole.roleId))
|
||||
.where(eq(usersRole.userId, userId));
|
||||
|
||||
return userRoles;
|
||||
}
|
||||
|
||||
async assignRoleToUser(assignRoleDto: AssignRoleDto) {
|
||||
const { userId, roleId } = assignRoleDto;
|
||||
|
||||
// Check if user exists
|
||||
const user = await this.drizzle
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
if (user.length === 0) {
|
||||
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check if role exists
|
||||
const role = await this.drizzle
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId));
|
||||
|
||||
if (role.length === 0) {
|
||||
throw new HttpException('Role not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check if the user already has this role
|
||||
const existingUserRole = await this.drizzle
|
||||
.select()
|
||||
.from(usersRole)
|
||||
.where(
|
||||
and(
|
||||
eq(usersRole.userId, userId),
|
||||
eq(usersRole.roleId, roleId),
|
||||
),
|
||||
);
|
||||
|
||||
if (existingUserRole.length > 0) {
|
||||
throw new HttpException('User already has this role', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Assign role to user
|
||||
await this.drizzle.insert(usersRole).values({
|
||||
userId,
|
||||
roleId,
|
||||
});
|
||||
|
||||
// Return the updated roles
|
||||
return this.getRolesByUserId(userId);
|
||||
}
|
||||
|
||||
async removeRoleFromUser(userId: number, roleId: number) {
|
||||
// Check if the user-role relationship exists
|
||||
const userRole = await this.drizzle
|
||||
.select()
|
||||
.from(usersRole)
|
||||
.where(
|
||||
and(
|
||||
eq(usersRole.userId, userId),
|
||||
eq(usersRole.roleId, roleId),
|
||||
),
|
||||
);
|
||||
|
||||
if (userRole.length === 0) {
|
||||
throw new HttpException(
|
||||
'User-role relationship not found',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the role from the user
|
||||
await this.drizzle
|
||||
.delete(usersRole)
|
||||
.where(
|
||||
and(
|
||||
eq(usersRole.userId, userId),
|
||||
eq(usersRole.roleId, roleId),
|
||||
),
|
||||
);
|
||||
|
||||
return { message: 'Role removed from user successfully' };
|
||||
}
|
||||
}
|
||||
33
apps/api/src/features/users/dto/create-user.dto.ts
Normal file
33
apps/api/src/features/users/dto/create-user.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsInt, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
fullname: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Phone must be a string',
|
||||
})
|
||||
@IsOptional()
|
||||
phone: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString({
|
||||
message: 'Password must be a string',
|
||||
})
|
||||
password: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
role: number;
|
||||
}
|
||||
40
apps/api/src/features/users/dto/update-user.dto.ts
Normal file
40
apps/api/src/features/users/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ApiProperty, PartialType } from '@nestjs/swagger';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
|
||||
// import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {
|
||||
// export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
username: string;
|
||||
|
||||
@IsOptional()
|
||||
email: string;
|
||||
|
||||
@IsOptional()
|
||||
fullname: string;
|
||||
|
||||
@IsOptional()
|
||||
phone: string;
|
||||
|
||||
@IsOptional()
|
||||
password: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
isActive: string;
|
||||
|
||||
@IsOptional()
|
||||
state: string | number | null;
|
||||
|
||||
@IsOptional()
|
||||
municipality: string | number | null;
|
||||
|
||||
@IsOptional()
|
||||
parish: string | number | null;
|
||||
|
||||
@IsOptional()
|
||||
role: number;
|
||||
}
|
||||
25
apps/api/src/features/users/entities/user.entity.ts
Normal file
25
apps/api/src/features/users/entities/user.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export class User {
|
||||
id?: number;
|
||||
username!: string;
|
||||
email!: string;
|
||||
fullname!: string;
|
||||
phone?: string | null;
|
||||
password?: string;
|
||||
isTwoFactorEnabled?: boolean;
|
||||
twoFactorSecret?: string | null;
|
||||
isEmailVerified?: boolean;
|
||||
isActive!: boolean;
|
||||
created_at?: Date | null;
|
||||
updated_at?: Date | null;
|
||||
state?: string | number | null;
|
||||
municipality?: string | number | null;
|
||||
parish?: string | number | null;
|
||||
}
|
||||
|
||||
export class UserRole {
|
||||
id!: number;
|
||||
userId!: number;
|
||||
roleId!: number;
|
||||
created_at?: Date | null;
|
||||
updated_at?: Date | null;
|
||||
}
|
||||
81
apps/api/src/features/users/users.controller.ts
Normal file
81
apps/api/src/features/users/users.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Get all users with pagination and filters' })
|
||||
@ApiResponse({ status: 200, description: 'Return paginated users.' })
|
||||
async findAll(@Query() paginationDto: PaginationDto) {
|
||||
const result = await this.usersService.findAll(paginationDto);
|
||||
return {
|
||||
message: 'Users fetched successfully',
|
||||
data: result.data,
|
||||
meta: result.meta
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
// @Roles('admin')
|
||||
@ApiOperation({ summary: 'Get a user by ID' })
|
||||
@ApiResponse({ status: 200, description: 'Return the user.' })
|
||||
@ApiResponse({ status: 404, description: 'User not found.' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
const data = await this.usersService.findOne(id);
|
||||
return { message: 'User fetched successfully', data };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Create a new user' })
|
||||
@ApiResponse({ status: 201, description: 'User created successfully.' })
|
||||
async create(
|
||||
@Body() createUserDto: CreateUserDto,
|
||||
@Query('roleId') roleId?: string,
|
||||
) {
|
||||
const data = await this.usersService.create(
|
||||
createUserDto,
|
||||
roleId ? parseInt(roleId) : undefined,
|
||||
);
|
||||
return { message: 'User created successfully', data };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Update a user' })
|
||||
@ApiResponse({ status: 200, description: 'User updated successfully.' })
|
||||
@ApiResponse({ status: 404, description: 'User not found.' })
|
||||
async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
||||
const data = await this.usersService.update(id, updateUserDto);
|
||||
return { message: 'User updated successfully', data };
|
||||
}
|
||||
|
||||
@Patch('profile/:id')
|
||||
// @Roles('admin')
|
||||
@ApiOperation({ summary: 'Update a user' })
|
||||
@ApiResponse({ status: 200, description: 'User updated successfully.' })
|
||||
@ApiResponse({ status: 400, description: 'email already exists.' })
|
||||
@ApiResponse({ status: 404, description: 'User not found.' })
|
||||
async updateProfile(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
||||
const data = await this.usersService.updateProfile(id, updateUserDto);
|
||||
return { message: 'User updated successfully', data };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Delete a user' })
|
||||
@ApiResponse({ status: 200, description: 'User deleted successfully.' })
|
||||
@ApiResponse({ status: 404, description: 'User not found.' })
|
||||
async remove(@Param('id') id: string) {
|
||||
return await this.usersService.remove(id);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/features/users/users.module.ts
Normal file
11
apps/api/src/features/users/users.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
import { DrizzleModule } from '@/database/drizzle.module';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
288
apps/api/src/features/users/users.service.ts
Normal file
288
apps/api/src/features/users/users.service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
|
||||
import { Env, validateString } from '@/common/utils';
|
||||
import { Inject, Injectable, HttpException, HttpStatus, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from 'src/database/index';
|
||||
import { users, roles, usersRole } from 'src/database/index';
|
||||
import { eq, like, or, SQL, sql, and, not } from 'drizzle-orm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { User } from './entities/user.entity';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
) { }
|
||||
|
||||
async findAll(paginationDto?: PaginationDto): Promise<{ data: User[], meta: any }> {
|
||||
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
|
||||
|
||||
// Calculate offset
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build search condition
|
||||
let searchCondition: SQL<unknown> | undefined;
|
||||
if (search) {
|
||||
searchCondition = or(
|
||||
like(users.username, `%${search}%`),
|
||||
like(users.email, `%${search}%`),
|
||||
like(users.fullname, `%${search}%`)
|
||||
);
|
||||
}
|
||||
|
||||
// Build sort condition
|
||||
const orderBy = sortOrder === 'asc'
|
||||
? sql`${users[sortBy as keyof typeof users]} asc`
|
||||
: sql`${users[sortBy as keyof typeof users]} desc`;
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCountResult = await this.drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(searchCondition);
|
||||
|
||||
const totalCount = Number(totalCountResult[0].count);
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
|
||||
// Get paginated data
|
||||
const data = await this.drizzle
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
fullname: users.fullname,
|
||||
phone: users.phone,
|
||||
isActive: users.isActive,
|
||||
role: roles.name,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(usersRole, eq(usersRole.userId, users.id))
|
||||
.leftJoin(roles, eq(roles.id, usersRole.roleId))
|
||||
.where(searchCondition)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
// Build pagination metadata
|
||||
const meta = {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
nextPage: page < totalPages ? page + 1 : null,
|
||||
previousPage: page > 1 ? page - 1 : null,
|
||||
};
|
||||
|
||||
// console.log(data);
|
||||
|
||||
return { data, meta };
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<User> {
|
||||
const find = await this.drizzle
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
fullname: users.fullname,
|
||||
phone: users.phone,
|
||||
isActive: users.isActive,
|
||||
role: roles.name,
|
||||
state: schema.states.name,
|
||||
municipality: schema.municipalities.name,
|
||||
parish: schema.parishes.name
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(usersRole, eq(usersRole.userId, users.id))
|
||||
.leftJoin(roles, eq(roles.id, usersRole.roleId))
|
||||
.leftJoin(schema.states, eq(schema.states.id, users.state))
|
||||
.leftJoin(schema.municipalities, eq(schema.municipalities.id, users.municipality))
|
||||
.leftJoin(schema.parishes, eq(schema.parishes.id, users.parish))
|
||||
|
||||
.where(eq(users.id, parseInt(id)));
|
||||
|
||||
if (find.length === 0) {
|
||||
throw new HttpException('User does not exist', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return find[0];
|
||||
}
|
||||
|
||||
// Rest of the service remains the same
|
||||
async create(
|
||||
createUserDto: CreateUserDto,
|
||||
roleId: number = 2,
|
||||
): Promise<User> {
|
||||
// Hash the password
|
||||
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
|
||||
|
||||
const data = await this.drizzle
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email
|
||||
})
|
||||
.from(users)
|
||||
.where(or(eq(users.username, createUserDto.username), eq(users.email, createUserDto.email)));
|
||||
|
||||
if (data.length > 0) {
|
||||
if (data[0].username === createUserDto.username) {
|
||||
throw new HttpException('Username already exists', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (data[0].email === createUserDto.email) {
|
||||
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Start a transaction
|
||||
return await this.drizzle.transaction(async (tx) => {
|
||||
// Create the user
|
||||
const [newUser] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
username: createUserDto.username,
|
||||
email: createUserDto.email,
|
||||
password: hashedPassword,
|
||||
fullname: createUserDto.fullname,
|
||||
isActive: true,
|
||||
phone: createUserDto.phone,
|
||||
isEmailVerified: false,
|
||||
isTwoFactorEnabled: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Assign role to user
|
||||
await tx.insert(usersRole).values({
|
||||
userId: newUser.id,
|
||||
roleId: roleId,
|
||||
});
|
||||
|
||||
// Return the created user with role
|
||||
const [userWithRole] = await tx
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
fullname: users.fullname,
|
||||
phone: users.phone,
|
||||
isActive: users.isActive,
|
||||
role: roles.name,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(usersRole, eq(usersRole.userId, users.id))
|
||||
.leftJoin(roles, eq(roles.id, usersRole.roleId))
|
||||
.where(eq(users.id, newUser.id));
|
||||
|
||||
return userWithRole;
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
|
||||
const userId = parseInt(id);
|
||||
|
||||
// Check if user exists
|
||||
await this.findOne(id);
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {};
|
||||
if (updateUserDto.username) updateData.username = updateUserDto.username;
|
||||
if (updateUserDto.email) updateData.email = updateUserDto.email;
|
||||
if (updateUserDto.fullname) updateData.fullname = updateUserDto.fullname;
|
||||
if (updateUserDto.password) {
|
||||
updateData.password = await bcrypt.hash(updateUserDto.password, 10);
|
||||
}
|
||||
if (updateUserDto.phone) updateData.phone = updateUserDto.phone;
|
||||
if (updateUserDto.isActive) updateData.isActive = updateUserDto.isActive;
|
||||
|
||||
const updateDataRole: any = {};
|
||||
if (updateUserDto.role) updateDataRole.roleId = updateUserDto.role;
|
||||
// Update user
|
||||
await this.drizzle
|
||||
.update(users)
|
||||
.set(updateData)
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
await this.drizzle
|
||||
.update(usersRole)
|
||||
.set(updateDataRole)
|
||||
.where(eq(usersRole.userId, userId));
|
||||
|
||||
|
||||
// Return updated user
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async updateProfile(id: string, updateUserDto: UpdateUserDto): Promise<User> {
|
||||
// throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
|
||||
|
||||
const userId = parseInt(id);
|
||||
|
||||
// Check if user exists
|
||||
await this.findOne(id);
|
||||
|
||||
const data = await this.drizzle
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email
|
||||
})
|
||||
.from(users)
|
||||
.where(and(
|
||||
not(eq(users.id, userId)),
|
||||
eq(users.email, updateUserDto.email)
|
||||
)
|
||||
)
|
||||
|
||||
if (data.length > 0) {
|
||||
if (data[0].email === updateUserDto.email) {
|
||||
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {};
|
||||
// if (updateUserDto.username) updateData.username = updateUserDto.username;
|
||||
if (updateUserDto.email) updateData.email = updateUserDto.email;
|
||||
if (updateUserDto.fullname) updateData.fullname = updateUserDto.fullname;
|
||||
if (updateUserDto.password) {
|
||||
updateData.password = await bcrypt.hash(updateUserDto.password, 10);
|
||||
}
|
||||
if (updateUserDto.phone) updateData.phone = updateUserDto.phone;
|
||||
if (updateUserDto.isActive) updateData.isActive = updateUserDto.isActive;
|
||||
|
||||
if (updateUserDto.state) {
|
||||
updateData.state = updateUserDto.state;
|
||||
updateData.municipality = updateUserDto.municipality
|
||||
updateData.parish = updateUserDto.parish
|
||||
}
|
||||
|
||||
|
||||
// Update user
|
||||
await this.drizzle
|
||||
.update(users)
|
||||
.set(updateData)
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
// Return updated user
|
||||
return this.findOne(id);
|
||||
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<{ message: string, data: User }> {
|
||||
const userId = parseInt(id);
|
||||
|
||||
// Check if user exists
|
||||
const user = await this.findOne(id);
|
||||
|
||||
// Delete user (this will cascade delete related records due to foreign key constraints)
|
||||
// await this.drizzle.delete(users).where(eq(users.id, userId));
|
||||
await this.drizzle.update(users).set({ isActive: false }).where(eq(users.id, userId));
|
||||
|
||||
return { message: 'User deleted successfully', data: user };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user