feat: add P0/P1/P2 features + Swagger enrichment for MVP completeness
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 12s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 53s
Deploy / Build API Image (push) Failing after 22s
Deploy / Build Web Image (push) Failing after 14s
Deploy / Build AI Services Image (push) Failing after 12s
E2E Tests / Playwright E2E (push) Failing after 9s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 2s
Security Scanning / Trivy Scan — API Image (push) Failing after 50s
Security Scanning / Trivy Scan — Web Image (push) Failing after 38s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 36s
Security Scanning / Trivy Filesystem Scan (push) Failing after 33s
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 12s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 53s
Deploy / Build API Image (push) Failing after 22s
Deploy / Build Web Image (push) Failing after 14s
Deploy / Build AI Services Image (push) Failing after 12s
E2E Tests / Playwright E2E (push) Failing after 9s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 2s
Security Scanning / Trivy Scan — API Image (push) Failing after 50s
Security Scanning / Trivy Scan — Web Image (push) Failing after 38s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 36s
Security Scanning / Trivy Filesystem Scan (push) Failing after 33s
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Closes four gaps the Swagger audit flagged as blocking a full MVP demo,
plus a general documentation pass.
P0 — Forgot/Reset password (auth)
- POST /auth/forgot-password (anti-enumeration: always 200)
- POST /auth/reset-password
- Reuses the Redis-OTP pattern from email/phone change; new key prefix
auth:password_reset_otp with 15-min TTL.
- Emits PasswordResetRequestedEvent; new listener in notifications
dispatches the existing password.reset email template (otp +
expiryMinutes variables already in template.service.ts).
- UserEntity gains changePassword(HashedPassword) domain method; reset
also revokes all refresh tokens for the user.
P0 — Favorites module
- New SavedListing Prisma model (unique(userId, listingId)) with User
and Listing back-relations; schema pushed via db push since the
remote DB was out of sync with migration history.
- New apps/api/src/modules/favorites/ module following the reviews
module's shape (DDD/CQRS: domain repo + Prisma impl + 2 commands
+ 2 queries + controller).
- POST /favorites/:listingId, DELETE /favorites/:listingId,
GET /favorites (paginated), GET /favorites/:listingId/check. All
guarded by JwtAuthGuard.
- FavoritesModule wired into AppModule.
P1 — Resend OTP (auth)
- POST /auth/resend-otp for EMAIL_CHANGE | PHONE_CHANGE. Reads the
pending OTP payload out of Redis and re-emits the original event
without minting a new code, so TTL semantics stay intact. Password
reset resend is done by re-POSTing /auth/forgot-password and is
deliberately not in this enum.
P1 — Agent self-upgrade (agents)
- POST /agents/me/upgrade lets a BUYER/SELLER convert to AGENT. Creates
an Agent row (isVerified=false) and flips User.role in one
$transaction. Rejects if already AGENT/ADMIN or if an Agent row
already exists.
P2 — Swagger enrichment
- @ApiConsumes('multipart/form-data') + body schema on listings media
upload.
- GET /subscriptions/quota/:metric now enumerates the real metric
values from METRIC_TO_PLAN_FIELD.
- POST /avm/batch and /analytics/valuation/batch document the max=50
batch size from their DTO's @ArrayMaxSize.
- GET /admin/dashboard gains a realistic response example schema.
- Admin-gated endpoints in projects/transfer/industrial gain concrete
400/401/403/404 responses.
Swagger endpoint count: 170 → 178. Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export class AddFavoriteCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly listingId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import {
|
||||
SAVED_LISTING_REPOSITORY,
|
||||
type ISavedListingRepository,
|
||||
} from '../../../domain/repositories/saved-listing.repository';
|
||||
import { AddFavoriteCommand } from './add-favorite.command';
|
||||
|
||||
export interface AddFavoriteResult {
|
||||
id: string;
|
||||
listingId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@CommandHandler(AddFavoriteCommand)
|
||||
export class AddFavoriteHandler implements ICommandHandler<AddFavoriteCommand> {
|
||||
constructor(
|
||||
@Inject(SAVED_LISTING_REPOSITORY)
|
||||
private readonly savedListingRepo: ISavedListingRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: AddFavoriteCommand): Promise<AddFavoriteResult> {
|
||||
try {
|
||||
const saved = await this.savedListingRepo.add(
|
||||
command.userId,
|
||||
command.listingId,
|
||||
);
|
||||
this.logger.log(
|
||||
`User ${command.userId} favorited listing ${command.listingId}`,
|
||||
'AddFavoriteHandler',
|
||||
);
|
||||
return {
|
||||
id: saved.id,
|
||||
listingId: command.listingId,
|
||||
createdAt: saved.createdAt.toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to add favorite: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'AddFavoriteHandler',
|
||||
);
|
||||
throw new InternalServerErrorException('Lỗi khi thêm yêu thích');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class RemoveFavoriteCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly listingId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import {
|
||||
SAVED_LISTING_REPOSITORY,
|
||||
type ISavedListingRepository,
|
||||
} from '../../../domain/repositories/saved-listing.repository';
|
||||
import { RemoveFavoriteCommand } from './remove-favorite.command';
|
||||
|
||||
@CommandHandler(RemoveFavoriteCommand)
|
||||
export class RemoveFavoriteHandler
|
||||
implements ICommandHandler<RemoveFavoriteCommand>
|
||||
{
|
||||
constructor(
|
||||
@Inject(SAVED_LISTING_REPOSITORY)
|
||||
private readonly savedListingRepo: ISavedListingRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: RemoveFavoriteCommand): Promise<void> {
|
||||
try {
|
||||
await this.savedListingRepo.remove(command.userId, command.listingId);
|
||||
this.logger.log(
|
||||
`User ${command.userId} unfavorited listing ${command.listingId}`,
|
||||
'RemoveFavoriteHandler',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to remove favorite: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'RemoveFavoriteHandler',
|
||||
);
|
||||
throw new InternalServerErrorException('Lỗi khi xóa yêu thích');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import {
|
||||
SAVED_LISTING_REPOSITORY,
|
||||
type ISavedListingRepository,
|
||||
} from '../../../domain/repositories/saved-listing.repository';
|
||||
import { IsFavoritedQuery } from './is-favorited.query';
|
||||
|
||||
export interface IsFavoritedResult {
|
||||
favorited: boolean;
|
||||
}
|
||||
|
||||
@QueryHandler(IsFavoritedQuery)
|
||||
export class IsFavoritedHandler implements IQueryHandler<IsFavoritedQuery> {
|
||||
constructor(
|
||||
@Inject(SAVED_LISTING_REPOSITORY)
|
||||
private readonly savedListingRepo: ISavedListingRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: IsFavoritedQuery): Promise<IsFavoritedResult> {
|
||||
try {
|
||||
const favorited = await this.savedListingRepo.exists(
|
||||
query.userId,
|
||||
query.listingId,
|
||||
);
|
||||
return { favorited };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to check favorite status: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'IsFavoritedHandler',
|
||||
);
|
||||
throw new InternalServerErrorException('Lỗi khi kiểm tra yêu thích');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class IsFavoritedQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly listingId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import {
|
||||
SAVED_LISTING_REPOSITORY,
|
||||
type FavoriteItem,
|
||||
type ISavedListingRepository,
|
||||
} from '../../../domain/repositories/saved-listing.repository';
|
||||
import { ListFavoritesQuery } from './list-favorites.query';
|
||||
|
||||
export interface ListFavoritesResult {
|
||||
data: FavoriteItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@QueryHandler(ListFavoritesQuery)
|
||||
export class ListFavoritesHandler
|
||||
implements IQueryHandler<ListFavoritesQuery>
|
||||
{
|
||||
constructor(
|
||||
@Inject(SAVED_LISTING_REPOSITORY)
|
||||
private readonly savedListingRepo: ISavedListingRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: ListFavoritesQuery): Promise<ListFavoritesResult> {
|
||||
try {
|
||||
const page = Math.max(query.page, 1);
|
||||
const limit = Math.min(Math.max(query.limit, 1), 100);
|
||||
const { data, total } = await this.savedListingRepo.listByUser(
|
||||
query.userId,
|
||||
{ page, limit },
|
||||
);
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: limit > 0 ? Math.ceil(total / limit) : 0,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to list favorites: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'ListFavoritesHandler',
|
||||
);
|
||||
throw new InternalServerErrorException('Lỗi khi lấy danh sách yêu thích');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class ListFavoritesQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const SAVED_LISTING_REPOSITORY = Symbol('SAVED_LISTING_REPOSITORY');
|
||||
|
||||
export interface FavoriteListingSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
priceVND: string;
|
||||
city: string;
|
||||
district: string;
|
||||
thumbnailUrl: string | null;
|
||||
status: string;
|
||||
transactionType: string;
|
||||
}
|
||||
|
||||
export interface FavoriteItem {
|
||||
id: string;
|
||||
listingId: string;
|
||||
createdAt: Date;
|
||||
listing: FavoriteListingSummary;
|
||||
}
|
||||
|
||||
export interface ListFavoritesParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ListFavoritesResult {
|
||||
data: FavoriteItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ISavedListingRepository {
|
||||
add(userId: string, listingId: string): Promise<{ id: string; createdAt: Date }>;
|
||||
remove(userId: string, listingId: string): Promise<void>;
|
||||
exists(userId: string, listingId: string): Promise<boolean>;
|
||||
listByUser(userId: string, params: ListFavoritesParams): Promise<ListFavoritesResult>;
|
||||
}
|
||||
24
apps/api/src/modules/favorites/favorites.module.ts
Normal file
24
apps/api/src/modules/favorites/favorites.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AddFavoriteHandler } from './application/commands/add-favorite/add-favorite.handler';
|
||||
import { RemoveFavoriteHandler } from './application/commands/remove-favorite/remove-favorite.handler';
|
||||
import { IsFavoritedHandler } from './application/queries/is-favorited/is-favorited.handler';
|
||||
import { ListFavoritesHandler } from './application/queries/list-favorites/list-favorites.handler';
|
||||
import { SAVED_LISTING_REPOSITORY } from './domain/repositories/saved-listing.repository';
|
||||
import { PrismaSavedListingRepository } from './infrastructure/repositories/prisma-saved-listing.repository';
|
||||
import { FavoritesController } from './presentation/controllers/favorites.controller';
|
||||
|
||||
const CommandHandlers = [AddFavoriteHandler, RemoveFavoriteHandler];
|
||||
const QueryHandlers = [ListFavoritesHandler, IsFavoritedHandler];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
controllers: [FavoritesController],
|
||||
providers: [
|
||||
{ provide: SAVED_LISTING_REPOSITORY, useClass: PrismaSavedListingRepository },
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [SAVED_LISTING_REPOSITORY],
|
||||
})
|
||||
export class FavoritesModule {}
|
||||
9
apps/api/src/modules/favorites/index.ts
Normal file
9
apps/api/src/modules/favorites/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { FavoritesModule } from './favorites.module';
|
||||
export {
|
||||
SAVED_LISTING_REPOSITORY,
|
||||
ISavedListingRepository,
|
||||
type FavoriteItem,
|
||||
type FavoriteListingSummary,
|
||||
type ListFavoritesParams,
|
||||
type ListFavoritesResult,
|
||||
} from './domain/repositories/saved-listing.repository';
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { ConflictException, NotFoundException, PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type FavoriteItem,
|
||||
type ISavedListingRepository,
|
||||
type ListFavoritesParams,
|
||||
type ListFavoritesResult,
|
||||
} from '../../domain/repositories/saved-listing.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaSavedListingRepository implements ISavedListingRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async add(
|
||||
userId: string,
|
||||
listingId: string,
|
||||
): Promise<{ id: string; createdAt: Date }> {
|
||||
try {
|
||||
const saved = await this.prisma.savedListing.create({
|
||||
data: { userId, listingId },
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
return saved;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
throw new ConflictException('Đã yêu thích tin đăng này');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(userId: string, listingId: string): Promise<void> {
|
||||
const result = await this.prisma.savedListing.deleteMany({
|
||||
where: { userId, listingId },
|
||||
});
|
||||
if (result.count === 0) {
|
||||
throw new NotFoundException('SavedListing');
|
||||
}
|
||||
}
|
||||
|
||||
async exists(userId: string, listingId: string): Promise<boolean> {
|
||||
const count = await this.prisma.savedListing.count({
|
||||
where: { userId, listingId },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async listByUser(
|
||||
userId: string,
|
||||
params: ListFavoritesParams,
|
||||
): Promise<ListFavoritesResult> {
|
||||
const take = Math.min(Math.max(params.limit, 1), 100);
|
||||
const page = Math.max(params.page, 1);
|
||||
const skip = (page - 1) * take;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.savedListing.findMany({
|
||||
where: { userId },
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
listing: {
|
||||
include: {
|
||||
property: {
|
||||
include: {
|
||||
media: { take: 1, orderBy: { order: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.savedListing.count({ where: { userId } }),
|
||||
]);
|
||||
|
||||
const data: FavoriteItem[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
listingId: row.listingId,
|
||||
createdAt: row.createdAt,
|
||||
listing: {
|
||||
id: row.listing.id,
|
||||
title: row.listing.property.title,
|
||||
priceVND: row.listing.priceVND.toString(),
|
||||
city: row.listing.property.city,
|
||||
district: row.listing.property.district,
|
||||
thumbnailUrl: row.listing.property.media[0]?.url ?? null,
|
||||
status: row.listing.status,
|
||||
transactionType: row.listing.transactionType,
|
||||
},
|
||||
}));
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '@modules/auth';
|
||||
import { AddFavoriteCommand } from '../../application/commands/add-favorite/add-favorite.command';
|
||||
import { type AddFavoriteResult } from '../../application/commands/add-favorite/add-favorite.handler';
|
||||
import { RemoveFavoriteCommand } from '../../application/commands/remove-favorite/remove-favorite.command';
|
||||
import { IsFavoritedQuery } from '../../application/queries/is-favorited/is-favorited.query';
|
||||
import { type IsFavoritedResult } from '../../application/queries/is-favorited/is-favorited.handler';
|
||||
import { ListFavoritesQuery } from '../../application/queries/list-favorites/list-favorites.query';
|
||||
import { type ListFavoritesResult } from '../../application/queries/list-favorites/list-favorites.handler';
|
||||
import { ListFavoritesDto } from '../dto/list-favorites.dto';
|
||||
|
||||
@ApiTags('favorites')
|
||||
@Controller('favorites')
|
||||
export class FavoritesController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Add a listing to favorites' })
|
||||
@ApiParam({ name: 'listingId', description: 'Listing ID to favorite' })
|
||||
@ApiResponse({ status: 201, description: 'Listing added to favorites' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 409, description: 'Listing already favorited' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':listingId')
|
||||
async addFavorite(
|
||||
@Param('listingId') listingId: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<AddFavoriteResult> {
|
||||
return this.commandBus.execute(new AddFavoriteCommand(user.sub, listingId));
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Remove a listing from favorites' })
|
||||
@ApiParam({ name: 'listingId', description: 'Listing ID to remove' })
|
||||
@ApiResponse({ status: 200, description: 'Listing removed from favorites' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Favorite not found' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':listingId')
|
||||
async removeFavorite(
|
||||
@Param('listingId') listingId: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.commandBus.execute(
|
||||
new RemoveFavoriteCommand(user.sub, listingId),
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'List authenticated user favorites' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated list of favorites' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
async listFavorites(
|
||||
@Query() dto: ListFavoritesDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<ListFavoritesResult> {
|
||||
return this.queryBus.execute(
|
||||
new ListFavoritesQuery(user.sub, dto.page ?? 1, dto.limit ?? 20),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Check whether a listing is favorited by the user' })
|
||||
@ApiParam({ name: 'listingId', description: 'Listing ID to check' })
|
||||
@ApiResponse({ status: 200, description: 'Favorited flag' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get(':listingId/check')
|
||||
async isFavorited(
|
||||
@Param('listingId') listingId: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<IsFavoritedResult> {
|
||||
return this.queryBus.execute(new IsFavoritedQuery(user.sub, listingId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListFavoritesDto {
|
||||
@ApiPropertyOptional({ example: 1, description: 'Page number', default: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 20, description: 'Items per page', default: 20 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user