2026-02-19 23:20:19 +09:00
commit 0e21562088
139 changed files with 35467 additions and 0 deletions

19
backend/.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
# Versioning and metadata
.git
.gitignore
.dockerignore
# Build dependencies
dist
node_modules
coverage
# Environment (contains sensitive data)
.env
# Files not required for production
.editorconfig
Dockerfile
README.md
.eslintrc.js
nodemon.json

41
backend/.env.example Normal file
View File

@@ -0,0 +1,41 @@
NODE_ENV=development
HOST=0.0.0.0
PORT=5001
APP_URL=http://localhost:5001
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=nestjs_blog
DB_SSL=false
# JWT
JWT_ACCESS_SECRET=change-me-access-secret-at-least-32-chars
JWT_REFRESH_SECRET=change-me-refresh-secret-at-least-32-chars
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Tokens
MAGIC_LINK_TTL_MINUTES=20
PASSWORD_RESET_TTL_MINUTES=30
# Cookies
COOKIE_SECURE=false
COOKIE_DOMAIN=
# Email (leave blank to use console fallback in dev)
MAIL_FROM=no-reply@blog.local
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:5000/auth/google/callback
FRONTEND_URL=http://localhost:5000

100
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,100 @@
# -----------------------------
# Dependencies
# -----------------------------
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# -----------------------------
# Build Output
# -----------------------------
dist/
build/
coverage/
.nyc_output/
# TypeScript
*.tsbuildinfo
# -----------------------------
# Environment Variables
# -----------------------------
.env
.env.*
!.env.example
# -----------------------------
# Logs
# -----------------------------
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# -----------------------------
# OS Files
# -----------------------------
.DS_Store
Thumbs.db
# -----------------------------
# IDE / Editor
# -----------------------------
.vscode/
!.vscode/extensions.json
.idea/
*.swp
*.swo
# -----------------------------
# Testing
# -----------------------------
/jest-cache/
coverage/
# -----------------------------
# Misc
# -----------------------------
tmp/
temp/
.cache/
# -----------------------------
# Docker (optional)
# -----------------------------
docker-compose.override.yml
# -----------------------------
# Package Managers
# -----------------------------
.pnp/
.pnp.js
# -----------------------------
# Nest CLI
# -----------------------------
nest-cli.json.lock
# -----------------------------
# Firebase / Serverless (optional)
# -----------------------------
.firebase/
.serverless/
# -----------------------------
# Mac
# -----------------------------
.AppleDouble
.LSOverride
*.sqlite
*.sqlite3

4
backend/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=base /app/dist ./dist
EXPOSE 3001
CMD ["node", "dist/src/main.js"]

94
backend/database/init.sql Normal file
View File

@@ -0,0 +1,94 @@
-- USERS
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(120),
role VARCHAR(20) NOT NULL DEFAULT 'MEMBER'
CHECK (role IN ('ADMIN', 'MANAGER', 'MEMBER')),
password_hash VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_users_email UNIQUE (email)
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- REFRESH TOKENS
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
replaced_by_token_hash VARCHAR(255),
user_agent TEXT,
ip VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_refresh_token_hash UNIQUE (token_hash)
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_expires ON refresh_tokens(user_id, expires_at);
-- MAGIC LINK TOKENS
CREATE TABLE IF NOT EXISTS magic_link_tokens (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_magic_link_token_hash UNIQUE (token_hash)
);
CREATE INDEX IF NOT EXISTS idx_magic_link_email ON magic_link_tokens(email);
CREATE INDEX IF NOT EXISTS idx_magic_link_expires ON magic_link_tokens(expires_at);
-- PASSWORD RESET TOKENS
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_password_reset_token_hash UNIQUE (token_hash)
);
-- OAUTH ACCOUNTS
CREATE TABLE IF NOT EXISTS oauth_accounts (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_id VARCHAR(255) NOT NULL,
email VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_oauth_provider_provider_id UNIQUE (provider, provider_id)
);
-- BLOG POSTS
CREATE TABLE IF NOT EXISTS blog_posts (
id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
slug VARCHAR(300),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
excerpt TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
content_format VARCHAR(20) NOT NULL DEFAULT 'markdown'
CHECK (content_format IN ('markdown', 'html')),
author_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
featured_image_url VARCHAR(500),
featured_image_alt VARCHAR(255),
is_featured BOOLEAN NOT NULL DEFAULT false,
views INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '',
categories TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_blog_posts_slug UNIQUE (slug)
);
CREATE INDEX IF NOT EXISTS idx_blog_posts_status_created ON blog_posts(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_blog_posts_featured ON blog_posts(is_featured, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_blog_posts_views ON blog_posts(views DESC);
CREATE INDEX IF NOT EXISTS idx_blog_posts_author ON blog_posts(author_id);

35
backend/eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
backend/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
}
}

11274
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

103
backend/package.json Normal file
View File

@@ -0,0 +1,103 @@
{
"name": "FullStack-Blog-Nestjs-HTMX",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"seed:admin": "ts-node -P tsconfig.json scripts/seed-admin.ts",
"seed:posts": "ts-node -P tsconfig.json scripts/seed-blog-posts.ts",
"db:init": "psql -U $DB_USER -d $DB_NAME -f database/init.sql"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"dotenv": "^17.3.1",
"marked": "^17.0.3",
"nodemailer": "^8.0.1",
"nunjucks": "^3.2.4",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"pg": "^8.18.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/marked": "^5.0.2",
"@types/node": "^22.10.7",
"@types/nodemailer": "^7.0.10",
"@types/nunjucks": "^3.2.6",
"@types/passport": "^1.0.17",
"@types/passport-google-oauth20": "^2.0.17",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(uuid)/)"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,55 @@
/**
* Run: npx ts-node -P tsconfig.json scripts/seed-admin.ts
*/
import 'dotenv/config';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { User, UserRole } from '../src/users/entities/user.entity';
const dataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || 'nestjs_blog',
entities: [User],
synchronize: true,
});
async function seed() {
await dataSource.initialize();
console.log('✅ Database connected');
const users = [
{ email: 'admin@gmail.com', role: UserRole.ADMIN, name: 'Admin' },
{ email: 'manager@gmail.com', role: UserRole.MANAGER, name: 'Manager' },
];
const repo = dataSource.getRepository(User);
const passwordHash = await bcrypt.hash('Whatever123$', 12);
for (const u of users) {
let user = await repo.findOne({ where: { email: u.email } });
if (!user) {
user = repo.create({ id: uuidv4(), ...u, passwordHash, isActive: true });
await repo.save(user);
console.log(`✅ Created ${u.role}: ${u.email}`);
} else {
user.role = u.role;
user.passwordHash = passwordHash;
user.isActive = true;
await repo.save(user);
console.log(`♻️ Updated ${u.role}: ${u.email}`);
}
}
await dataSource.destroy();
console.log('🎉 Seeding complete');
}
seed().catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

