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