init
Some checks failed
Deploy Production / deploy (push) Failing after 5s

This commit is contained in:
commit e980536871
51 changed files with 8181 additions and 0 deletions

23
.env.example Normal file
View 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

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View 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
View 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 {}

View 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);
}
}

View 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 {}

View 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();
}
}

View 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;
}

View 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;
}

View File

@ -0,0 +1,3 @@
export * from './create-article.dto';
export * from './update-article.dto';
export * from './articles-query.dto';

View 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;
}

View 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;
}

View File

@ -0,0 +1 @@
export * from './article.entity';

5
src/articles/index.ts Normal file
View 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
View 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
View 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
View 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);
}
}

View 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
View 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
View 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;
}

View 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
View File

@ -0,0 +1,2 @@
export * from './jwt-auth.guard';
export * from './jwt-refresh.guard';

View 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;
}
}

View 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
View 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';

View File

@ -0,0 +1 @@
export * from './jwt-payload.interface';

View File

@ -0,0 +1,10 @@
export interface JwtPayload {
sub: string;
username: string;
type: 'access' | 'refresh';
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
}

View File

@ -0,0 +1,2 @@
export * from './jwt.strategy';
export * from './jwt-refresh.strategy';

View 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 };
}
}

View 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
View 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();

View 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;
}

View File

@ -0,0 +1,2 @@
export * from './create-message.dto';
export * from './messages-query.dto';

View 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;
}

View File

@ -0,0 +1 @@
export * from './message.entity';

View 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
View File

@ -0,0 +1,5 @@
export * from './messages.module';
export * from './messages.service';
export * from './messages.controller';
export * from './dto';
export * from './entities';

View 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);
}
}

View 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 {}

View 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;
}
}

View File

@ -0,0 +1 @@
export * from './user.entity';

View 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
View File

@ -0,0 +1,3 @@
export * from './users.module';
export * from './users.service';
export * from './entities';

11
src/users/users.module.ts Normal file
View 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 {}

View 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
View 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"]
}