83
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,83 @@
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { validate } from './config/env.validation';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { BlogPostsModule } from './blog-posts/blog-posts.module';
import { TokensModule } from './tokens/tokens.module';
import { User } from './users/entities/user.entity';
import { BlogPost } from './blog-posts/entities/blog-post.entity';
import { RefreshToken } from './tokens/entities/refresh-token.entity';
import { MagicLinkToken } from './tokens/entities/magic-link-token.entity';
import { PasswordResetToken } from './tokens/entities/password-reset-token.entity';
import { OAuthAccount } from './tokens/entities/oauth-account.entity';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
validate,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get<string>('DB_HOST'),
port: configService.get<number>('DB_PORT'),
username: configService.get<string>('DB_USER'),
password: configService.get<string>('DB_PASSWORD'),
database: configService.get<string>('DB_NAME'),
ssl:
configService.get<string>('DB_SSL') === 'true'
? { rejectUnauthorized: false }
: false,
entities: [
User,
BlogPost,
RefreshToken,
MagicLinkToken,
PasswordResetToken,
OAuthAccount,
],
synchronize: configService.get<string>('NODE_ENV') !== 'production',
logging: configService.get<string>('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
AuthModule,
UsersModule,
BlogPostsModule,
TokensModule,
],
providers: [
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(CsrfMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -0,0 +1,110 @@
import {
Body,
Controller,
Get,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { MagicLinkRequestDto } from './dto/magic-link-request.dto';
import { PasswordResetConfirmDto } from './dto/password-reset-confirm.dto';
import { PasswordResetRequestDto } from './dto/password-reset-request.dto';
import { RegisterDto } from './dto/register.dto';
import { Public } from '../common/decorators/public.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/** Returns the JWT payload of the currently logged-in user.
* Used by the frontend to restore auth state (e.g. after Google OAuth redirect). */
@Get('me')
me(@CurrentUser() user: any) {
return { success: true, user };
}
@Post('register')
@Public()
register(@Body() dto: RegisterDto, @Req() req: Request, @Res() res: Response) {
return this.authService.register(dto, req, res);
}
@Post('login')
@Public()
login(@Body() dto: LoginDto, @Req() req: Request, @Res() res: Response) {
return this.authService.login(dto, req, res);
}
@Post('refresh')
@Public()
refresh(@Req() req: Request, @Res() res: Response) {
return this.authService.refresh(req, res);
}
@Post('logout')
@Public()
logout(@Req() req: Request, @Res() res: Response) {
return this.authService.logout(req, res);
}
@Post('magic-link')
@Public()
requestMagicLink(
@Body() dto: MagicLinkRequestDto,
@Req() req: Request,
@Res() res: Response,
) {
return this.authService.requestMagicLink(dto.email, req, res);
}
@Get('magic-link/verify')
@Public()
verifyMagicLink(
@Query('token') token: string,
@Req() req: Request,
@Res() res: Response,
) {
return this.authService.verifyMagicLink(token, req, res);
}
@Post('password-reset/request')
@Public()
requestPasswordReset(
@Body() dto: PasswordResetRequestDto,
@Req() req: Request,
@Res() res: Response,
) {
return this.authService.requestPasswordReset(dto.email, req, res);
}
@Post('password-reset/confirm')
@Public()
confirmPasswordReset(
@Body() dto: PasswordResetConfirmDto,
@Req() req: Request,
@Res() res: Response,
) {
return this.authService.confirmPasswordReset(dto, req, res);
}
@Get('google')
@Public()
@UseGuards(AuthGuard('google'))
googleAuth() {
// Passport redirects to Google
}
@Get('google/callback')
@Public()
@UseGuards(AuthGuard('google'))
googleCallback(@Req() req: Request, @Res() res: Response) {
return this.authService.handleGoogleCallback((req as any).user, req, res);
}
}

View File

@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
import { RefreshToken } from '../tokens/entities/refresh-token.entity';
import { MagicLinkToken } from '../tokens/entities/magic-link-token.entity';
import { PasswordResetToken } from '../tokens/entities/password-reset-token.entity';
import { OAuthAccount } from '../tokens/entities/oauth-account.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './strategies/google.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_ACCESS_SECRET'),
signOptions: { expiresIn: (configService.get<string>('JWT_ACCESS_EXPIRES_IN') || '15m') as any },
}),
inject: [ConfigService],
}),
TypeOrmModule.forFeature([
User,
RefreshToken,
MagicLinkToken,
PasswordResetToken,
OAuthAccount,
]),
],
controllers: [AuthController],
providers: [AuthService, GoogleStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,368 @@
import {
BadRequestException,
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { User, UserRole } from '../users/entities/user.entity';
import { RefreshToken } from '../tokens/entities/refresh-token.entity';
import { MagicLinkToken } from '../tokens/entities/magic-link-token.entity';
import { PasswordResetToken } from '../tokens/entities/password-reset-token.entity';
import { OAuthAccount } from '../tokens/entities/oauth-account.entity';
import {
generateRawToken,
hashToken,
signAccessToken,
signRefreshToken,
verifyRefreshToken,
} from '../common/helpers/jwt.helper';
import { sendMail } from '../common/helpers/mailer.helper';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { PasswordResetConfirmDto } from './dto/password-reset-confirm.dto';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(RefreshToken)
private readonly refreshTokenRepo: Repository<RefreshToken>,
@InjectRepository(MagicLinkToken)
private readonly magicLinkRepo: Repository<MagicLinkToken>,
@InjectRepository(PasswordResetToken)
private readonly pwdResetRepo: Repository<PasswordResetToken>,
@InjectRepository(OAuthAccount)
private readonly oauthRepo: Repository<OAuthAccount>,
private readonly configService: ConfigService,
) {}
// ─── Token helpers ───────────────────────────────────────────────────────────
private issueTokens(user: User) {
const accessSecret = this.configService.get<string>('JWT_ACCESS_SECRET');
const refreshSecret = this.configService.get<string>('JWT_REFRESH_SECRET');
const accessExpiresIn = this.configService.get<string>('JWT_ACCESS_EXPIRES_IN') || '15m';
const refreshExpiresIn = this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') || '7d';
const accessToken = signAccessToken(
{ sub: user.id, email: user.email, role: user.role },
accessSecret,
accessExpiresIn,
);
const rawRefresh = generateRawToken();
const refreshToken = signRefreshToken({ sub: user.id }, refreshSecret, refreshExpiresIn);
return { accessToken, rawRefresh, refreshToken };
}
private cookieOptions(res: any, accessToken: string, refreshToken: string) {
const secure = this.configService.get<string>('COOKIE_SECURE') === 'true';
const domain = this.configService.get<string>('COOKIE_DOMAIN') || undefined;
// SameSite=none required for cross-subdomain requests (frontend ↔ backend on different subdomains)
// SameSite=none requires Secure=true
const sameSite = secure ? ('none' as const) : ('lax' as const);
const opts = { httpOnly: true, sameSite, secure, domain };
res.cookie('accessToken', accessToken, { ...opts, maxAge: 15 * 60 * 1000 });
res.cookie('refreshToken', refreshToken, { ...opts, maxAge: 7 * 24 * 60 * 60 * 1000 });
}
private clearCookies(res: any) {
const secure = this.configService.get<string>('COOKIE_SECURE') === 'true';
const domain = this.configService.get<string>('COOKIE_DOMAIN') || undefined;
const sameSite = secure ? ('none' as const) : ('lax' as const);
const opts = { httpOnly: true, sameSite, secure, domain };
res.clearCookie('accessToken', opts);
res.clearCookie('refreshToken', opts);
}
private safeUser(user: User) {
const { passwordHash, ...safe } = user as any;
return safe;
}
private get frontendUrl(): string {
return this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
}
// ─── Register ────────────────────────────────────────────────────────────────
async register(dto: RegisterDto, req: any, res: any) {
const existing = await this.userRepo.findOne({ where: { email: dto.email } });
if (existing) throw new ConflictException('Email already in use');
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = this.userRepo.create({
id: uuidv4(),
email: dto.email,
name: dto.name,
role: UserRole.MEMBER,
passwordHash,
});
await this.userRepo.save(user);
const { accessToken, rawRefresh, refreshToken } = this.issueTokens(user);
await this.storeRefreshToken(user.id, rawRefresh, req);
this.cookieOptions(res, accessToken, refreshToken);
return res.json({ success: true, accessToken, refreshToken, user: this.safeUser(user) });
}
// ─── Login ────────────────────────────────────────────────────────────────────
async login(dto: LoginDto, req: any, res: any) {
const user = await this.userRepo.findOne({ where: { email: dto.email } });
if (!user || !user.passwordHash) {
throw new UnauthorizedException('Invalid email or password');
}
const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) throw new UnauthorizedException('Invalid email or password');
if (!user.isActive) throw new UnauthorizedException('Account is deactivated');
const { accessToken, rawRefresh, refreshToken } = this.issueTokens(user);
await this.storeRefreshToken(user.id, rawRefresh, req);
this.cookieOptions(res, accessToken, refreshToken);
return res.json({ success: true, accessToken, refreshToken, user: this.safeUser(user) });
}
// ─── Refresh ──────────────────────────────────────────────────────────────────
async refresh(req: any, res: any) {
const rawToken = req.cookies?.refreshToken || req.body?.refreshToken;
if (!rawToken) throw new UnauthorizedException('No refresh token provided');
const refreshSecret = this.configService.get<string>('JWT_REFRESH_SECRET');
let payload: any;
try {
payload = verifyRefreshToken(rawToken, refreshSecret);
} catch {
throw new UnauthorizedException('Invalid or expired refresh token');
}
const tokenHash = hashToken(rawToken);
const stored = await this.refreshTokenRepo.findOne({
where: { tokenHash },
relations: ['user'],
});
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException('Refresh token invalid or revoked');
}
const user = stored.user;
// Rotate: revoke old, issue new
const newRaw = generateRawToken();
const refreshExpiresIn = this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') || '7d';
const newRefreshSecret = this.configService.get<string>('JWT_REFRESH_SECRET');
const newRefreshToken = signRefreshToken({ sub: user.id }, newRefreshSecret, refreshExpiresIn);
const newHash = hashToken(newRaw);
stored.revokedAt = new Date();
stored.replacedByTokenHash = newHash;
await this.refreshTokenRepo.save(stored);
await this.storeRefreshToken(user.id, newRaw, req);
const { accessToken } = this.issueTokens(user);
this.cookieOptions(res, accessToken, newRefreshToken);
return res.json({ success: true, accessToken, refreshToken: newRefreshToken });
}
// ─── Logout ───────────────────────────────────────────────────────────────────
async logout(req: any, res: any) {
const rawToken = req.cookies?.refreshToken || req.body?.refreshToken;
if (rawToken) {
const tokenHash = hashToken(rawToken);
await this.refreshTokenRepo.update({ tokenHash }, { revokedAt: new Date() });
}
this.clearCookies(res);
return res.json({ success: true });
}
// ─── Magic Link ───────────────────────────────────────────────────────────────
async requestMagicLink(email: string, req: any, res: any) {
const appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:3001';
const ttl = this.configService.get<number>('MAGIC_LINK_TTL_MINUTES') || 20;
const user = await this.userRepo.findOne({ where: { email } });
const rawToken = generateRawToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + ttl * 60 * 1000);
await this.magicLinkRepo.save(
this.magicLinkRepo.create({
id: uuidv4(),
userId: user?.id || null,
email,
tokenHash,
expiresAt,
}),
);
// Link points to backend verify endpoint which then redirects to frontend
const link = `${appUrl}/auth/magic-link/verify?token=${rawToken}`;
await sendMail({
to: email,
subject: 'Your Magic Link',
html: `<p>Click <a href="${link}">here</a> to sign in. Link expires in ${ttl} minutes.</p><p>${link}</p>`,
});
return res.json({ success: true, message: 'Magic link sent' });
}
async verifyMagicLink(rawToken: string, req: any, res: any) {
const tokenHash = hashToken(rawToken);
const record = await this.magicLinkRepo.findOne({ where: { tokenHash } });
if (!record || record.consumedAt || record.expiresAt < new Date()) {
throw new UnauthorizedException('Invalid or expired magic link');
}
record.consumedAt = new Date();
await this.magicLinkRepo.save(record);
let user = await this.userRepo.findOne({ where: { email: record.email } });
if (!user) {
user = this.userRepo.create({
id: uuidv4(),
email: record.email,
role: UserRole.MEMBER,
isActive: true,
});
await this.userRepo.save(user);
}
const { accessToken, rawRefresh, refreshToken } = this.issueTokens(user);
await this.storeRefreshToken(user.id, rawRefresh, req);
this.cookieOptions(res, accessToken, refreshToken);
// Redirect to Next.js frontend dashboard
return res.redirect(`${this.frontendUrl}/dashboard`);
}
// ─── Password Reset ───────────────────────────────────────────────────────────
async requestPasswordReset(email: string, req: any, res: any) {
const frontendUrl = this.frontendUrl;
const ttl = this.configService.get<number>('PASSWORD_RESET_TTL_MINUTES') || 30;
const user = await this.userRepo.findOne({ where: { email } });
if (!user) {
// Return success to avoid enumeration
return res.json({ success: true });
}
const rawToken = generateRawToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + ttl * 60 * 1000);
await this.pwdResetRepo.save(
this.pwdResetRepo.create({ id: uuidv4(), userId: user.id, tokenHash, expiresAt }),
);
// Link points to Next.js frontend auth page with reset token
const link = `${frontendUrl}/auth?tab=reset&token=${rawToken}`;
await sendMail({
to: email,
subject: 'Password Reset',
html: `<p>Click <a href="${link}">here</a> to reset your password. Expires in ${ttl} minutes.</p>`,
});
return res.json({ success: true });
}
async confirmPasswordReset(dto: PasswordResetConfirmDto, req: any, res: any) {
const tokenHash = hashToken(dto.token);
const record = await this.pwdResetRepo.findOne({
where: { tokenHash },
relations: ['user'],
});
if (!record || record.consumedAt || record.expiresAt < new Date()) {
throw new BadRequestException('Invalid or expired reset token');
}
const passwordHash = await bcrypt.hash(dto.password, 12);
await this.userRepo.update(record.userId, { passwordHash });
record.consumedAt = new Date();
await this.pwdResetRepo.save(record);
return res.json({ success: true });
}
// ─── Google OAuth ──────────────────────────────────────────────────────────────
async handleGoogleCallback(googleUser: any, req: any, res: any) {
const { providerId, email, name } = googleUser;
let oauthAccount = await this.oauthRepo.findOne({
where: { provider: 'google', providerId },
relations: ['user'],
});
let user: User;
if (oauthAccount) {
user = oauthAccount.user;
} else {
user = await this.userRepo.findOne({ where: { email } });
if (!user) {
user = this.userRepo.create({
id: uuidv4(),
email,
name,
role: UserRole.MEMBER,
isActive: true,
});
await this.userRepo.save(user);
}
oauthAccount = this.oauthRepo.create({
id: uuidv4(),
userId: user.id,
provider: 'google',
providerId,
email,
});
await this.oauthRepo.save(oauthAccount);
}
const { accessToken, rawRefresh, refreshToken } = this.issueTokens(user);
await this.storeRefreshToken(user.id, rawRefresh, req);
this.cookieOptions(res, accessToken, refreshToken);
// Redirect to Next.js frontend dashboard after Google OAuth
return res.redirect(`${this.frontendUrl}/dashboard`);
}
// ─── Private ──────────────────────────────────────────────────────────────────
private async storeRefreshToken(userId: string, rawToken: string, req: any) {
const tokenHash = hashToken(rawToken);
const refreshExpiresIn = this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') || '7d';
const days = parseInt(refreshExpiresIn.replace('d', ''), 10) || 7;
const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
await this.refreshTokenRepo.save(
this.refreshTokenRepo.create({
id: uuidv4(),
userId,
tokenHash,
expiresAt,
userAgent: req.headers?.['user-agent'] || null,
ip: req.ip || null,
}),
);
}
}

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(1)
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class MagicLinkRequestDto {
@IsEmail()
email: string;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class MagicLinkVerifyDto {
@IsString()
token: string;
}

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class PasswordResetConfirmDto {
@IsString()
token: string;
@IsString()
@MinLength(8)
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class PasswordResetRequestDto {
@IsEmail()
email: string;
}

View File

@@ -0,0 +1,15 @@
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
@IsString()
@MaxLength(120)
@IsOptional()
name?: string;
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(configService: ConfigService) {
super({
clientID: configService.get<string>('GOOGLE_CLIENT_ID') || 'dummy',
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') || 'dummy',
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
): Promise<any> {
const { id, displayName, emails } = profile;
const user = {
providerId: id,
provider: 'google',
email: emails?.[0]?.value,
name: displayName,
};
done(null, user);
}
}

View File

@@ -0,0 +1,83 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { BlogPostsService } from './blog-posts.service';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { ListPostsQueryDto } from './dto/list-posts-query.dto';
import { Public } from '../common/decorators/public.decorator';
import { Roles } from '../common/decorators/roles.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { UserRole } from '../users/entities/user.entity';
@Controller('blog-posts')
export class BlogPostsController {
constructor(private readonly blogPostsService: BlogPostsService) {}
// ─── Public endpoints ────────────────────────────────────────────────────────
@Get('public')
@Public()
async findPublished(@Query() query: ListPostsQueryDto) {
return this.blogPostsService.findPublished(query);
}
@Get('public/featured')
@Public()
async findFeatured() {
return this.blogPostsService.findFeatured();
}
@Get('public/:slug')
@Public()
async findBySlug(@Param('slug') slug: string) {
const post = await this.blogPostsService.findBySlug(slug);
return { success: true, post };
}
@Post('public/:slug/view')
@Public()
async incrementViews(@Param('slug') slug: string) {
await this.blogPostsService.incrementViews(slug);
return { success: true };
}
// ─── Authenticated CRUD ───────────────────────────────────────────────────────
@Get()
async findAll(@Query() query: ListPostsQueryDto, @CurrentUser() user: any) {
return this.blogPostsService.findAll(query, user);
}
@Post()
@Roles(UserRole.ADMIN, UserRole.MANAGER)
async create(@Body() dto: CreatePostDto, @CurrentUser() user: any) {
const post = await this.blogPostsService.create(dto, user);
return { success: true, post };
}
@Patch(':id')
@Roles(UserRole.ADMIN, UserRole.MANAGER)
async update(
@Param('id') id: string,
@Body() dto: UpdatePostDto,
@CurrentUser() user: any,
) {
const post = await this.blogPostsService.update(id, dto, user);
return { success: true, post };
}
@Delete(':id')
@Roles(UserRole.ADMIN)
async remove(@Param('id') id: string) {
await this.blogPostsService.remove(id);
return { success: true };
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BlogPost } from './entities/blog-post.entity';
import { User } from '../users/entities/user.entity';
import { BlogPostsController } from './blog-posts.controller';
import { BlogPostsService } from './blog-posts.service';
@Module({
imports: [TypeOrmModule.forFeature([BlogPost, User])],
controllers: [BlogPostsController],
providers: [BlogPostsService],
exports: [BlogPostsService],
})
export class BlogPostsModule {}

View File

@@ -0,0 +1,150 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { BlogPostsService } from './blog-posts.service';
import { BlogPost, PostStatus, ContentFormat } from './entities/blog-post.entity';
import { UserRole } from '../users/entities/user.entity';
const mockPost: Partial<BlogPost> = {
id: 'post-uuid-1',
title: 'Test Post',
slug: 'test-post-abc',
status: PostStatus.PUBLISHED,
excerpt: 'A test post',
content: '# Test',
contentFormat: ContentFormat.MARKDOWN,
authorId: 'user-1',
isFeatured: false,
views: 100,
tags: ['test'],
categories: ['Testing'],
};
const mockQueryBuilder = {
leftJoin: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(1),
getMany: jest.fn().mockResolvedValue([mockPost]),
getOne: jest.fn().mockResolvedValue(mockPost),
};
const mockRepo = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
findOne: jest.fn(),
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
increment: jest.fn(),
remove: jest.fn(),
};
describe('BlogPostsService', () => {
let service: BlogPostsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BlogPostsService,
{ provide: getRepositoryToken(BlogPost), useValue: mockRepo },
],
}).compile();
service = module.get<BlogPostsService>(BlogPostsService);
jest.clearAllMocks();
mockRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('findPublished', () => {
it('should return paginated published posts', async () => {
const result = await service.findPublished({});
expect(result.items).toHaveLength(1);
expect(result.total).toBe(1);
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
'post.status = :status',
{ status: PostStatus.PUBLISHED },
);
});
});
describe('findBySlug', () => {
it('should return published post by slug', async () => {
mockQueryBuilder.getOne.mockResolvedValue(mockPost);
const post = await service.findBySlug('test-post-abc');
expect(post.slug).toBe('test-post-abc');
});
it('should throw NotFoundException for draft post', async () => {
mockQueryBuilder.getOne.mockResolvedValue({ ...mockPost, status: PostStatus.DRAFT });
await expect(service.findBySlug('test-post-abc')).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException for non-existent slug', async () => {
mockQueryBuilder.getOne.mockResolvedValue(null);
await expect(service.findBySlug('nonexistent')).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
const adminUser = { sub: 'user-1', role: UserRole.ADMIN };
const managerUser = { sub: 'user-2', role: UserRole.MANAGER };
it('ADMIN can create published post', async () => {
mockRepo.create.mockReturnValue({ ...mockPost });
mockRepo.save.mockResolvedValue({ ...mockPost, status: PostStatus.PUBLISHED });
const result = await service.create(
{ title: 'New Post', content: 'Content', status: PostStatus.PUBLISHED },
adminUser,
);
expect(mockRepo.save).toHaveBeenCalled();
});
it('MANAGER is forced to draft', async () => {
mockRepo.create.mockImplementation((data) => ({ ...data }));
mockRepo.save.mockImplementation((data) => Promise.resolve(data));
const result = await service.create(
{ title: 'Manager Post', content: 'Content', status: PostStatus.PUBLISHED },
managerUser,
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ status: PostStatus.DRAFT }),
);
});
});
describe('incrementViews', () => {
it('should increment post views', async () => {
mockRepo.increment.mockResolvedValue({});
await service.incrementViews('test-post-abc');
expect(mockRepo.increment).toHaveBeenCalledWith(
{ slug: 'test-post-abc' },
'views',
1,
);
});
});
describe('remove', () => {
it('should remove a post', async () => {
mockRepo.findOne.mockResolvedValue(mockPost);
mockRepo.remove.mockResolvedValue({});
await service.remove('post-uuid-1');
expect(mockRepo.remove).toHaveBeenCalledWith(mockPost);
});
it('should throw NotFoundException for non-existent post', async () => {
mockRepo.findOne.mockResolvedValue(null);
await expect(service.remove('nonexistent')).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -0,0 +1,284 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { BlogPost, PostStatus } from './entities/blog-post.entity';
import { UserRole } from '../users/entities/user.entity';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { ListPostsQueryDto } from './dto/list-posts-query.dto';
import { generateSlug } from '../common/helpers/slug.helper';
// Safe author columns — never include passwordHash
const AUTHOR_COLS: string[] = [
'author.id',
'author.email',
'author.name',
'author.role',
'author.isActive',
'author.createdAt',
];
@Injectable()
export class BlogPostsService {
constructor(
@InjectRepository(BlogPost)
private readonly postRepo: Repository<BlogPost>,
) {}
// ─── Public (published) posts ──────────────────────────────────────────────
async findPublished(query: ListPostsQueryDto) {
const page = parseInt(query.page || '1', 10);
const pageSize = parseInt(query.pageSize || '9', 10);
const qb = this.postRepo
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.addSelect(AUTHOR_COLS)
.where('post.status = :status', { status: PostStatus.PUBLISHED });
if (query.q) {
qb.andWhere(
'(post.title ILIKE :q OR post.excerpt ILIKE :q)',
{ q: `%${query.q}%` },
);
}
// Support comma-separated tags (from HTMX filter form: name="tags")
if (query.tags) {
const tagList = query.tags.split(',').map((t) => t.trim()).filter(Boolean);
tagList.forEach((tag, i) => {
qb.andWhere(`post.tags LIKE :tag${i}`, { [`tag${i}`]: `%${tag}%` });
});
}
if (query.category) {
qb.andWhere('post.categories LIKE :cat', { cat: `%${query.category}%` });
}
// Sort options matching the home page template values
switch (query.sort) {
case 'oldest':
qb.orderBy('post.createdAt', 'ASC');
break;
case 'most_viewed':
qb.orderBy('post.views', 'DESC');
break;
case 'featured':
qb.orderBy('post.isFeatured', 'DESC').addOrderBy('post.createdAt', 'DESC');
break;
default: // 'newest' or undefined
qb.orderBy('post.createdAt', 'DESC');
}
const total = await qb.getCount();
const posts = await qb.skip((page - 1) * pageSize).take(pageSize).getMany();
return {
items: posts,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async findFeatured(limit = 3) {
return this.postRepo
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.addSelect(AUTHOR_COLS)
.where('post.status = :status', { status: PostStatus.PUBLISHED })
.andWhere('post.isFeatured = true')
.orderBy('post.createdAt', 'DESC')
.take(limit)
.getMany();
}
async findPopular(limit = 5) {
return this.postRepo
.createQueryBuilder('post')
.where('post.status = :status', { status: PostStatus.PUBLISHED })
.orderBy('post.views', 'DESC')
.take(limit)
.select(['post.slug', 'post.title', 'post.views'])
.getMany();
}
async findRelated(post: BlogPost, limit = 4) {
const qb = this.postRepo
.createQueryBuilder('p')
.where('p.status = :status', { status: PostStatus.PUBLISHED })
.andWhere('p.id != :id', { id: post.id })
.orderBy('p.createdAt', 'DESC')
.take(limit);
// Prefer posts sharing at least one category or tag
const cats = (post.categories || []).filter(Boolean);
const tags = (post.tags || []).filter(Boolean);
if (cats.length > 0) {
qb.andWhere(
'(' + cats.map((_, i) => `p.categories LIKE :rc${i}`).join(' OR ') + ')',
Object.fromEntries(cats.map((c, i) => [`rc${i}`, `%${c}%`])),
);
} else if (tags.length > 0) {
qb.andWhere(
'(' + tags.map((_, i) => `p.tags LIKE :rt${i}`).join(' OR ') + ')',
Object.fromEntries(tags.map((t, i) => [`rt${i}`, `%${t}%`])),
);
}
const results = await qb.getMany();
// Fallback: if nothing related, return latest posts
if (results.length === 0) {
return this.postRepo
.createQueryBuilder('p')
.where('p.status = :status', { status: PostStatus.PUBLISHED })
.andWhere('p.id != :id', { id: post.id })
.orderBy('p.createdAt', 'DESC')
.take(limit)
.getMany();
}
return results;
}
async findBySlug(slug: string): Promise<BlogPost> {
const post = await this.postRepo
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.addSelect(AUTHOR_COLS)
.where('post.slug = :slug', { slug })
.getOne();
if (!post || post.status !== PostStatus.PUBLISHED) {
throw new NotFoundException('Post not found');
}
return post;
}
async incrementViews(slug: string) {
await this.postRepo.increment({ slug }, 'views', 1);
}
// ─── Admin / authenticated ──────────────────────────────────────────────────
async findAll(query: ListPostsQueryDto, user: any) {
const page = parseInt(query.page || '1', 10);
const pageSize = parseInt(query.pageSize || '20', 10);
const qb = this.postRepo
.createQueryBuilder('post')
.leftJoin('post.author', 'author')
.addSelect(AUTHOR_COLS);
if (user.role !== UserRole.ADMIN) {
qb.where('post.authorId = :uid', { uid: user.sub });
}
if (query.status) {
qb.andWhere('post.status = :status', { status: query.status });
}
if (query.q) {
qb.andWhere('post.title ILIKE :q', { q: `%${query.q}%` });
}
qb.orderBy('post.createdAt', 'DESC');
const total = await qb.getCount();
const posts = await qb.skip((page - 1) * pageSize).take(pageSize).getMany();
return { posts, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
}
async create(dto: CreatePostDto, user: any): Promise<BlogPost> {
let status = dto.status || PostStatus.DRAFT;
// MANAGER can only create drafts
if (user.role === UserRole.MANAGER) {
status = PostStatus.DRAFT;
}
// Use provided slug or generate from title
const slug = dto.slug ? dto.slug.trim() : generateSlug(dto.title);
const post = this.postRepo.create({
id: uuidv4(),
title: dto.title,
slug,
status,
excerpt: dto.excerpt || '',
content: dto.content,
contentFormat: dto.contentFormat,
authorId: user.sub,
featuredImageUrl: dto.featuredImageUrl,
featuredImageAlt: dto.featuredImageAlt,
isFeatured: dto.isFeatured || false,
tags: dto.tags || [],
categories: dto.categories || [],
});
return this.postRepo.save(post);
}
async update(id: string, dto: UpdatePostDto, user: any): Promise<BlogPost> {
const post = await this.postRepo.findOne({ where: { id } });
if (!post) throw new NotFoundException('Post not found');
// Regenerate slug if title changed and no explicit slug provided
if (dto.title && dto.title !== post.title && !dto.slug) {
(dto as any).slug = generateSlug(dto.title);
}
Object.assign(post, dto);
return this.postRepo.save(post);
}
async remove(id: string): Promise<void> {
const post = await this.postRepo.findOne({ where: { id } });
if (!post) throw new NotFoundException('Post not found');
await this.postRepo.remove(post);
}
// ─── Tag cloud ──────────────────────────────────────────────────────────────
async getTagCloud(): Promise<Array<{ name: string; count: number }>> {
const posts = await this.postRepo.find({
where: { status: PostStatus.PUBLISHED },
select: ['tags'],
});
const counts: Record<string, number> = {};
for (const post of posts) {
for (const tag of post.tags || []) {
const t = tag.trim();
if (t) counts[t] = (counts[t] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}
// ─── Category cloud ─────────────────────────────────────────────────────────
async getCategoryCloud(): Promise<Array<{ name: string; count: number }>> {
const posts = await this.postRepo.find({
where: { status: PostStatus.PUBLISHED },
select: ['categories'],
});
const counts: Record<string, number> = {};
for (const post of posts) {
for (const cat of post.categories || []) {
const c = cat.trim();
if (c) counts[c] = (counts[c] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}
}

View File

@@ -0,0 +1,78 @@
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { ContentFormat, PostStatus } from '../entities/blog-post.entity';
/** Parse "true"/"false" strings into a real boolean */
function transformBoolean({ value }: { value: unknown }): boolean | undefined {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return undefined;
}
/** Parse a comma-separated string OR an array into a trimmed string[] */
function transformCSV({ value }: { value: unknown }): string[] {
if (Array.isArray(value)) return (value as unknown[]).map((v) => String(v).trim()).filter(Boolean);
if (typeof value === 'string') return value.split(',').map((s) => s.trim()).filter(Boolean);
return [];
}
export class CreatePostDto {
@IsString()
@MinLength(1)
@MaxLength(255)
title: string;
/** Optional custom slug; if omitted the service generates one from the title */
@IsString()
@IsOptional()
@MaxLength(300)
slug?: string;
@IsString()
@IsOptional()
excerpt?: string;
@IsString()
@MinLength(1)
content: string;
@IsEnum(ContentFormat)
@IsOptional()
contentFormat?: ContentFormat;
@IsEnum(PostStatus)
@IsOptional()
status?: PostStatus;
@IsString()
@IsOptional()
featuredImageUrl?: string;
@IsString()
@IsOptional()
featuredImageAlt?: string;
@IsBoolean()
@IsOptional()
@Transform(transformBoolean)
isFeatured?: boolean;
/** Accepts a comma-separated string "tag1,tag2" or a plain string[] */
@IsString({ each: true })
@IsOptional()
@Transform(transformCSV)
tags?: string[];
/** Accepts a comma-separated string "cat1,cat2" or a plain string[] */
@IsString({ each: true })
@IsOptional()
@Transform(transformCSV)
categories?: string[];
}

View File

@@ -0,0 +1,37 @@
import { IsEnum, IsNumberString, IsOptional, IsString } from 'class-validator';
import { PostStatus } from '../entities/blog-post.entity';
export class ListPostsQueryDto {
@IsNumberString()
@IsOptional()
page?: string;
@IsNumberString()
@IsOptional()
pageSize?: string;
/** Full-text search on title + excerpt */
@IsString()
@IsOptional()
q?: string;
/** Comma-separated tags e.g. "nestjs,htmx" */
@IsString()
@IsOptional()
tags?: string;
/** Single category filter */
@IsString()
@IsOptional()
category?: string;
/** Sort order: newest | oldest | most_viewed | featured */
@IsEnum(['newest', 'oldest', 'most_viewed', 'featured'])
@IsOptional()
sort?: string;
/** Admin/manager status filter */
@IsEnum(PostStatus)
@IsOptional()
status?: PostStatus;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePostDto } from './create-post.dto';
export class UpdatePostDto extends PartialType(CreatePostDto) {}

View File

@@ -0,0 +1,82 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
export enum PostStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
ARCHIVED = 'archived',
}
export enum ContentFormat {
MARKDOWN = 'markdown',
HTML = 'html',
}
@Entity('blog_posts')
export class BlogPost {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 255 })
title: string;
@Column({ length: 300, nullable: true, unique: true })
slug: string;
@Column({ type: 'varchar', length: 20, default: PostStatus.DRAFT })
status: PostStatus;
@Column({ type: 'text', default: '' })
excerpt: string;
@Column({ type: 'text' })
content: string;
@Column({ name: 'content_format', type: 'varchar', length: 20, default: ContentFormat.MARKDOWN })
contentFormat: ContentFormat;
@Column({ name: 'author_id' })
authorId: string;
@ManyToOne(() => User, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'author_id' })
author: User;
@Column({ name: 'featured_image_url', length: 500, nullable: true })
featuredImageUrl: string;
@Column({ name: 'featured_image_alt', length: 255, nullable: true })
featuredImageAlt: string;
@Column({ name: 'is_featured', default: false })
isFeatured: boolean;
@Column({ default: 0 })
views: number;
@Column({ type: 'simple-array', default: '' })
tags: string[];
@Column({ type: 'simple-array', default: '' })
categories: string[];
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
/** Virtual getter — templates use `post.featuredImage.url` and `post.featuredImage.alt` */
get featuredImage(): { url: string; alt: string } | null {
if (!this.featuredImageUrl) return null;
return { url: this.featuredImageUrl, alt: this.featuredImageAlt || '' };
}
}

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../users/entities/user.entity';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,44 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const responseBody = exception.getResponse();
if (typeof responseBody === 'string') {
message = responseBody;
} else if (typeof responseBody === 'object' && responseBody !== null) {
const body = responseBody as any;
message = body.message || body.error || message;
if (Array.isArray(message)) {
message = message[0];
}
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(exception.message, exception.stack);
}
res.status(status).json({
success: false,
error: { code: status, message },
});
}
}

View File

@@ -0,0 +1,54 @@
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { verifyAccessToken } from '../helpers/jwt.helper';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtAuthGuard {
constructor(
private reflector: Reflector,
private configService: ConfigService,
) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('Missing authentication token');
}
try {
const secret = this.configService.get<string>('JWT_ACCESS_SECRET');
const payload = verifyAccessToken(token, secret);
request.user = payload;
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractToken(request: any): string | null {
// Check Authorization header
const authHeader = request.headers?.authorization as string;
if (authHeader?.startsWith('Bearer ')) {
return authHeader.substring(7);
}
// Check cookie
if (request.cookies?.accessToken) {
return request.cookies.accessToken;
}
return null;
}
}

View File

@@ -0,0 +1,44 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { UserRole } from '../../users/entities/user.entity';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('Access denied');
}
if (!requiredRoles.includes(user.role)) {
throw new ForbiddenException(
`Access denied. Required roles: ${requiredRoles.join(', ')}`,
);
}
return true;
}
}

View File

@@ -0,0 +1,46 @@
import * as crypto from 'crypto';
import * as jwt from 'jsonwebtoken';
export interface AccessTokenPayload {
sub: string;
email: string;
role: string;
type: 'access';
}
export interface RefreshTokenPayload {
sub: string;
type: 'refresh';
}
export function signAccessToken(
payload: Omit<AccessTokenPayload, 'type'>,
secret: string,
expiresIn: string,
): string {
return jwt.sign({ ...payload, type: 'access' }, secret, { expiresIn } as any);
}
export function signRefreshToken(
payload: Omit<RefreshTokenPayload, 'type'>,
secret: string,
expiresIn: string,
): string {
return jwt.sign({ ...payload, type: 'refresh' }, secret, { expiresIn } as any);
}
export function verifyAccessToken(token: string, secret: string): AccessTokenPayload {
return jwt.verify(token, secret) as AccessTokenPayload;
}
export function verifyRefreshToken(token: string, secret: string): RefreshTokenPayload {
return jwt.verify(token, secret) as RefreshTokenPayload;
}
export function hashToken(raw: string): string {
return crypto.createHash('sha256').update(raw).digest('hex');
}
export function generateRawToken(): string {
return crypto.randomBytes(48).toString('hex');
}

View File

@@ -0,0 +1,45 @@
import * as nodemailer from 'nodemailer';
export interface MailOptions {
to: string;
subject: string;
html: string;
}
let transporter: nodemailer.Transporter | null = null;
function getTransporter(): nodemailer.Transporter {
if (transporter) return transporter;
const host = process.env.SMTP_HOST;
if (!host) {
// Console fallback for development
transporter = nodemailer.createTransport({ jsonTransport: true });
return transporter;
}
transporter = nodemailer.createTransport({
host,
port: parseInt(process.env.SMTP_PORT || '587', 10),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
return transporter;
}
export async function sendMail(options: MailOptions): Promise<void> {
const from = process.env.MAIL_FROM || 'no-reply@blog.local';
const t = getTransporter();
if (!process.env.SMTP_HOST) {
console.log('[MAIL CONSOLE FALLBACK]');
console.log(` TO: ${options.to}`);
console.log(` SUBJECT: ${options.subject}`);
console.log(` BODY: ${options.html}`);
return;
}
await t.sendMail({ from, ...options });
}

View File

@@ -0,0 +1,14 @@
import { v4 as uuidv4 } from 'uuid';
export function generateSlug(title: string): string {
const base = title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.substring(0, 200);
const suffix = uuidv4().split('-')[0]; // 8-char UUID segment
return `${base}-${suffix}`;
}

View File

@@ -0,0 +1,60 @@
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import * as crypto from 'crypto';
const EXEMPT_PATHS = [
'/auth/login',
'/auth/register',
'/auth/magic-link',
'/auth/refresh',
'/auth/google',
'/auth/google/callback',
'/auth/password-reset',
'/health',
];
const STATE_CHANGING_METHODS = ['POST', 'PATCH', 'PUT', 'DELETE'];
@Injectable()
export class CsrfMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Read or generate CSRF token
let csrfToken: string = req.cookies?.csrfToken;
if (!csrfToken) {
csrfToken = crypto.randomBytes(24).toString('hex');
}
// Set as non-httpOnly cookie so JS can read it
// SameSite=none required for cross-subdomain (frontend ↔ backend on different subdomains)
const secure = process.env.COOKIE_SECURE === 'true';
const domain = process.env.COOKIE_DOMAIN || undefined;
const sameSite = secure ? ('none' as const) : ('lax' as const);
res.cookie('csrfToken', csrfToken, {
httpOnly: false,
sameSite,
secure,
domain,
});
(req as any).csrfToken = csrfToken;
// Validate on state-changing methods
if (STATE_CHANGING_METHODS.includes(req.method)) {
const isExempt = EXEMPT_PATHS.some((p) => req.path.startsWith(p));
if (!isExempt) {
const hasCookieAuth =
req.cookies?.accessToken || req.cookies?.refreshToken;
if (hasCookieAuth) {
const tokenFromHeader = req.headers['x-csrf-token'] as string;
const tokenFromBody = (req.body as any)?._csrf;
const provided = tokenFromHeader || tokenFromBody;
if (!provided || provided !== csrfToken) {
throw new ForbiddenException('Invalid CSRF token');
}
}
}
}
next();
}
}

View File

@@ -0,0 +1,131 @@
import { plainToClass, Transform } from 'class-transformer';
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, validateSync } from 'class-validator';
enum Environment {
Development = 'development',
Production = 'production',
Test = 'test',
}
class EnvironmentVariables {
@IsEnum(Environment)
@IsOptional()
NODE_ENV: Environment = Environment.Development;
@IsString()
@IsOptional()
HOST: string = '0.0.0.0';
@IsNumber()
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
PORT: number = 3000;
@IsString()
@IsOptional()
APP_URL: string = 'http://localhost:3000';
@IsString()
@IsNotEmpty()
DB_HOST: string;
@IsNumber()
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
DB_PORT: number = 5432;
@IsString()
@IsNotEmpty()
DB_USER: string;
@IsString()
@IsNotEmpty()
DB_PASSWORD: string;
@IsString()
@IsNotEmpty()
DB_NAME: string;
@IsString()
@IsOptional()
DB_SSL: string = 'false';
@IsString()
@IsNotEmpty()
JWT_ACCESS_SECRET: string;
@IsString()
@IsNotEmpty()
JWT_REFRESH_SECRET: string;
@IsString()
@IsOptional()
JWT_ACCESS_EXPIRES_IN: string = '15m';
@IsString()
@IsOptional()
JWT_REFRESH_EXPIRES_IN: string = '7d';
@IsNumber()
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
MAGIC_LINK_TTL_MINUTES: number = 20;
@IsNumber()
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
PASSWORD_RESET_TTL_MINUTES: number = 30;
@IsString()
@IsOptional()
COOKIE_SECURE: string = 'false';
@IsString()
@IsOptional()
COOKIE_DOMAIN: string = '';
@IsString()
@IsOptional()
MAIL_FROM: string = 'no-reply@blog.local';
@IsString()
@IsOptional()
SMTP_HOST: string = '';
@IsNumber()
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
SMTP_PORT: number = 587;
@IsString()
@IsOptional()
SMTP_USER: string = '';
@IsString()
@IsOptional()
SMTP_PASS: string = '';
@IsString()
@IsOptional()
GOOGLE_CLIENT_ID: string = '';
@IsString()
@IsOptional()
GOOGLE_CLIENT_SECRET: string = '';
@IsString()
@IsOptional()
GOOGLE_CALLBACK_URL: string = 'http://localhost:3000/auth/google/callback';
}
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToClass(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}

42
backend/src/main.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common';
import { join } from 'path';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const cookieParser = require('cookie-parser');
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// ─── CORS — allow Next.js frontend ──────────────────────────────────────────
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
});
// ─── Static assets ──────────────────────────────────────────────────────────
app.useStaticAssets(join(process.cwd(), 'public'));
// ─── Middleware ──────────────────────────────────────────────────────────────
app.use(cookieParser());
// ─── Global pipes ────────────────────────────────────────────────────────────
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: false,
transform: true,
}),
);
// ─── Start ───────────────────────────────────────────────────────────────────
const port = process.env.PORT || 3000;
const host = process.env.HOST || '0.0.0.0';
await app.listen(port, host);
console.log(`🚀 Server running at http://${host}:${port}`);
}
bootstrap();

