This commit is contained in:
commit
e980536871
23
.env.example
Normal file
23
.env.example
Normal file
@ -0,0 +1,23 @@
|
||||
# Application
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=portfolio
|
||||
|
||||
# JWT Configuration
|
||||
JWT_ACCESS_SECRET=your-super-secret-access-key-change-in-production
|
||||
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-in-production
|
||||
JWT_ACCESS_EXPIRATION=15m
|
||||
JWT_REFRESH_EXPIRATION=7d
|
||||
|
||||
# Admin username (password set via /auth/setup on first run)
|
||||
ADMIN_USERNAME=admin
|
||||
|
||||
# Cookie Settings
|
||||
COOKIE_DOMAIN=localhost
|
||||
COOKIE_SECURE=false
|
||||
24
.gitea/workflows/deploy-production.yml
Normal file
24
.gitea/workflows/deploy-production.yml
Normal file
@ -0,0 +1,24 @@
|
||||
name: Deploy Production
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker:latest
|
||||
steps:
|
||||
- name: Install git
|
||||
run: apk add --no-cache git
|
||||
|
||||
- name: Clone repository
|
||||
run: git clone --depth 1 https://git.ai-assistant-bot.xyz/root/portfolio-api.git .
|
||||
|
||||
- name: Build & Deploy
|
||||
run: |
|
||||
docker compose -f docker-compose.yml build
|
||||
docker compose -f docker-compose.yml up -d
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Misc
|
||||
*.tgz
|
||||
73
Dockerfile
Normal file
73
Dockerfile
Normal file
@ -0,0 +1,73 @@
|
||||
# ============================================
|
||||
# Development Stage
|
||||
# ============================================
|
||||
FROM node:18-alpine AS development
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies for native modules
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies (including devDependencies)
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start in development mode
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
|
||||
# ============================================
|
||||
# Build Stage
|
||||
# ============================================
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies for native modules
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Prune dev dependencies
|
||||
RUN npm prune --production
|
||||
|
||||
# ============================================
|
||||
# Production Stage
|
||||
# ============================================
|
||||
FROM node:18-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nestjs -u 1001
|
||||
|
||||
COPY --from=build --chown=nestjs:nodejs /app/dist ./dist
|
||||
COPY --from=build --chown=nestjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=nestjs:nodejs /app/package*.json ./
|
||||
COPY --from=build --chown=nestjs:nodejs /app/scripts ./scripts
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
USER nestjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["sh", "-c", "node dist/main.js"]
|
||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@ -0,0 +1,40 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
container_name: api_porfolio
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USERNAME: portfolio_user
|
||||
DB_PASSWORD: portfolio123
|
||||
DB_NAME: portfolio
|
||||
JWT_SECRET: jLqumBtNFomOkVy8GqktMpjxpZ9Ej4iKdsCo89hbiJG
|
||||
JWT_REFRESH_SECRET: RzcodpRglHs4Xqs5pwCFxwNacqjYVNUKm2lVtwiwxOD
|
||||
COOKIE_DOMAIN: altricade.github.io
|
||||
COOKIE_SECURE: true
|
||||
JWT_ACCESS_EXPIRATION: 15m
|
||||
JWT_REFRESH_EXPIRATION: 7d
|
||||
PORT: 3000
|
||||
networks:
|
||||
- proxy
|
||||
- internal
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=proxy
|
||||
- traefik.http.routers.api-portfolio.rule=Host(`api-portfolio.ai-assistant-bot.xyz`)
|
||||
- traefik.http.routers.api-portfolio.entrypoints=web,websecure
|
||||
- traefik.http.routers.api-portfolio.tls.certresolver=le
|
||||
- traefik.http.services.api-portfolio.loadbalancer.server.port=3000
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
internal:
|
||||
external: true
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
5902
package-lock.json
generated
Normal file
5902
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "portfolio-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.12",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.12",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.12",
|
||||
"@nestjs/swagger": "^11.2.5",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.17.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
39
src/app.module.ts
Normal file
39
src/app.module.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ArticlesModule } from './articles/articles.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { MessagesModule } from './messages/messages.module';
|
||||
import { User } from './users/entities';
|
||||
import { Article } from './articles/entities';
|
||||
import { Message } from './messages/entities';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get<string>('DB_HOST') || 'localhost',
|
||||
port: configService.get<number>('DB_PORT') || 5432,
|
||||
username: configService.get<string>('DB_USERNAME') || 'postgres',
|
||||
password: configService.get<string>('DB_PASSWORD') || 'postgres',
|
||||
database: configService.get<string>('DB_NAME') || 'portfolio',
|
||||
entities: [User, Article, Message],
|
||||
synchronize: configService.get<string>('NODE_ENV') !== 'production',
|
||||
logging: configService.get<string>('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
ArticlesModule,
|
||||
MessagesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
199
src/articles/articles.controller.ts
Normal file
199
src/articles/articles.controller.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiCookieAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { CreateArticleDto, UpdateArticleDto, ArticlesQueryDto } from './dto';
|
||||
import { Article, ArticlePreview } from './entities';
|
||||
import { JwtAuthGuard } from '../auth/guards';
|
||||
|
||||
@ApiTags('articles')
|
||||
@Controller('articles')
|
||||
export class ArticlesController {
|
||||
constructor(private readonly articlesService: ArticlesService) {}
|
||||
|
||||
// ==================== PUBLIC ENDPOINTS ====================
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all published articles with previews' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||
@ApiQuery({ name: 'tag', required: false, type: String, example: 'nestjs' })
|
||||
@ApiQuery({ name: 'search', required: false, type: String, example: 'api' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of published articles with pagination',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
articles: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ArticlePreview' },
|
||||
},
|
||||
total: { type: 'number', example: 25 },
|
||||
page: { type: 'number', example: 1 },
|
||||
limit: { type: 'number', example: 10 },
|
||||
},
|
||||
},
|
||||
})
|
||||
async findAllPublished(@Query() query: ArticlesQueryDto) {
|
||||
return this.articlesService.findAll(query, false);
|
||||
}
|
||||
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: 'Get all unique tags from published articles' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of unique tags',
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['nestjs', 'typescript', 'nodejs'],
|
||||
},
|
||||
})
|
||||
async getAllTags() {
|
||||
return this.articlesService.getAllTags();
|
||||
}
|
||||
|
||||
@Get('slug/:slug')
|
||||
@ApiOperation({ summary: 'Get a published article by slug' })
|
||||
@ApiParam({ name: 'slug', description: 'Article slug', example: 'getting-started-with-nestjs' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The article',
|
||||
type: Article,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Article not found' })
|
||||
async findBySlug(@Param('slug') slug: string) {
|
||||
return this.articlesService.findBySlug(slug, false);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a published article by ID' })
|
||||
@ApiParam({ name: 'id', description: 'Article ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The article',
|
||||
type: Article,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Article not found' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
const article = await this.articlesService.findOne(id);
|
||||
if (!article.published) {
|
||||
throw new Error('Article not found');
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
// ==================== ADMIN ENDPOINTS ====================
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Create a new article (Admin only)' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Article created successfully',
|
||||
type: Article,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async create(@Body() createArticleDto: CreateArticleDto) {
|
||||
return this.articlesService.create(createArticleDto);
|
||||
}
|
||||
|
||||
@Get('admin/all')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Get all articles including unpublished (Admin only)' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||
@ApiQuery({ name: 'published', required: false, type: Boolean })
|
||||
@ApiQuery({ name: 'tag', required: false, type: String, example: 'nestjs' })
|
||||
@ApiQuery({ name: 'search', required: false, type: String, example: 'api' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of all articles with pagination',
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async findAllAdmin(@Query() query: ArticlesQueryDto) {
|
||||
return this.articlesService.findAll(query, true);
|
||||
}
|
||||
|
||||
@Get('admin/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Get any article by ID (Admin only)' })
|
||||
@ApiParam({ name: 'id', description: 'Article ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The article',
|
||||
type: Article,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Article not found' })
|
||||
async findOneAdmin(@Param('id') id: string) {
|
||||
return this.articlesService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('admin/slug/:slug')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Get any article by slug (Admin only)' })
|
||||
@ApiParam({ name: 'slug', description: 'Article slug' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The article',
|
||||
type: Article,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Article not found' })
|
||||
async findBySlugAdmin(@Param('slug') slug: string) {
|
||||
return this.articlesService.findBySlug(slug, true);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Update an article (Admin only)' })
|
||||
@ApiParam({ name: 'id', description: 'Article ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Article updated successfully',
|
||||
type: Article,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Article not found' })
|
||||
async update(@Param('id') id: string, @Body() updateArticleDto: UpdateArticleDto) {
|
||||
return this.articlesService.update(id, updateArticleDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Delete an article (Admin only)' })
|
||||
@ApiParam({ name: 'id', description: 'Article ID' })
|
||||
@ApiResponse({ status: 204, description: 'Article deleted successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Article not found' })
|
||||
async remove(@Param('id') id: string) {
|
||||
await this.articlesService.remove(id);
|
||||
}
|
||||
}
|
||||
13
src/articles/articles.module.ts
Normal file
13
src/articles/articles.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { ArticlesController } from './articles.controller';
|
||||
import { Article } from './entities';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Article])],
|
||||
controllers: [ArticlesController],
|
||||
providers: [ArticlesService],
|
||||
exports: [ArticlesService],
|
||||
})
|
||||
export class ArticlesModule {}
|
||||
155
src/articles/articles.service.ts
Normal file
155
src/articles/articles.service.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, ILike } from 'typeorm';
|
||||
import { Article, ArticlePreview } from './entities';
|
||||
import { CreateArticleDto, UpdateArticleDto, ArticlesQueryDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class ArticlesService {
|
||||
constructor(
|
||||
@InjectRepository(Article)
|
||||
private articlesRepository: Repository<Article>,
|
||||
) {}
|
||||
|
||||
private generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
private toPreview(article: Article): ArticlePreview {
|
||||
const { content, ...preview } = article;
|
||||
return preview;
|
||||
}
|
||||
|
||||
async create(createArticleDto: CreateArticleDto): Promise<Article> {
|
||||
const slug = this.generateSlug(createArticleDto.title);
|
||||
|
||||
const article = this.articlesRepository.create({
|
||||
title: createArticleDto.title,
|
||||
slug,
|
||||
preview: createArticleDto.preview,
|
||||
content: createArticleDto.content,
|
||||
coverImage: createArticleDto.coverImage,
|
||||
tags: createArticleDto.tags || [],
|
||||
published: createArticleDto.published ?? false,
|
||||
});
|
||||
|
||||
return this.articlesRepository.save(article);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query: ArticlesQueryDto,
|
||||
includeUnpublished = false,
|
||||
): Promise<{ articles: ArticlePreview[]; total: number; page: number; limit: number }> {
|
||||
const { page = 1, limit = 10, published, tag, search } = query;
|
||||
|
||||
const queryBuilder = this.articlesRepository.createQueryBuilder('article');
|
||||
|
||||
// Filter by published status
|
||||
if (!includeUnpublished) {
|
||||
queryBuilder.andWhere('article.published = :published', { published: true });
|
||||
} else if (published !== undefined) {
|
||||
queryBuilder.andWhere('article.published = :published', { published });
|
||||
}
|
||||
|
||||
// Search in title and preview
|
||||
if (search) {
|
||||
queryBuilder.andWhere(
|
||||
'(LOWER(article.title) LIKE LOWER(:search) OR LOWER(article.preview) LIKE LOWER(:search))',
|
||||
{ search: `%${search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by tag (tags stored as comma-separated string)
|
||||
if (tag) {
|
||||
queryBuilder.andWhere(
|
||||
'(article.tags LIKE :tagStart OR article.tags LIKE :tagMiddle OR article.tags LIKE :tagEnd OR article.tags = :tagExact)',
|
||||
{
|
||||
tagStart: `${tag},%`,
|
||||
tagMiddle: `%,${tag},%`,
|
||||
tagEnd: `%,${tag}`,
|
||||
tagExact: tag,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by createdAt (newest first)
|
||||
queryBuilder.orderBy('article.createdAt', 'DESC');
|
||||
|
||||
// Get total count
|
||||
const total = await queryBuilder.getCount();
|
||||
|
||||
// Pagination
|
||||
queryBuilder.skip((page - 1) * limit).take(limit);
|
||||
|
||||
const articles = await queryBuilder.getMany();
|
||||
|
||||
return {
|
||||
articles: articles.map((a) => this.toPreview(a)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Article> {
|
||||
const article = await this.articlesRepository.findOne({ where: { id } });
|
||||
if (!article) {
|
||||
throw new NotFoundException(`Article with ID ${id} not found`);
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string, includeUnpublished = false): Promise<Article> {
|
||||
const whereClause: { slug: string; published?: boolean } = { slug };
|
||||
if (!includeUnpublished) {
|
||||
whereClause.published = true;
|
||||
}
|
||||
|
||||
const article = await this.articlesRepository.findOne({ where: whereClause });
|
||||
|
||||
if (!article) {
|
||||
throw new NotFoundException(`Article with slug "${slug}" not found`);
|
||||
}
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
async update(id: string, updateArticleDto: UpdateArticleDto): Promise<Article> {
|
||||
const article = await this.findOne(id);
|
||||
|
||||
// Regenerate slug if title changed
|
||||
if (updateArticleDto.title) {
|
||||
updateArticleDto = {
|
||||
...updateArticleDto,
|
||||
slug: this.generateSlug(updateArticleDto.title),
|
||||
} as UpdateArticleDto & { slug: string };
|
||||
}
|
||||
|
||||
Object.assign(article, updateArticleDto);
|
||||
return this.articlesRepository.save(article);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const article = await this.findOne(id);
|
||||
await this.articlesRepository.remove(article);
|
||||
}
|
||||
|
||||
async getAllTags(): Promise<string[]> {
|
||||
const articles = await this.articlesRepository.find({
|
||||
where: { published: true },
|
||||
select: ['tags'],
|
||||
});
|
||||
|
||||
const tagsSet = new Set<string>();
|
||||
articles.forEach((article) => {
|
||||
if (article.tags) {
|
||||
article.tags.forEach((tag) => tagsSet.add(tag));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(tagsSet).sort();
|
||||
}
|
||||
}
|
||||
62
src/articles/dto/articles-query.dto.ts
Normal file
62
src/articles/dto/articles-query.dto.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsBoolean, IsString, IsInt, Min, Max } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
|
||||
export class ArticlesQueryDto {
|
||||
@ApiProperty({
|
||||
description: 'Page number (1-based)',
|
||||
example: 1,
|
||||
default: 1,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Number of items per page',
|
||||
example: 10,
|
||||
default: 10,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 10;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Filter by published status (admin only can see unpublished)',
|
||||
example: true,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
published?: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Filter by tag',
|
||||
example: 'nestjs',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tag?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Search in title and preview',
|
||||
example: 'nestjs',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
72
src/articles/dto/create-article.dto.ts
Normal file
72
src/articles/dto/create-article.dto.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsArray,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateArticleDto {
|
||||
@ApiProperty({
|
||||
description: 'Article title',
|
||||
example: 'Getting Started with NestJS',
|
||||
minLength: 3,
|
||||
maxLength: 200,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
@MaxLength(200)
|
||||
title: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Short preview/excerpt of the article (max 500 characters)',
|
||||
example: 'Learn how to build scalable Node.js applications with NestJS framework...',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
preview: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Full article content (Markdown supported)',
|
||||
example: '# Introduction\n\nNestJS is a progressive Node.js framework...',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Cover image URL',
|
||||
example: 'https://example.com/images/nestjs-cover.jpg',
|
||||
required: false,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
coverImage?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Article tags',
|
||||
example: ['nestjs', 'nodejs', 'typescript'],
|
||||
type: [String],
|
||||
required: false,
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether the article is published immediately',
|
||||
example: false,
|
||||
default: false,
|
||||
required: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
published?: boolean;
|
||||
}
|
||||
3
src/articles/dto/index.ts
Normal file
3
src/articles/dto/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './create-article.dto';
|
||||
export * from './update-article.dto';
|
||||
export * from './articles-query.dto';
|
||||
73
src/articles/dto/update-article.dto.ts
Normal file
73
src/articles/dto/update-article.dto.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsArray,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateArticleDto {
|
||||
@ApiProperty({
|
||||
description: 'Article title',
|
||||
example: 'Getting Started with NestJS - Updated',
|
||||
minLength: 3,
|
||||
maxLength: 200,
|
||||
required: false,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MinLength(3)
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Short preview/excerpt of the article (max 500 characters)',
|
||||
example: 'Updated: Learn how to build scalable Node.js applications...',
|
||||
maxLength: 500,
|
||||
required: false,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
preview?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Full article content (Markdown supported)',
|
||||
example: '# Introduction\n\nUpdated content...',
|
||||
required: false,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
content?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Cover image URL',
|
||||
example: 'https://example.com/images/new-cover.jpg',
|
||||
required: false,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
coverImage?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Article tags',
|
||||
example: ['nestjs', 'nodejs', 'typescript', 'backend'],
|
||||
type: [String],
|
||||
required: false,
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether the article is published',
|
||||
example: true,
|
||||
required: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
published?: boolean;
|
||||
}
|
||||
135
src/articles/entities/article.entity.ts
Normal file
135
src/articles/entities/article.entity.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
@Entity('articles')
|
||||
export class Article {
|
||||
@ApiProperty({
|
||||
description: 'Unique article identifier',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
})
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Article title',
|
||||
example: 'Getting Started with NestJS',
|
||||
})
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'URL-friendly slug',
|
||||
example: 'getting-started-with-nestjs',
|
||||
})
|
||||
@Column({ unique: true })
|
||||
slug: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Short preview/excerpt of the article',
|
||||
example: 'Learn how to build scalable Node.js applications with NestJS framework...',
|
||||
})
|
||||
@Column({ type: 'text' })
|
||||
preview: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Full article content (Markdown supported)',
|
||||
example: '# Introduction\n\nNestJS is a progressive Node.js framework...',
|
||||
})
|
||||
@Column({ type: 'text' })
|
||||
content: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Cover image URL',
|
||||
example: 'https://example.com/images/nestjs-cover.jpg',
|
||||
required: false,
|
||||
})
|
||||
@Column({ nullable: true })
|
||||
coverImage?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Article tags',
|
||||
example: ['nestjs', 'nodejs', 'typescript'],
|
||||
type: [String],
|
||||
})
|
||||
@Column('simple-array', { default: '' })
|
||||
tags: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether the article is published',
|
||||
example: true,
|
||||
})
|
||||
@Column({ default: false })
|
||||
published: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Article creation timestamp',
|
||||
example: '2024-01-15T10:30:00.000Z',
|
||||
})
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Article last update timestamp',
|
||||
example: '2024-01-16T14:20:00.000Z',
|
||||
})
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class ArticlePreview {
|
||||
@ApiProperty({
|
||||
description: 'Unique article identifier',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Article title',
|
||||
example: 'Getting Started with NestJS',
|
||||
})
|
||||
title: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'URL-friendly slug',
|
||||
example: 'getting-started-with-nestjs',
|
||||
})
|
||||
slug: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Short preview/excerpt of the article',
|
||||
example: 'Learn how to build scalable Node.js applications with NestJS framework...',
|
||||
})
|
||||
preview: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Cover image URL',
|
||||
example: 'https://example.com/images/nestjs-cover.jpg',
|
||||
required: false,
|
||||
})
|
||||
coverImage?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Article tags',
|
||||
example: ['nestjs', 'nodejs', 'typescript'],
|
||||
type: [String],
|
||||
})
|
||||
tags: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether the article is published',
|
||||
example: true,
|
||||
})
|
||||
published: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Article creation timestamp',
|
||||
example: '2024-01-15T10:30:00.000Z',
|
||||
})
|
||||
createdAt: Date;
|
||||
}
|
||||
1
src/articles/entities/index.ts
Normal file
1
src/articles/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './article.entity';
|
||||
5
src/articles/index.ts
Normal file
5
src/articles/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './articles.module';
|
||||
export * from './articles.service';
|
||||
export * from './articles.controller';
|
||||
export * from './dto';
|
||||
export * from './entities';
|
||||
257
src/auth/auth.controller.ts
Normal file
257
src/auth/auth.controller.ts
Normal file
@ -0,0 +1,257 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Res,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBody,
|
||||
ApiCookieAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { Response, Request } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto, ChangePasswordDto, SetupPasswordDto } from './dto';
|
||||
import { JwtAuthGuard, JwtRefreshGuard } from './guards';
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user: {
|
||||
userId: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Check if initial setup is required' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Returns setup status',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
needsSetup: { type: 'boolean', example: true },
|
||||
message: { type: 'string', example: 'Initial password setup required' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async status() {
|
||||
const needsSetup = await this.authService.needsSetup();
|
||||
return {
|
||||
needsSetup,
|
||||
message: needsSetup
|
||||
? 'Initial password setup required'
|
||||
: 'System is configured',
|
||||
};
|
||||
}
|
||||
|
||||
@Post('setup')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Initial admin password setup (first time only)' })
|
||||
@ApiBody({ type: SetupPasswordDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Password set successfully. JWT tokens set in HTTP-only cookies.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string', example: 'Password set successfully' },
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: '550e8400-e29b-41d4-a716-446655440000' },
|
||||
username: { type: 'string', example: 'admin' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Password already set' })
|
||||
@ApiResponse({ status: 401, description: 'User not found or not admin' })
|
||||
async setup(
|
||||
@Body() setupPasswordDto: SetupPasswordDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const user = await this.authService.setupPassword(setupPasswordDto);
|
||||
const tokens = await this.authService.generateTokens(user.id, user.username);
|
||||
|
||||
// Set HTTP-only cookies
|
||||
res.cookie('access_token', tokens.accessToken, this.authService.getCookieOptions(false));
|
||||
res.cookie('refresh_token', tokens.refreshToken, this.authService.getCookieOptions(true));
|
||||
|
||||
return {
|
||||
message: 'Password set successfully',
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Admin login' })
|
||||
@ApiBody({ type: LoginDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Login successful. JWT tokens set in HTTP-only cookies.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string', example: 'Login successful' },
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'admin-001' },
|
||||
username: { type: 'string', example: 'admin' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
async login(
|
||||
@Body() loginDto: LoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const admin = await this.authService.validateUser(loginDto);
|
||||
const tokens = await this.authService.generateTokens(admin.id, admin.username);
|
||||
|
||||
// Set HTTP-only cookies
|
||||
res.cookie('access_token', tokens.accessToken, this.authService.getCookieOptions(false));
|
||||
res.cookie('refresh_token', tokens.refreshToken, this.authService.getCookieOptions(true));
|
||||
|
||||
return {
|
||||
message: 'Login successful',
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Admin logout' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Logout successful. Cookies cleared.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string', example: 'Logout successful' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async logout(@Res({ passthrough: true }) res: Response) {
|
||||
// Clear cookies
|
||||
res.clearCookie('access_token', this.authService.getClearCookieOptions(false));
|
||||
res.clearCookie('refresh_token', this.authService.getClearCookieOptions(true));
|
||||
|
||||
return { message: 'Logout successful' };
|
||||
}
|
||||
|
||||
@Get('check')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Check authentication status' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User is authenticated',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
authenticated: { type: 'boolean', example: true },
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'admin-001' },
|
||||
username: { type: 'string', example: 'admin' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Not authenticated' })
|
||||
async check(@Req() req: AuthenticatedRequest) {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: req.user.userId,
|
||||
username: req.user.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtRefreshGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Refresh access token using refresh token' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Tokens refreshed successfully. New JWT tokens set in HTTP-only cookies.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string', example: 'Tokens refreshed' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Invalid or expired refresh token' })
|
||||
async refresh(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const tokens = await this.authService.refreshTokens(
|
||||
req.user.userId,
|
||||
req.user.username,
|
||||
);
|
||||
|
||||
// Set new HTTP-only cookies
|
||||
res.cookie('access_token', tokens.accessToken, this.authService.getCookieOptions(false));
|
||||
res.cookie('refresh_token', tokens.refreshToken, this.authService.getCookieOptions(true));
|
||||
|
||||
return { message: 'Tokens refreshed' };
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Change password' })
|
||||
@ApiBody({ type: ChangePasswordDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Password changed successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string', example: 'Password changed successfully' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Current password is incorrect or new password is same as current' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async changePassword(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() changePasswordDto: ChangePasswordDto,
|
||||
) {
|
||||
await this.authService.changePassword(req.user.userId, changePasswordDto);
|
||||
return { message: 'Password changed successfully' };
|
||||
}
|
||||
}
|
||||
21
src/auth/auth.module.ts
Normal file
21
src/auth/auth.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy, JwtRefreshStrategy } from './strategies';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.register({}),
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
|
||||
exports: [AuthService, JwtStrategy],
|
||||
})
|
||||
export class AuthModule {}
|
||||
144
src/auth/auth.service.ts
Normal file
144
src/auth/auth.service.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { LoginDto, ChangePasswordDto, SetupPasswordDto } from './dto';
|
||||
import { TokenPair } from './interfaces';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async validateUser(loginDto: LoginDto): Promise<{ id: string; username: string }> {
|
||||
const { username, password } = loginDto;
|
||||
|
||||
const user = await this.usersService.findByUsername(username);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
if (!user.isPasswordSet) {
|
||||
throw new BadRequestException('Password not set. Please use /auth/setup endpoint first.');
|
||||
}
|
||||
|
||||
const isPasswordValid = await this.usersService.validatePassword(user, password);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
if (!user.isAdmin) {
|
||||
throw new UnauthorizedException('Access denied');
|
||||
}
|
||||
|
||||
return { id: user.id, username: user.username };
|
||||
}
|
||||
|
||||
async setupPassword(setupPasswordDto: SetupPasswordDto): Promise<{ id: string; username: string }> {
|
||||
const { username, password } = setupPasswordDto;
|
||||
|
||||
const user = await this.usersService.findByUsername(username);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
if (!user.isAdmin) {
|
||||
throw new UnauthorizedException('Access denied');
|
||||
}
|
||||
|
||||
if (user.isPasswordSet) {
|
||||
throw new BadRequestException('Password already set. Use /auth/change-password to change it.');
|
||||
}
|
||||
|
||||
await this.usersService.setupPassword(username, password);
|
||||
return { id: user.id, username: user.username };
|
||||
}
|
||||
|
||||
async needsSetup(): Promise<boolean> {
|
||||
return this.usersService.needsPasswordSetup();
|
||||
}
|
||||
|
||||
async generateTokens(userId: string, username: string): Promise<TokenPair> {
|
||||
const accessPayload = {
|
||||
sub: userId,
|
||||
username,
|
||||
type: 'access',
|
||||
};
|
||||
|
||||
const refreshPayload = {
|
||||
sub: userId,
|
||||
username,
|
||||
type: 'refresh',
|
||||
};
|
||||
|
||||
const accessSecret = this.configService.get<string>('JWT_ACCESS_SECRET') || 'default-secret';
|
||||
const refreshSecret = this.configService.get<string>('JWT_REFRESH_SECRET') || 'default-refresh-secret';
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(accessPayload, {
|
||||
secret: accessSecret,
|
||||
expiresIn: '15m',
|
||||
}),
|
||||
this.jwtService.signAsync(refreshPayload, {
|
||||
secret: refreshSecret,
|
||||
expiresIn: '7d',
|
||||
}),
|
||||
]);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async refreshTokens(userId: string, username: string): Promise<TokenPair> {
|
||||
// Verify user still exists and is admin
|
||||
const user = await this.usersService.findById(userId);
|
||||
if (!user || !user.isAdmin) {
|
||||
throw new UnauthorizedException('User not found or no longer authorized');
|
||||
}
|
||||
return this.generateTokens(userId, username);
|
||||
}
|
||||
|
||||
getCookieOptions(isRefreshToken = false) {
|
||||
const isProduction = this.configService.get<string>('NODE_ENV') === 'production';
|
||||
const cookieSecure = this.configService.get<string>('COOKIE_SECURE') === 'true';
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction || cookieSecure,
|
||||
sameSite: 'lax' as const,
|
||||
path: isRefreshToken ? '/auth/refresh' : '/',
|
||||
maxAge: isRefreshToken
|
||||
? 7 * 24 * 60 * 60 * 1000 // 7 days for refresh token
|
||||
: 15 * 60 * 1000, // 15 minutes for access token
|
||||
};
|
||||
}
|
||||
|
||||
getClearCookieOptions(isRefreshToken = false) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: isRefreshToken ? '/auth/refresh' : '/',
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(userId: string, changePasswordDto: ChangePasswordDto): Promise<void> {
|
||||
const { currentPassword, newPassword } = changePasswordDto;
|
||||
|
||||
const user = await this.usersService.findById(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const isCurrentPasswordValid = await this.usersService.validatePassword(user, currentPassword);
|
||||
if (!isCurrentPasswordValid) {
|
||||
throw new BadRequestException('Current password is incorrect');
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
throw new BadRequestException('New password must be different from current password');
|
||||
}
|
||||
|
||||
await this.usersService.updatePassword(userId, newPassword);
|
||||
}
|
||||
}
|
||||
22
src/auth/dto/change-password.dto.ts
Normal file
22
src/auth/dto/change-password.dto.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({
|
||||
description: 'Current password',
|
||||
example: 'oldpassword123',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
currentPassword: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'New password',
|
||||
example: 'newpassword456',
|
||||
minLength: 6,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6)
|
||||
newPassword: string;
|
||||
}
|
||||
3
src/auth/dto/index.ts
Normal file
3
src/auth/dto/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './login.dto';
|
||||
export * from './change-password.dto';
|
||||
export * from './setup-password.dto';
|
||||
22
src/auth/dto/login.dto.ts
Normal file
22
src/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
description: 'Admin username',
|
||||
example: 'admin',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Admin password',
|
||||
example: 'admin123',
|
||||
minLength: 6,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
22
src/auth/dto/setup-password.dto.ts
Normal file
22
src/auth/dto/setup-password.dto.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class SetupPasswordDto {
|
||||
@ApiProperty({
|
||||
description: 'Admin username',
|
||||
example: 'admin',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'New password to set',
|
||||
example: 'securepassword123',
|
||||
minLength: 6,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
2
src/auth/guards/index.ts
Normal file
2
src/auth/guards/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './jwt-auth.guard';
|
||||
export * from './jwt-refresh.guard';
|
||||
16
src/auth/guards/jwt-auth.guard.ts
Normal file
16
src/auth/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
canActivate(context: ExecutionContext) {
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest(err: Error | null, user: any, info: any) {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException('Access token is invalid or expired');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
16
src/auth/guards/jwt-refresh.guard.ts
Normal file
16
src/auth/guards/jwt-refresh.guard.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {
|
||||
canActivate(context: ExecutionContext) {
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest(err: Error | null, user: any, info: any) {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException('Refresh token is invalid or expired');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
6
src/auth/index.ts
Normal file
6
src/auth/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './auth.module';
|
||||
export * from './auth.service';
|
||||
export * from './auth.controller';
|
||||
export * from './guards';
|
||||
export * from './dto';
|
||||
export * from './interfaces';
|
||||
1
src/auth/interfaces/index.ts
Normal file
1
src/auth/interfaces/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './jwt-payload.interface';
|
||||
10
src/auth/interfaces/jwt-payload.interface.ts
Normal file
10
src/auth/interfaces/jwt-payload.interface.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
type: 'access' | 'refresh';
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
2
src/auth/strategies/index.ts
Normal file
2
src/auth/strategies/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './jwt.strategy';
|
||||
export * from './jwt-refresh.strategy';
|
||||
30
src/auth/strategies/jwt-refresh.strategy.ts
Normal file
30
src/auth/strategies/jwt-refresh.strategy.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Request } from 'express';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '../interfaces';
|
||||
|
||||
@Injectable()
|
||||
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||
constructor(private configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: (req: Request) => {
|
||||
// Extract refresh token from HTTP-only cookie
|
||||
if (req && req.cookies) {
|
||||
return req.cookies['refresh_token'];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET') || 'default-refresh-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
return { userId: payload.sub, username: payload.username };
|
||||
}
|
||||
}
|
||||
30
src/auth/strategies/jwt.strategy.ts
Normal file
30
src/auth/strategies/jwt.strategy.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Request } from 'express';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '../interfaces';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(private configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: (req: Request) => {
|
||||
// Extract JWT from HTTP-only cookie
|
||||
if (req && req.cookies) {
|
||||
return req.cookies['access_token'];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_ACCESS_SECRET') || 'default-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
return { userId: payload.sub, username: payload.username };
|
||||
}
|
||||
}
|
||||
50
src/main.ts
Normal file
50
src/main.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||
import cookieParser = require("cookie-parser");
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const corsOrigins = ["http://localhost:3000", "https://altricade.github.io"];
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
||||
});
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("Portfolio Backend API")
|
||||
.setDescription(
|
||||
"API for portfolio app with admin authentication and blog management",
|
||||
)
|
||||
.setVersion("1.0")
|
||||
.addCookieAuth("access_token")
|
||||
.addTag("auth", "Authentication endpoints")
|
||||
.addTag("articles", "Blog articles management")
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("api", app, document);
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`Application is running on: http://localhost:${port}`);
|
||||
console.log(`Swagger documentation: http://localhost:${port}/api`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
43
src/messages/dto/create-message.dto.ts
Normal file
43
src/messages/dto/create-message.dto.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateMessageDto {
|
||||
@ApiProperty({
|
||||
description: 'Sender name',
|
||||
example: 'John Doe',
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Sender email address',
|
||||
example: 'john@example.com',
|
||||
})
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Message subject (optional)',
|
||||
example: 'Project Inquiry',
|
||||
required: false,
|
||||
maxLength: 200,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
subject?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Message content',
|
||||
example: 'Hello, I would like to discuss a project with you...',
|
||||
maxLength: 5000,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(5000)
|
||||
message: string;
|
||||
}
|
||||
2
src/messages/dto/index.ts
Normal file
2
src/messages/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './create-message.dto';
|
||||
export * from './messages-query.dto';
|
||||
44
src/messages/dto/messages-query.dto.ts
Normal file
44
src/messages/dto/messages-query.dto.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsBoolean, IsInt, Min, Max } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
|
||||
export class MessagesQueryDto {
|
||||
@ApiProperty({
|
||||
description: 'Page number (1-based)',
|
||||
example: 1,
|
||||
default: 1,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Number of items per page',
|
||||
example: 10,
|
||||
default: 10,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 10;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Filter by read status',
|
||||
example: false,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
isRead?: boolean;
|
||||
}
|
||||
1
src/messages/entities/index.ts
Normal file
1
src/messages/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './message.entity';
|
||||
59
src/messages/entities/message.entity.ts
Normal file
59
src/messages/entities/message.entity.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
@Entity('messages')
|
||||
export class Message {
|
||||
@ApiProperty({
|
||||
description: 'Unique message identifier',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
})
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Sender name',
|
||||
example: 'John Doe',
|
||||
})
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Sender email address',
|
||||
example: 'john@example.com',
|
||||
})
|
||||
@Column()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Message subject',
|
||||
example: 'Project Inquiry',
|
||||
})
|
||||
@Column({ nullable: true })
|
||||
subject?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Message content',
|
||||
example: 'Hello, I would like to discuss a project...',
|
||||
})
|
||||
@Column({ type: 'text' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether the message has been read',
|
||||
example: false,
|
||||
})
|
||||
@Column({ default: false })
|
||||
isRead: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Message creation timestamp',
|
||||
example: '2024-01-15T10:30:00.000Z',
|
||||
})
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
5
src/messages/index.ts
Normal file
5
src/messages/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './messages.module';
|
||||
export * from './messages.service';
|
||||
export * from './messages.controller';
|
||||
export * from './dto';
|
||||
export * from './entities';
|
||||
167
src/messages/messages.controller.ts
Normal file
167
src/messages/messages.controller.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Patch,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiCookieAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { CreateMessageDto, MessagesQueryDto } from './dto';
|
||||
import { Message } from './entities';
|
||||
import { JwtAuthGuard } from '../auth/guards';
|
||||
|
||||
@ApiTags('messages')
|
||||
@Controller('messages')
|
||||
export class MessagesController {
|
||||
constructor(private readonly messagesService: MessagesService) {}
|
||||
|
||||
// ==================== PUBLIC ENDPOINT ====================
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Submit a contact message (public)' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Message submitted successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string', example: 'Message sent successfully' },
|
||||
id: { type: 'string', example: '550e8400-e29b-41d4-a716-446655440000' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Invalid input data' })
|
||||
async create(@Body() createMessageDto: CreateMessageDto) {
|
||||
const message = await this.messagesService.create(createMessageDto);
|
||||
return {
|
||||
message: 'Message sent successfully',
|
||||
id: message.id,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== ADMIN ENDPOINTS ====================
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Get all messages (Admin only)' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||
@ApiQuery({ name: 'isRead', required: false, type: Boolean, example: false })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of messages with pagination',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
messages: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Message' },
|
||||
},
|
||||
total: { type: 'number', example: 25 },
|
||||
page: { type: 'number', example: 1 },
|
||||
limit: { type: 'number', example: 10 },
|
||||
unreadCount: { type: 'number', example: 5 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async findAll(@Query() query: MessagesQueryDto) {
|
||||
return this.messagesService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Get unread messages count (Admin only)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Unread messages count',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
unreadCount: { type: 'number', example: 5 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getUnreadCount() {
|
||||
const unreadCount = await this.messagesService.getUnreadCount();
|
||||
return { unreadCount };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Get a message by ID - marks as read (Admin only)' })
|
||||
@ApiParam({ name: 'id', description: 'Message ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The message (automatically marked as read)',
|
||||
type: Message,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Message not found' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.messagesService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Mark message as read (Admin only)' })
|
||||
@ApiParam({ name: 'id', description: 'Message ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Message marked as read',
|
||||
type: Message,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Message not found' })
|
||||
async markAsRead(@Param('id') id: string) {
|
||||
return this.messagesService.markAsRead(id);
|
||||
}
|
||||
|
||||
@Patch(':id/unread')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Mark message as unread (Admin only)' })
|
||||
@ApiParam({ name: 'id', description: 'Message ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Message marked as unread',
|
||||
type: Message,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Message not found' })
|
||||
async markAsUnread(@Param('id') id: string) {
|
||||
return this.messagesService.markAsUnread(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: 'Delete a message (Admin only)' })
|
||||
@ApiParam({ name: 'id', description: 'Message ID' })
|
||||
@ApiResponse({ status: 204, description: 'Message deleted successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Message not found' })
|
||||
async remove(@Param('id') id: string) {
|
||||
await this.messagesService.remove(id);
|
||||
}
|
||||
}
|
||||
13
src/messages/messages.module.ts
Normal file
13
src/messages/messages.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { MessagesController } from './messages.controller';
|
||||
import { Message } from './entities';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Message])],
|
||||
controllers: [MessagesController],
|
||||
providers: [MessagesService],
|
||||
exports: [MessagesService],
|
||||
})
|
||||
export class MessagesModule {}
|
||||
106
src/messages/messages.service.ts
Normal file
106
src/messages/messages.service.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Message } from './entities';
|
||||
import { CreateMessageDto, MessagesQueryDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class MessagesService {
|
||||
constructor(
|
||||
@InjectRepository(Message)
|
||||
private messagesRepository: Repository<Message>,
|
||||
) {}
|
||||
|
||||
async create(createMessageDto: CreateMessageDto): Promise<Message> {
|
||||
const message = this.messagesRepository.create({
|
||||
name: createMessageDto.name,
|
||||
email: createMessageDto.email,
|
||||
subject: createMessageDto.subject,
|
||||
message: createMessageDto.message,
|
||||
isRead: false,
|
||||
});
|
||||
|
||||
return this.messagesRepository.save(message);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query: MessagesQueryDto,
|
||||
): Promise<{ messages: Message[]; total: number; page: number; limit: number; unreadCount: number }> {
|
||||
const { page = 1, limit = 10, isRead } = query;
|
||||
|
||||
const queryBuilder = this.messagesRepository.createQueryBuilder('message');
|
||||
|
||||
// Filter by read status if specified
|
||||
if (isRead !== undefined) {
|
||||
queryBuilder.andWhere('message.isRead = :isRead', { isRead });
|
||||
}
|
||||
|
||||
// Sort by createdAt (newest first)
|
||||
queryBuilder.orderBy('message.createdAt', 'DESC');
|
||||
|
||||
// Get total count
|
||||
const total = await queryBuilder.getCount();
|
||||
|
||||
// Pagination
|
||||
queryBuilder.skip((page - 1) * limit).take(limit);
|
||||
|
||||
const messages = await queryBuilder.getMany();
|
||||
|
||||
// Get unread count
|
||||
const unreadCount = await this.messagesRepository.count({
|
||||
where: { isRead: false },
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
unreadCount,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Message> {
|
||||
const message = await this.messagesRepository.findOne({ where: { id } });
|
||||
if (!message) {
|
||||
throw new NotFoundException(`Message with ID ${id} not found`);
|
||||
}
|
||||
|
||||
// Mark as read when fetched by ID
|
||||
if (!message.isRead) {
|
||||
message.isRead = true;
|
||||
await this.messagesRepository.save(message);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async markAsRead(id: string): Promise<Message> {
|
||||
const message = await this.findOneWithoutMarkingRead(id);
|
||||
message.isRead = true;
|
||||
return this.messagesRepository.save(message);
|
||||
}
|
||||
|
||||
async markAsUnread(id: string): Promise<Message> {
|
||||
const message = await this.findOneWithoutMarkingRead(id);
|
||||
message.isRead = false;
|
||||
return this.messagesRepository.save(message);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const message = await this.findOneWithoutMarkingRead(id);
|
||||
await this.messagesRepository.remove(message);
|
||||
}
|
||||
|
||||
async getUnreadCount(): Promise<number> {
|
||||
return this.messagesRepository.count({ where: { isRead: false } });
|
||||
}
|
||||
|
||||
private async findOneWithoutMarkingRead(id: string): Promise<Message> {
|
||||
const message = await this.messagesRepository.findOne({ where: { id } });
|
||||
if (!message) {
|
||||
throw new NotFoundException(`Message with ID ${id} not found`);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
1
src/users/entities/index.ts
Normal file
1
src/users/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './user.entity';
|
||||
54
src/users/entities/user.entity.ts
Normal file
54
src/users/entities/user.entity.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@ApiProperty({
|
||||
description: 'Unique user identifier',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
})
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Username',
|
||||
example: 'admin',
|
||||
})
|
||||
@Column({ unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
password!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether user is an admin',
|
||||
example: true,
|
||||
})
|
||||
@Column({ default: false })
|
||||
isAdmin: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether password has been set up',
|
||||
example: true,
|
||||
})
|
||||
@Column({ default: false })
|
||||
isPasswordSet: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User creation timestamp',
|
||||
})
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User last update timestamp',
|
||||
})
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
3
src/users/index.ts
Normal file
3
src/users/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './users.module';
|
||||
export * from './users.service';
|
||||
export * from './entities';
|
||||
11
src/users/users.module.ts
Normal file
11
src/users/users.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './entities';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
82
src/users/users.service.ts
Normal file
82
src/users/users.service.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from './entities';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService implements OnModuleInit {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.seedAdminUser();
|
||||
}
|
||||
|
||||
private async seedAdminUser() {
|
||||
const adminUsername = this.configService.get<string>('ADMIN_USERNAME') || 'admin';
|
||||
|
||||
const existingAdmin = await this.usersRepository.findOne({
|
||||
where: { username: adminUsername },
|
||||
});
|
||||
|
||||
if (!existingAdmin) {
|
||||
// Create admin without password - requires setup on first login
|
||||
const admin = this.usersRepository.create({
|
||||
username: adminUsername,
|
||||
password: null,
|
||||
isAdmin: true,
|
||||
isPasswordSet: false,
|
||||
});
|
||||
await this.usersRepository.save(admin);
|
||||
console.log(`Admin user "${adminUsername}" created - password setup required`);
|
||||
}
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async validatePassword(user: User, password: string): Promise<boolean> {
|
||||
if (!user.password) {
|
||||
return false;
|
||||
}
|
||||
return bcrypt.compare(password, user.password);
|
||||
}
|
||||
|
||||
async updatePassword(userId: string, newPassword: string): Promise<void> {
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
await this.usersRepository.update(userId, {
|
||||
password: hashedPassword,
|
||||
isPasswordSet: true,
|
||||
});
|
||||
}
|
||||
|
||||
async setupPassword(username: string, password: string): Promise<User> {
|
||||
const user = await this.findByUsername(username);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
user.password = hashedPassword;
|
||||
user.isPasswordSet = true;
|
||||
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
async needsPasswordSetup(): Promise<boolean> {
|
||||
const admin = await this.usersRepository.findOne({
|
||||
where: { isAdmin: true },
|
||||
});
|
||||
return admin ? !admin.isPasswordSet : false;
|
||||
}
|
||||
}
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user