Agregar campo username a User entity y DTO

- Columna username (unique, nullable) en auth.users
- Campo username en CreateUserDto y UpdateUserDto

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 11:47:37 -04:00
parent 6e0ad420ab
commit 8b6483aa7d
971 changed files with 16339 additions and 752 deletions

View File

@@ -0,0 +1,147 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsBoolean, IsNumber, IsDateString, IsArray, IsEnum, MaxLength } from 'class-validator';
import { TripStatus } from '../../../entities/user-trip.entity';
export class CreateTripDto {
@ApiProperty({ description: 'Trip name', example: 'Summer Vacation in DR' })
@IsString()
@MaxLength(150)
name: string;
@ApiPropertyOptional({ description: 'Trip description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Cover image URL' })
@IsOptional()
@IsString()
coverImageUrl?: string;
@ApiPropertyOptional({ description: 'Start date', example: '2026-06-01' })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ description: 'End date', example: '2026-06-07' })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({ description: 'Destination' })
@IsOptional()
@IsString()
destination?: string;
@ApiPropertyOptional({ description: 'Number of travelers', default: 1 })
@IsOptional()
@IsNumber()
travelersCount?: number;
@ApiPropertyOptional({ description: 'Estimated budget' })
@IsOptional()
@IsNumber()
estimatedBudget?: number;
@ApiPropertyOptional({ description: 'Budget currency', default: 'USD' })
@IsOptional()
@IsString()
budgetCurrency?: string;
@ApiPropertyOptional({ description: 'Trip tags' })
@IsOptional()
@IsArray()
tags?: string[];
@ApiPropertyOptional({ description: 'Is public trip', default: false })
@IsOptional()
@IsBoolean()
isPublic?: boolean;
}
export class CreateTripDayDto {
@ApiProperty({ description: 'Day number', example: 1 })
@IsNumber()
dayNumber: number;
@ApiPropertyOptional({ description: 'Date', example: '2026-06-01' })
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ description: 'Day title' })
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({ description: 'Notes' })
@IsOptional()
@IsString()
notes?: string;
}
export class CreateTripActivityDto {
@ApiPropertyOptional({ description: 'Reference item ID' })
@IsOptional()
@IsString()
itemId?: string;
@ApiPropertyOptional({ description: 'Item type' })
@IsOptional()
@IsString()
itemType?: string;
@ApiProperty({ description: 'Activity title' })
@IsString()
@MaxLength(200)
title: string;
@ApiPropertyOptional({ description: 'Description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Start time', example: '09:00' })
@IsOptional()
@IsString()
startTime?: string;
@ApiPropertyOptional({ description: 'End time', example: '12:00' })
@IsOptional()
@IsString()
endTime?: string;
@ApiPropertyOptional({ description: 'Duration in minutes' })
@IsOptional()
@IsNumber()
durationMinutes?: number;
@ApiPropertyOptional({ description: 'Location name' })
@IsOptional()
@IsString()
locationName?: string;
@ApiPropertyOptional({ description: 'Location address' })
@IsOptional()
@IsString()
locationAddress?: string;
@ApiPropertyOptional({ description: 'Location coordinates' })
@IsOptional()
locationCoords?: { lat: number; lng: number };
@ApiPropertyOptional({ description: 'Estimated cost' })
@IsOptional()
@IsNumber()
estimatedCost?: number;
@ApiPropertyOptional({ description: 'Image URL' })
@IsOptional()
@IsString()
imageUrl?: string;
@ApiPropertyOptional({ description: 'Notes' })
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './create-trip.dto';
export * from './update-trip.dto';

View File

@@ -0,0 +1,16 @@
import { PartialType } from '@nestjs/swagger';
import { CreateTripDto, CreateTripDayDto, CreateTripActivityDto } from './create-trip.dto';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { TripStatus } from '../../../entities/user-trip.entity';
export class UpdateTripDto extends PartialType(CreateTripDto) {
@ApiPropertyOptional({ description: 'Trip status', enum: TripStatus })
@IsOptional()
@IsEnum(TripStatus)
status?: TripStatus;
}
export class UpdateTripDayDto extends PartialType(CreateTripDayDto) {}
export class UpdateTripActivityDto extends PartialType(CreateTripActivityDto) {}

View File

@@ -0,0 +1,145 @@
import {
Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards, Request,
} from '@nestjs/common';
import {
ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam,
} from '@nestjs/swagger';
import { TripsService } from './trips.service';
import { CreateTripDto, CreateTripDayDto, CreateTripActivityDto } from './dto/create-trip.dto';
import { UpdateTripDto, UpdateTripDayDto, UpdateTripActivityDto } from './dto/update-trip.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { UserTrip, TripDay, TripActivity, TripStatus } from '../../entities/user-trip.entity';
@ApiTags('Trips')
@Controller('trips')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
export class TripsController {
constructor(private readonly tripsService: TripsService) {}
@Post()
@ApiOperation({ summary: 'Create a new trip' })
@ApiResponse({ status: 201, type: UserTrip })
create(@Body() createDto: CreateTripDto, @Request() req) {
return this.tripsService.create(req.user.id, createDto);
}
@Get('my')
@ApiOperation({ summary: 'Get my trips' })
@ApiQuery({ name: 'status', required: false, enum: TripStatus })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
findMyTrips(
@Request() req,
@Query('status') status?: TripStatus,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.tripsService.findAllByUser(req.user.id, status, page, limit);
}
@Get('my/stats')
@ApiOperation({ summary: 'Get trip statistics' })
getStats(@Request() req) {
return this.tripsService.getTripStats(req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get trip by ID' })
@ApiParam({ name: 'id', type: 'string' })
findOne(@Param('id') id: string, @Request() req) {
return this.tripsService.findOne(req.user.id, id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update trip' })
@ApiParam({ name: 'id', type: 'string' })
update(@Param('id') id: string, @Body() updateDto: UpdateTripDto, @Request() req) {
return this.tripsService.update(req.user.id, id, updateDto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete trip' })
@ApiParam({ name: 'id', type: 'string' })
remove(@Param('id') id: string, @Request() req) {
return this.tripsService.remove(req.user.id, id);
}
// Days
@Post(':tripId/days')
@ApiOperation({ summary: 'Add day to trip' })
addDay(
@Param('tripId') tripId: string,
@Body() createDto: CreateTripDayDto,
@Request() req,
) {
return this.tripsService.addDay(req.user.id, tripId, createDto);
}
@Patch(':tripId/days/:dayId')
@ApiOperation({ summary: 'Update day' })
updateDay(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() updateDto: UpdateTripDayDto,
@Request() req,
) {
return this.tripsService.updateDay(req.user.id, tripId, dayId, updateDto);
}
@Delete(':tripId/days/:dayId')
@ApiOperation({ summary: 'Remove day' })
removeDay(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Request() req,
) {
return this.tripsService.removeDay(req.user.id, tripId, dayId);
}
// Activities
@Post(':tripId/days/:dayId/activities')
@ApiOperation({ summary: 'Add activity to day' })
addActivity(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() createDto: CreateTripActivityDto,
@Request() req,
) {
return this.tripsService.addActivity(req.user.id, tripId, dayId, createDto);
}
@Patch(':tripId/days/:dayId/activities/:activityId')
@ApiOperation({ summary: 'Update activity' })
updateActivity(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Param('activityId') activityId: string,
@Body() updateDto: UpdateTripActivityDto,
@Request() req,
) {
return this.tripsService.updateActivity(req.user.id, tripId, dayId, activityId, updateDto);
}
@Delete(':tripId/days/:dayId/activities/:activityId')
@ApiOperation({ summary: 'Remove activity' })
removeActivity(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Param('activityId') activityId: string,
@Request() req,
) {
return this.tripsService.removeActivity(req.user.id, tripId, dayId, activityId);
}
@Patch(':tripId/days/:dayId/activities/order')
@ApiOperation({ summary: 'Reorder activities in day' })
reorderActivities(
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() body: { activityIds: string[] },
@Request() req,
) {
return this.tripsService.reorderActivities(req.user.id, tripId, dayId, body.activityIds);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TripsService } from './trips.service';
import { TripsController } from './trips.controller';
import { UserTrip, TripDay, TripActivity } from '../../entities/user-trip.entity';
@Module({
imports: [TypeOrmModule.forFeature([UserTrip, TripDay, TripActivity])],
controllers: [TripsController],
providers: [TripsService],
exports: [TripsService],
})
export class TripsModule {}

View File

@@ -0,0 +1,181 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserTrip, TripDay, TripActivity, TripStatus } from '../../entities/user-trip.entity';
import { CreateTripDto, CreateTripDayDto, CreateTripActivityDto } from './dto/create-trip.dto';
import { UpdateTripDto, UpdateTripDayDto, UpdateTripActivityDto } from './dto/update-trip.dto';
@Injectable()
export class TripsService {
constructor(
@InjectRepository(UserTrip)
private readonly tripRepository: Repository<UserTrip>,
@InjectRepository(TripDay)
private readonly dayRepository: Repository<TripDay>,
@InjectRepository(TripActivity)
private readonly activityRepository: Repository<TripActivity>,
) {}
async create(userId: string, createDto: CreateTripDto): Promise<UserTrip> {
const trip = this.tripRepository.create({
...createDto,
userId,
status: TripStatus.PLANNING,
});
return this.tripRepository.save(trip);
}
async findAllByUser(
userId: string,
status?: TripStatus,
page = 1,
limit = 20,
): Promise<{ data: UserTrip[]; total: number }> {
const where: any = { userId };
if (status) where.status = status;
const [data, total] = await this.tripRepository.findAndCount({
where,
relations: ['days', 'days.activities'],
order: { updatedAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return { data, total };
}
async findOne(userId: string, id: string): Promise<UserTrip> {
const trip = await this.tripRepository.findOne({
where: { id },
relations: ['days', 'days.activities'],
});
if (!trip) throw new NotFoundException('Trip not found');
if (trip.userId !== userId && !trip.isPublic) {
throw new ForbiddenException('Access denied');
}
if (trip.days) {
trip.days.sort((a, b) => a.dayNumber - b.dayNumber);
trip.days.forEach(day => {
if (day.activities) {
day.activities.sort((a, b) => a.sortOrder - b.sortOrder);
}
});
}
return trip;
}
async update(userId: string, id: string, updateDto: UpdateTripDto): Promise<UserTrip> {
const trip = await this.tripRepository.findOne({ where: { id, userId } });
if (!trip) throw new NotFoundException('Trip not found');
Object.assign(trip, updateDto);
return this.tripRepository.save(trip);
}
async remove(userId: string, id: string): Promise<void> {
const trip = await this.tripRepository.findOne({ where: { id, userId } });
if (!trip) throw new NotFoundException('Trip not found');
await this.tripRepository.remove(trip);
}
async addDay(userId: string, tripId: string, createDto: CreateTripDayDto): Promise<TripDay> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const day = this.dayRepository.create({ ...createDto, tripId });
return this.dayRepository.save(day);
}
async updateDay(userId: string, tripId: string, dayId: string, updateDto: UpdateTripDayDto): Promise<TripDay> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const day = await this.dayRepository.findOne({ where: { id: dayId, tripId } });
if (!day) throw new NotFoundException('Day not found');
Object.assign(day, updateDto);
return this.dayRepository.save(day);
}
async removeDay(userId: string, tripId: string, dayId: string): Promise<void> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const day = await this.dayRepository.findOne({ where: { id: dayId, tripId } });
if (!day) throw new NotFoundException('Day not found');
await this.dayRepository.remove(day);
}
async addActivity(userId: string, tripId: string, dayId: string, createDto: CreateTripActivityDto): Promise<TripActivity> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const day = await this.dayRepository.findOne({ where: { id: dayId, tripId } });
if (!day) throw new NotFoundException('Day not found');
const maxOrder = await this.activityRepository
.createQueryBuilder('a')
.select('MAX(a.sort_order)', 'max')
.where('a.day_id = :dayId', { dayId })
.getRawOne();
const activity = this.activityRepository.create({
...createDto,
dayId,
sortOrder: (maxOrder?.max || 0) + 1,
});
return this.activityRepository.save(activity);
}
async updateActivity(userId: string, tripId: string, dayId: string, activityId: string, updateDto: UpdateTripActivityDto): Promise<TripActivity> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const activity = await this.activityRepository.findOne({ where: { id: activityId, dayId } });
if (!activity) throw new NotFoundException('Activity not found');
Object.assign(activity, updateDto);
return this.activityRepository.save(activity);
}
async removeActivity(userId: string, tripId: string, dayId: string, activityId: string): Promise<void> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
const activity = await this.activityRepository.findOne({ where: { id: activityId, dayId } });
if (!activity) throw new NotFoundException('Activity not found');
await this.activityRepository.remove(activity);
}
async reorderActivities(userId: string, tripId: string, dayId: string, activityIds: string[]): Promise<void> {
const trip = await this.tripRepository.findOne({ where: { id: tripId, userId } });
if (!trip) throw new NotFoundException('Trip not found');
await Promise.all(
activityIds.map((id, index) =>
this.activityRepository.update({ id, dayId }, { sortOrder: index })
)
);
}
async getTripStats(userId: string): Promise<Record<TripStatus, number>> {
const counts = await this.tripRepository
.createQueryBuilder('t')
.select('t.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('t.user_id = :userId', { userId })
.groupBy('t.status')
.getRawMany();
const result: Record<string, number> = {};
Object.values(TripStatus).forEach(s => (result[s] = 0));
counts.forEach(c => (result[c.status] = parseInt(c.count, 10)));
return result as Record<TripStatus, number>;
}
}