View File

@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('magic_link_tokens')
export class MagicLinkToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', nullable: true })
userId: string;
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ length: 255 })
email: string;
@Column({ name: 'token_hash', length: 255, unique: true })
tokenHash: string;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'consumed_at', type: 'timestamptz', nullable: true })
consumedAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@@ -0,0 +1,40 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('oauth_accounts')
@Unique('uq_oauth_provider_provider_id', ['provider', 'providerId'])
export class OAuthAccount {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ length: 50 })
provider: string;
@Column({ name: 'provider_id', length: 255 })
providerId: string;
@Column({ length: 255, nullable: true })
email: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@@ -0,0 +1,34 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('password_reset_tokens')
export class PasswordResetToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'token_hash', length: 255, unique: true })
tokenHash: string;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'consumed_at', type: 'timestamptz', nullable: true })
consumedAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'token_hash', length: 255, unique: true })
tokenHash: string;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt: Date;
@Column({ name: 'replaced_by_token_hash', nullable: true })
replacedByTokenHash: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Column({ name: 'ip', length: 64, nullable: true })
ip: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RefreshToken } from './entities/refresh-token.entity';
import { MagicLinkToken } from './entities/magic-link-token.entity';
import { PasswordResetToken } from './entities/password-reset-token.entity';
import { OAuthAccount } from './entities/oauth-account.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
RefreshToken,
MagicLinkToken,
PasswordResetToken,
OAuthAccount,
]),
],
exports: [TypeOrmModule],
})
export class TokensModule {}

View File

@@ -0,0 +1,20 @@
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
import { UserRole } from '../entities/user.entity';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@IsOptional()
name?: string;
@IsEnum(UserRole)
@IsOptional()
role?: UserRole;
@IsString()
@MinLength(8)
@IsOptional()
password?: string;
}

View File

@@ -0,0 +1,8 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateUserNameDto {
@IsString()
@MaxLength(120)
@IsOptional()
name?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { UserRole } from '../entities/user.entity';
export class UpdateUserRoleDto {
@IsEnum(UserRole)
role: UserRole;
}

View File

@@ -0,0 +1,41 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export enum UserRole {
ADMIN = 'ADMIN',
MANAGER = 'MANAGER',
MEMBER = 'MEMBER',
}
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 255, unique: true })
email: string;
@Column({ length: 120, nullable: true })
name: string;
@Column({ type: 'varchar', length: 20, default: UserRole.MEMBER })
role: UserRole;
@Column({ name: 'password_hash', nullable: true })
passwordHash: string;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@@ -0,0 +1,64 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserNameDto } from './dto/update-user-name.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from './entities/user.entity';
@Controller('users')
@Roles(UserRole.ADMIN)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
async findAll(
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
@Query('q') q?: string,
) {
return this.usersService.findAll(parseInt(page, 10), parseInt(pageSize, 10), q);
}
@Post()
async create(@Body() dto: CreateUserDto) {
const user = await this.usersService.create(dto);
const { passwordHash, ...safe } = user as any;
return { success: true, user: safe };
}
@Patch(':id')
async updateName(
@Param('id') id: string,
@Body() dto: UpdateUserNameDto,
) {
const user = await this.usersService.updateName(id, dto);
const { passwordHash, ...safe } = user as any;
return { success: true, user: safe };
}
@Patch(':id/role')
async updateRole(
@Param('id') id: string,
@Body() dto: UpdateUserRoleDto,
) {
const user = await this.usersService.updateRole(id, dto);
const { passwordHash, ...safe } = user as any;
return { success: true, user: safe };
}
@Delete(':id')
async remove(@Param('id') id: string) {
await this.usersService.remove(id);
return { success: true };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,91 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { User, UserRole } from './entities/user.entity';
const mockUser: Partial<User> = {
id: 'uuid-1',
email: 'test@test.com',
name: 'Test User',
role: UserRole.MEMBER,
isActive: true,
passwordHash: 'hash',
};
const mockRepo = {
findAndCount: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
};
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: getRepositoryToken(User), useValue: mockRepo },
],
}).compile();
service = module.get<UsersService>(UsersService);
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return paginated users', async () => {
mockRepo.findAndCount.mockResolvedValue([[mockUser], 1]);
const result = await service.findAll(1, 20);
expect(result.users).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.totalPages).toBe(1);
});
});
describe('findById', () => {
it('should return user by id', async () => {
mockRepo.findOne.mockResolvedValue(mockUser);
const user = await service.findById('uuid-1');
expect(user.id).toBe('uuid-1');
});
it('should throw NotFoundException for unknown id', async () => {
mockRepo.findOne.mockResolvedValue(null);
await expect(service.findById('unknown')).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('should create a new user', async () => {
mockRepo.findOne.mockResolvedValue(null); // no existing user
mockRepo.create.mockReturnValue({ ...mockUser });
mockRepo.save.mockResolvedValue({ ...mockUser });
const result = await service.create({ email: 'new@test.com', password: 'password123' });
expect(mockRepo.save).toHaveBeenCalled();
});
it('should throw ConflictException for duplicate email', async () => {
mockRepo.findOne.mockResolvedValue(mockUser); // existing user
await expect(
service.create({ email: mockUser.email, password: 'password123' }),
).rejects.toThrow(ConflictException);
});
});
describe('updateRole', () => {
it('should update user role', async () => {
mockRepo.findOne.mockResolvedValue({ ...mockUser });
mockRepo.update.mockResolvedValue({});
mockRepo.findOne.mockResolvedValueOnce({ ...mockUser })
.mockResolvedValueOnce({ ...mockUser, role: UserRole.MANAGER });
const result = await service.updateRole('uuid-1', { role: UserRole.MANAGER });
expect(mockRepo.update).toHaveBeenCalledWith('uuid-1', { role: UserRole.MANAGER });
});
});
});

View File

@@ -0,0 +1,101 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { User, UserRole } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserNameDto } from './dto/update-user-name.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
async findAll(page = 1, pageSize = 20, q?: string) {
const where = q
? [{ email: ILike(`%${q}%`) }, { name: ILike(`%${q}%`) }]
: {};
const [users, total] = await this.userRepo.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
users,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async findById(id: string): Promise<User> {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepo.findOne({ where: { email } });
}
async create(dto: CreateUserDto): Promise<User> {
const existing = await this.findByEmail(dto.email);
if (existing) throw new ConflictException('Email already in use');
const user = this.userRepo.create({
id: uuidv4(),
email: dto.email,
name: dto.name,
role: dto.role || UserRole.MEMBER,
passwordHash: dto.password ? await bcrypt.hash(dto.password, 12) : null,
});
return this.userRepo.save(user);
}
async updateName(id: string, dto: UpdateUserNameDto): Promise<User> {
await this.findById(id);
await this.userRepo.update(id, { name: dto.name });
return this.findById(id);
}
async updateRole(id: string, dto: UpdateUserRoleDto): Promise<User> {
await this.findById(id);
await this.userRepo.update(id, { role: dto.role });
return this.findById(id);
}
async remove(id: string): Promise<void> {
await this.findById(id); // throws NotFoundException if not found
await this.userRepo.delete(id);
}
async setPassword(id: string, password: string): Promise<void> {
const hash = await bcrypt.hash(password, 12);
await this.userRepo.update(id, { passwordHash: hash });
}
async upsertByEmail(
email: string,
data: Partial<Pick<User, 'name' | 'role' | 'passwordHash' | 'isActive'>>,
): Promise<User> {
let user = await this.findByEmail(email);
if (!user) {
user = this.userRepo.create({ id: uuidv4(), email, ...data });
} else {
Object.assign(user, data);
}
return this.userRepo.save(user);
}
}

View File

@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@@ -0,0 +1,141 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import * as cookieParser from 'cookie-parser';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AppModule } from '../src/app.module';
import { User } from '../src/users/entities/user.entity';
describe('Auth (e2e)', () => {
let app: INestApplication;
let userRepo: Repository<User>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
userRepo = moduleFixture.get<Repository<User>>(getRepositoryToken(User));
});
afterAll(async () => {
await app.close();
});
const testEmail = `test-${Date.now()}@example.com`;
const testPassword = 'Test1234!';
describe('POST /auth/register', () => {
it('should register a new user and return tokens', async () => {
const res = await request(app.getHttpServer())
.post('/auth/register')
.send({ email: testEmail, password: testPassword, name: 'Test User' })
.expect(201);
expect(res.body.success).toBe(true);
expect(res.body.accessToken).toBeDefined();
expect(res.body.user.email).toBe(testEmail);
expect(res.body.user.passwordHash).toBeUndefined();
});
it('should reject duplicate email', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({ email: testEmail, password: testPassword })
.expect(409);
});
it('should reject short password', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({ email: 'new@example.com', password: '123' })
.expect(400);
});
});
describe('POST /auth/login', () => {
it('should login with correct credentials', async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: testEmail, password: testPassword })
.expect(201);
expect(res.body.success).toBe(true);
expect(res.body.accessToken).toBeDefined();
// Cookies should be set
const cookies = res.headers['set-cookie'] as string[];
expect(cookies).toBeDefined();
const hasAccessCookie = cookies.some((c) => c.startsWith('accessToken='));
expect(hasAccessCookie).toBe(true);
});
it('should reject wrong password', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({ email: testEmail, password: 'wrongpassword' })
.expect(401);
});
it('should reject non-existent email', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'nonexistent@example.com', password: testPassword })
.expect(401);
});
});
describe('POST /auth/refresh', () => {
it('should rotate refresh token', async () => {
// First login to get refresh token
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: testEmail, password: testPassword });
const cookies = loginRes.headers['set-cookie'] as string[];
const refreshCookie = cookies.find((c) => c.startsWith('refreshToken='));
expect(refreshCookie).toBeDefined();
// Use refresh token
const refreshRes = await request(app.getHttpServer())
.post('/auth/refresh')
.set('Cookie', cookies)
.expect(201);
expect(refreshRes.body.success).toBe(true);
expect(refreshRes.body.accessToken).toBeDefined();
});
it('should reject missing refresh token', async () => {
await request(app.getHttpServer())
.post('/auth/refresh')
.expect(401);
});
});
describe('POST /auth/magic-link', () => {
it('should accept magic link request for existing email', async () => {
const res = await request(app.getHttpServer())
.post('/auth/magic-link')
.send({ email: testEmail })
.expect(201);
expect(res.body.success).toBe(true);
});
it('should accept magic link request for non-existing email (no enumeration)', async () => {
const res = await request(app.getHttpServer())
.post('/auth/magic-link')
.send({ email: 'nobody@nowhere.com' })
.expect(201);
expect(res.body.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,12 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(uuid)/)"
]
}

View File

@@ -0,0 +1,133 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import * as cookieParser from 'cookie-parser';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { AppModule } from '../src/app.module';
import { BlogPost, PostStatus, ContentFormat } from '../src/blog-posts/entities/blog-post.entity';
import { User, UserRole } from '../src/users/entities/user.entity';
describe('Public Posts (e2e)', () => {
let app: INestApplication;
let postRepo: Repository<BlogPost>;
let userRepo: Repository<User>;
let testAuthorId: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
postRepo = moduleFixture.get<Repository<BlogPost>>(getRepositoryToken(BlogPost));
userRepo = moduleFixture.get<Repository<User>>(getRepositoryToken(User));
// Create test author
const author = userRepo.create({
id: uuidv4(),
email: `author-${Date.now()}@test.com`,
role: UserRole.ADMIN,
isActive: true,
});
await userRepo.save(author);
testAuthorId = author.id;
// Create test posts
await postRepo.save([
postRepo.create({
id: uuidv4(),
title: 'Published Test Post',
slug: `published-test-${Date.now()}`,
status: PostStatus.PUBLISHED,
excerpt: 'A published post for testing',
content: 'Test content',
contentFormat: ContentFormat.MARKDOWN,
authorId: testAuthorId,
tags: ['test', 'published'],
categories: ['Test'],
}),
postRepo.create({
id: uuidv4(),
title: 'Draft Test Post',
slug: `draft-test-${Date.now()}`,
status: PostStatus.DRAFT,
excerpt: 'A draft post',
content: 'Draft content',
contentFormat: ContentFormat.MARKDOWN,
authorId: testAuthorId,
tags: ['test', 'draft'],
categories: ['Test'],
}),
]);
});
afterAll(async () => {
await app.close();
});
describe('GET /blog-posts/public', () => {
it('should return only published posts', async () => {
const res = await request(app.getHttpServer())
.get('/blog-posts/public')
.expect(200);
expect(Array.isArray(res.body.posts)).toBe(true);
const allPublished = res.body.posts.every((p: any) => p.status === 'published');
expect(allPublished).toBe(true);
});
it('should support search by query', async () => {
const res = await request(app.getHttpServer())
.get('/blog-posts/public?q=Published+Test')
.expect(200);
expect(res.body.posts.length).toBeGreaterThanOrEqual(1);
});
it('should support pagination', async () => {
const res = await request(app.getHttpServer())
.get('/blog-posts/public?page=1&pageSize=2')
.expect(200);
expect(res.body.posts.length).toBeLessThanOrEqual(2);
expect(res.body.page).toBe(1);
});
});
describe('GET /blog-posts/public/:slug', () => {
it('should return a published post by slug', async () => {
// First get a published post slug
const listRes = await request(app.getHttpServer()).get('/blog-posts/public');
const slug = listRes.body.posts[0]?.slug;
expect(slug).toBeDefined();
const res = await request(app.getHttpServer())
.get(`/blog-posts/public/${slug}`)
.expect(200);
expect(res.body.post.slug).toBe(slug);
});
it('should return 404 for draft post', async () => {
await request(app.getHttpServer())
.get(`/blog-posts/public/draft-test-99999`)
.expect(404);
});
});
describe('GET /blog-posts/public/featured', () => {
it('should return featured published posts', async () => {
const res = await request(app.getHttpServer())
.get('/blog-posts/public/featured')
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
});
});
});

View File

@@ -0,0 +1,125 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import * as cookieParser from 'cookie-parser';
import { AppModule } from '../src/app.module';
describe('RBAC (e2e)', () => {
let app: INestApplication;
// We'll store cookies per user role
let adminCookies: string[];
let managerCookies: string[];
let memberCookies: string[];
const ts = Date.now();
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
// Register three users, then promote via direct DB or use existing seed
// For simplicity, register all as MEMBER — we test permission differences
const adminEmail = `admin-rbac-${ts}@test.com`;
const managerEmail = `manager-rbac-${ts}@test.com`;
const memberEmail = `member-rbac-${ts}@test.com`;
const pw = 'RbacTest123!';
// Register all
await request(app.getHttpServer())
.post('/auth/register')
.send({ email: adminEmail, password: pw });
await request(app.getHttpServer())
.post('/auth/register')
.send({ email: managerEmail, password: pw });
const memberReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ email: memberEmail, password: pw });
// Store member cookies
memberCookies = memberReg.headers['set-cookie'] as string[];
// Login as the newly registered users
const adminLogin = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: adminEmail, password: pw });
adminCookies = adminLogin.headers['set-cookie'] as string[];
const managerLogin = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: managerEmail, password: pw });
managerCookies = managerLogin.headers['set-cookie'] as string[];
});
afterAll(async () => {
await app.close();
});
describe('MEMBER permissions', () => {
it('should NOT be able to create a blog post', async () => {
await request(app.getHttpServer())
.post('/blog-posts')
.set('Cookie', memberCookies)
.send({ title: 'Member Post', content: 'Content' })
.expect(403);
});
it('should NOT be able to list users', async () => {
await request(app.getHttpServer())
.get('/users')
.set('Cookie', memberCookies)
.expect(403);
});
it('should be able to view dashboard', async () => {
await request(app.getHttpServer())
.get('/dashboard')
.set('Cookie', memberCookies)
.expect(200);
});
});
describe('Unauthenticated access', () => {
it('should allow access to public posts', async () => {
await request(app.getHttpServer())
.get('/blog-posts/public')
.expect(200);
});
it('should deny access to dashboard', async () => {
await request(app.getHttpServer())
.get('/dashboard')
.expect(401);
});
it('should deny access to users list', async () => {
await request(app.getHttpServer())
.get('/users')
.expect(401);
});
});
describe('Public endpoints', () => {
it('GET /health should be publicly accessible', async () => {
const res = await request(app.getHttpServer())
.get('/health')
.expect(200);
expect(res.body.status).toBe('ok');
});
it('GET / should be publicly accessible', async () => {
await request(app.getHttpServer())
.get('/')
.expect(200);
});
});
});

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

26
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": ["src/*"]
}
}
}