add moodules

This commit is contained in:
2026-04-17 23:42:24 +01:00
parent 532458ecfa
commit a7fbb2c466
54 changed files with 3074 additions and 74 deletions
+15 -1
View File
@@ -6,7 +6,10 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"prisma:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"build": "prisma generate && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
@@ -21,12 +24,19 @@
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"jwks-rsa": "^4.0.1",
"minio": "^8.0.6",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -38,14 +48,18 @@
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^24.0.0",
"@types/pg": "^8.15.5",
"@types/supertest": "^7.0.0",
"dotenv": "^16.5.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^7.7.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
+1167 -49
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url: env('DATABASE_URL'),
},
});
+143
View File
@@ -0,0 +1,143 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum UserRole {
ADMIN
EDITOR
AUTHOR
READER
}
enum ArticleStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model User {
id String @id @default(uuid())
keycloakId String @unique
email String @unique
displayName String?
avatarKey String?
role UserRole @default(READER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
articles Article[]
comments Comment[]
bookmarks Bookmark[]
}
model Category {
id String @id @default(uuid())
name String
slug String @unique
parentId String?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
articles ArticleCategory[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parentId])
}
model Tag {
id String @id @default(uuid())
name String
slug String @unique
articles ArticleTag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Article {
id String @id @default(uuid())
title String
slug String @unique
content String @db.Text
excerpt String? @db.Text
status ArticleStatus @default(DRAFT)
publishedAt DateTime?
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
categories ArticleCategory[]
tags ArticleTag[]
images ArticleImage[]
comments Comment[]
bookmarks Bookmark[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, publishedAt])
@@index([authorId])
}
model ArticleCategory {
articleId String
categoryId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([articleId, categoryId])
}
model ArticleTag {
articleId String
tagId String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([articleId, tagId])
}
model Image {
id String @id @default(uuid())
fileKey String @unique
articles ArticleImage[]
createdAt DateTime @default(now())
}
model ArticleImage {
articleId String
imageId String
sortOrder Int @default(0)
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
@@id([articleId, imageId])
@@index([articleId, sortOrder])
}
model Comment {
id String @id @default(uuid())
content String @db.Text
articleId String
userId String
parentId String?
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
parent Comment? @relation("CommentThread", fields: [parentId], references: [id], onDelete: Cascade)
replies Comment[] @relation("CommentThread")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([articleId])
@@index([parentId])
}
model Bookmark {
userId String
articleId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([userId, articleId])
}
+25 -1
View File
@@ -1,11 +1,35 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import configuration from './config/configuration';
import { StorageModule } from './infrastructure/storage/storage.module';
import { ArticlesModule } from './module/articles/articles.module';
import { AuthModule } from './module/auth/auth.module';
import { BookmarksModule } from './module/bookmarks/bookmarks.module';
import { CategoriesModule } from './module/categories/categories.module';
import { CommentsModule } from './module/comments/comments.module';
import { ImagesModule } from './module/images/images.module';
import { ProfileModule } from './module/profile/profile.module';
import { TagsModule } from './module/tags/tags.module';
import { UsersModule } from './module/users/users.module';
import { PrismaModule } from './shared/prisma/prisma.module';
@Module({
imports: [AuthModule, ProfileModule],
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
PrismaModule,
StorageModule,
AuthModule,
UsersModule,
ProfileModule,
CategoriesModule,
TagsModule,
ArticlesModule,
ImagesModule,
CommentsModule,
BookmarksModule,
],
controllers: [AppController],
providers: [AppService],
})
+16
View File
@@ -0,0 +1,16 @@
export default () => ({
port: parseInt(process.env.PORT ?? '3001', 10),
databaseUrl: process.env.DATABASE_URL,
minio: {
endpoint: process.env.MINIO_ENDPOINT ?? 'localhost',
port: parseInt(process.env.MINIO_PORT ?? '9000', 10),
useSsl: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY ?? '',
secretKey: process.env.MINIO_SECRET_KEY ?? '',
bucket: process.env.MINIO_BUCKET ?? 'tvone',
publicBaseUrl: process.env.MINIO_PUBLIC_BASE_URL ?? '',
},
thumbor: {
publicBaseUrl: process.env.THUMBOR_PUBLIC_URL ?? '',
},
});
@@ -0,0 +1,56 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Client } from 'minio';
import { randomUUID } from 'crypto';
@Injectable()
export class MinioService implements OnModuleInit {
private client: Client | null = null;
private bucket: string;
constructor(private readonly config: ConfigService) {
this.bucket = this.config.get<string>('minio.bucket', 'tvone');
}
async onModuleInit() {
const accessKey = this.config.get<string>('minio.accessKey', '');
const secretKey = this.config.get<string>('minio.secretKey', '');
if (!accessKey || !secretKey) {
return;
}
this.client = new Client({
endPoint: this.config.get<string>('minio.endpoint', 'localhost'),
port: this.config.get<number>('minio.port', 9000),
useSSL: this.config.get<boolean>('minio.useSsl', false),
accessKey,
secretKey,
});
const exists = await this.client.bucketExists(this.bucket).catch(() => false);
if (!exists) {
await this.client.makeBucket(this.bucket, '');
}
}
isConfigured(): boolean {
return this.client !== null;
}
async putObject(
objectName: string,
buffer: Buffer,
contentType: string,
): Promise<string> {
if (!this.client) {
throw new Error('MinIO is not configured (MINIO_ACCESS_KEY / MINIO_SECRET_KEY)');
}
await this.client.putObject(this.bucket, objectName, buffer, buffer.length, {
'Content-Type': contentType,
});
return objectName;
}
buildOriginalsKey(prefix: string, originalName: string): string {
const safe = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
return `${prefix}/${randomUUID()}-${safe}`;
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { MinioService } from './minio.service';
import { ThumborUrlService } from './thumbor-url.service';
@Global()
@Module({
providers: [MinioService, ThumborUrlService],
exports: [MinioService, ThumborUrlService],
})
export class StorageModule {}
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/**
* Builds Thumbor-style URLs for the CDN/Varnish layer in front of Thumbor.
* Example path segment: /800x0/smart/originals/articles/abc.jpg
*/
@Injectable()
export class ThumborUrlService {
constructor(private readonly config: ConfigService) {}
imageUrl(fileKey: string, options?: { width?: number; height?: number; smart?: boolean }) {
const base = this.config.get<string>('thumbor.publicBaseUrl', '');
if (!base) {
return { path: `/${fileKey}`, full: null as string | null };
}
const w = options?.width ?? 0;
const h = options?.height ?? 0;
const smart = options?.smart !== false ? 'smart' : 'fit-in';
const dims = `${w}x${h}`;
const path = `/${dims}/${smart}/${fileKey}`;
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
return { path, full: `${normalizedBase}${path}` };
}
}
+11 -3
View File
@@ -1,12 +1,20 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
app.enableCors({
origin: ["http://localhost:3000"], // 👈 array is safer
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
origin: ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
});
await app.listen(process.env.PORT ?? 3001);
@@ -0,0 +1,86 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { User, UserRole } from '@prisma/client';
import { CurrentDbUser } from '../../shared/decorators/current-db-user.decorator';
import { Roles } from '../../shared/decorators/roles.decorator';
import { RolesGuard } from '../../shared/guards/roles.guard';
import { UserProvisioningGuard } from '../users/user-provisioning.guard';
import { ArticlesService } from './articles.service';
import { AttachImageDto } from './dto/attach-image.dto';
import { CreateArticleDto } from './dto/create-article.dto';
import { ListArticlesQueryDto, ManageArticlesQueryDto } from './dto/list-articles-query.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
@Controller('articles')
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@Get()
listPublished(@Query() query: ListArticlesQueryDto) {
return this.articlesService.listPublished(query);
}
@Get('by-slug/:slug')
findPublishedBySlug(@Param('slug') slug: string) {
return this.articlesService.findPublishedBySlug(slug);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard)
@Get('manage')
listManage(@CurrentDbUser() user: User, @Query() query: ManageArticlesQueryDto) {
return this.articlesService.listManage(user, query);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard)
@Get('by-id/:id')
findById(@Param('id', ParseUUIDPipe) id: string, @CurrentDbUser() user: User) {
return this.articlesService.findByIdForUser(id, user);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.AUTHOR)
@Post()
create(@CurrentDbUser() user: User, @Body() dto: CreateArticleDto) {
return this.articlesService.create(user, dto);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.AUTHOR)
@Patch(':id')
update(
@CurrentDbUser() user: User,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateArticleDto,
) {
return this.articlesService.update(user, id, dto);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.AUTHOR)
@Delete(':id')
remove(@CurrentDbUser() user: User, @Param('id', ParseUUIDPipe) id: string) {
return this.articlesService.remove(user, id);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.AUTHOR)
@Post(':id/images')
attachImage(
@CurrentDbUser() user: User,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AttachImageDto,
) {
return this.articlesService.attachImage(user, id, dto);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { ArticlesController } from './articles.controller';
import { ArticlesService } from './articles.service';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'keycloak' }),
AuthModule,
UsersModule,
],
controllers: [ArticlesController],
providers: [ArticlesService],
})
export class ArticlesModule {}
+320
View File
@@ -0,0 +1,320 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Article, ArticleStatus, Prisma, User, UserRole } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { slugify } from '../../shared/utils/slug';
import { AttachImageDto } from './dto/attach-image.dto';
import { CreateArticleDto } from './dto/create-article.dto';
import { ListArticlesQueryDto, ManageArticlesQueryDto } from './dto/list-articles-query.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
const articleInclude = {
author: {
select: { id: true, displayName: true, email: true, avatarKey: true },
},
categories: { include: { category: true } },
tags: { include: { tag: true } },
images: { include: { image: true }, orderBy: { sortOrder: 'asc' as const } },
} satisfies Prisma.ArticleInclude;
function canManageAllArticles(user: User) {
return user.role === UserRole.ADMIN || user.role === UserRole.EDITOR;
}
function canAuthorArticles(user: User) {
return (
user.role === UserRole.ADMIN ||
user.role === UserRole.EDITOR ||
user.role === UserRole.AUTHOR
);
}
function canEditArticle(user: User, article: Article) {
if (canManageAllArticles(user)) return true;
return user.role === UserRole.AUTHOR && article.authorId === user.id;
}
@Injectable()
export class ArticlesService {
constructor(private readonly prisma: PrismaService) {}
async listPublished(query: ListArticlesQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Prisma.ArticleWhereInput = {
status: ArticleStatus.PUBLISHED,
...(query.search
? {
OR: [
{ title: { contains: query.search, mode: 'insensitive' } },
{ excerpt: { contains: query.search, mode: 'insensitive' } },
],
}
: {}),
...(query.categoryId
? {
categories: { some: { categoryId: query.categoryId } },
}
: {}),
...(query.tagId
? {
tags: { some: { tagId: query.tagId } },
}
: {}),
};
const [items, total] = await Promise.all([
this.prisma.article.findMany({
where,
include: articleInclude,
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.article.count({ where }),
]);
return { items, total, page, limit };
}
async listManage(user: User, query: ManageArticlesQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Prisma.ArticleWhereInput = {
...(canManageAllArticles(user) ? {} : { authorId: user.id }),
...(query.status ? { status: query.status } : {}),
...(query.search
? {
OR: [
{ title: { contains: query.search, mode: 'insensitive' } },
{ excerpt: { contains: query.search, mode: 'insensitive' } },
],
}
: {}),
...(query.categoryId
? {
categories: { some: { categoryId: query.categoryId } },
}
: {}),
...(query.tagId
? {
tags: { some: { tagId: query.tagId } },
}
: {}),
};
const [items, total] = await Promise.all([
this.prisma.article.findMany({
where,
include: articleInclude,
orderBy: [{ updatedAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.article.count({ where }),
]);
return { items, total, page, limit };
}
async findPublishedBySlug(slug: string) {
const article = await this.prisma.article.findFirst({
where: { slug, status: ArticleStatus.PUBLISHED },
include: articleInclude,
});
if (!article) throw new NotFoundException('Article not found');
return article;
}
async findByIdForUser(id: string, user: User) {
const article = await this.prisma.article.findUnique({
where: { id },
include: articleInclude,
});
if (!article) throw new NotFoundException('Article not found');
if (article.status === ArticleStatus.PUBLISHED) return article;
if (!canEditArticle(user, article)) {
throw new ForbiddenException('You cannot view this article');
}
return article;
}
async create(user: User, dto: CreateArticleDto) {
if (!canAuthorArticles(user)) {
throw new ForbiddenException('Cannot create articles');
}
const slug = await this.uniqueSlug(dto.slug?.trim() || slugify(dto.title));
const status = dto.status ?? ArticleStatus.DRAFT;
const publishedAt =
status === ArticleStatus.PUBLISHED ? new Date() : null;
return this.prisma.$transaction(async (tx) => {
const created = await tx.article.create({
data: {
title: dto.title,
slug,
content: dto.content,
excerpt: dto.excerpt ?? null,
status,
publishedAt,
authorId: user.id,
categories: dto.categoryIds?.length
? {
create: dto.categoryIds.map((categoryId) => ({
category: { connect: { id: categoryId } },
})),
}
: undefined,
tags: dto.tagIds?.length
? {
create: dto.tagIds.map((tagId) => ({
tag: { connect: { id: tagId } },
})),
}
: undefined,
},
include: articleInclude,
});
if (dto.imageIds?.length) {
await this.replaceArticleImagesTx(tx, created.id, dto.imageIds);
}
return tx.article.findUniqueOrThrow({
where: { id: created.id },
include: articleInclude,
});
});
}
async update(user: User, id: string, dto: UpdateArticleDto) {
const existing = await this.prisma.article.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Article not found');
if (!canEditArticle(user, existing)) {
throw new ForbiddenException('Cannot edit this article');
}
let slug = existing.slug;
if (dto.slug?.trim()) {
slug = await this.uniqueSlug(dto.slug.trim(), id);
} else if (dto.title && dto.title !== existing.title) {
slug = await this.uniqueSlug(slugify(dto.title), id);
}
let publishedAt = existing.publishedAt;
let status = dto.status ?? existing.status;
if (dto.status === ArticleStatus.PUBLISHED && existing.status !== ArticleStatus.PUBLISHED) {
publishedAt = new Date();
status = ArticleStatus.PUBLISHED;
}
if (dto.status === ArticleStatus.DRAFT) {
publishedAt = null;
status = ArticleStatus.DRAFT;
}
if (dto.status === ArticleStatus.ARCHIVED) {
status = ArticleStatus.ARCHIVED;
}
return this.prisma.$transaction(async (tx) => {
await tx.article.update({
where: { id },
data: {
title: dto.title ?? undefined,
slug,
content: dto.content ?? undefined,
excerpt: dto.excerpt === undefined ? undefined : dto.excerpt,
status,
publishedAt,
},
});
if (dto.categoryIds) {
await tx.articleCategory.deleteMany({ where: { articleId: id } });
if (dto.categoryIds.length) {
await tx.articleCategory.createMany({
data: dto.categoryIds.map((categoryId) => ({ articleId: id, categoryId })),
});
}
}
if (dto.tagIds) {
await tx.articleTag.deleteMany({ where: { articleId: id } });
if (dto.tagIds.length) {
await tx.articleTag.createMany({
data: dto.tagIds.map((tagId) => ({ articleId: id, tagId })),
});
}
}
if (dto.imageIds) {
await this.replaceArticleImagesTx(tx, id, dto.imageIds);
}
return tx.article.findUniqueOrThrow({
where: { id },
include: articleInclude,
});
});
}
async remove(user: User, id: string) {
const existing = await this.prisma.article.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Article not found');
if (!canEditArticle(user, existing)) {
throw new ForbiddenException('Cannot delete this article');
}
await this.prisma.article.delete({ where: { id } });
return { deleted: true };
}
async attachImage(user: User, articleId: string, dto: AttachImageDto) {
const article = await this.prisma.article.findUnique({ where: { id: articleId } });
if (!article) throw new NotFoundException('Article not found');
if (!canEditArticle(user, article)) {
throw new ForbiddenException('Cannot edit this article');
}
const image = await this.prisma.image.findUnique({ where: { id: dto.imageId } });
if (!image) throw new NotFoundException('Image not found');
await this.prisma.articleImage.upsert({
where: { articleId_imageId: { articleId, imageId: dto.imageId } },
create: {
articleId,
imageId: dto.imageId,
sortOrder: dto.sortOrder ?? 0,
},
update: { sortOrder: dto.sortOrder ?? 0 },
});
return this.prisma.article.findUniqueOrThrow({
where: { id: articleId },
include: articleInclude,
});
}
private async replaceArticleImagesTx(
tx: Prisma.TransactionClient,
articleId: string,
imageIds: string[],
) {
await tx.articleImage.deleteMany({ where: { articleId } });
if (!imageIds.length) return;
const rows = imageIds.map((imageId, index) => ({
articleId,
imageId,
sortOrder: index,
}));
await tx.articleImage.createMany({ data: rows });
}
private async uniqueSlug(base: string, excludeArticleId?: string) {
let candidate = base;
let n = 1;
while (true) {
const found = await this.prisma.article.findUnique({ where: { slug: candidate } });
if (!found || found.id === excludeArticleId) return candidate;
n += 1;
candidate = `${base}-${n}`;
}
}
}
@@ -0,0 +1,14 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
export class AttachImageDto {
@IsUUID()
imageId: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10_000)
sortOrder?: number;
}
@@ -0,0 +1,53 @@
import { ArticleStatus } from '@prisma/client';
import {
ArrayUnique,
IsArray,
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateArticleDto {
@IsString()
@MinLength(1)
@MaxLength(200)
title: string;
@IsOptional()
@IsString()
@MaxLength(220)
slug?: string;
@IsString()
@MinLength(1)
content: string;
@IsOptional()
@IsString()
@MaxLength(500)
excerpt?: string;
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
categoryIds?: string[];
@IsOptional()
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
tagIds?: string[];
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
imageIds?: string[];
}
@@ -0,0 +1,36 @@
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { ArticleStatus } from '@prisma/client';
export class ListArticlesQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
@IsOptional()
@IsUUID()
tagId?: string;
}
export class ManageArticlesQueryDto extends ListArticlesQueryDto {
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
}
@@ -0,0 +1,55 @@
import { ArticleStatus } from '@prisma/client';
import {
ArrayUnique,
IsArray,
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
export class UpdateArticleDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
@MaxLength(220)
slug?: string;
@IsOptional()
@IsString()
@MinLength(1)
content?: string;
@IsOptional()
@IsString()
@MaxLength(500)
excerpt?: string;
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
categoryIds?: string[];
@IsOptional()
@IsArray()
@ArrayUnique()
@IsUUID('4', { each: true })
tagIds?: string[];
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
imageIds?: string[];
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { KeycloakStrategy } from "./keycloak.strategy";
@Module({
imports: [PassportModule],
providers: [KeycloakStrategy], // 👈 THIS IS THE FIX
exports: [PassportModule],
providers: [KeycloakStrategy],
exports: [PassportModule, KeycloakStrategy],
})
export class AuthModule {}
@@ -0,0 +1,44 @@
import {
Controller,
DefaultValuePipe,
Delete,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { CurrentDbUser } from '../../shared/decorators/current-db-user.decorator';
import { User } from '@prisma/client';
import { UserProvisioningGuard } from '../users/user-provisioning.guard';
import { BookmarksService } from './bookmarks.service';
@Controller('bookmarks')
export class BookmarksController {
constructor(private readonly bookmarksService: BookmarksService) {}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard)
@Get('me')
listMine(
@CurrentDbUser() user: User,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
) {
return this.bookmarksService.listMine(user, page, limit);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard)
@Post(':articleId')
add(@CurrentDbUser() user: User, @Param('articleId', ParseUUIDPipe) articleId: string) {
return this.bookmarksService.add(user, articleId);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard)
@Delete(':articleId')
remove(@CurrentDbUser() user: User, @Param('articleId', ParseUUIDPipe) articleId: string) {
return this.bookmarksService.remove(user, articleId);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { BookmarksController } from './bookmarks.controller';
import { BookmarksService } from './bookmarks.service';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'keycloak' }),
AuthModule,
UsersModule,
],
controllers: [BookmarksController],
providers: [BookmarksService],
})
export class BookmarksModule {}
+58
View File
@@ -0,0 +1,58 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ArticleStatus, User } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
const bookmarkArticleInclude = {
author: {
select: { id: true, displayName: true, avatarKey: true },
},
categories: { include: { category: true } },
tags: { include: { tag: true } },
} as const;
@Injectable()
export class BookmarksService {
constructor(private readonly prisma: PrismaService) {}
async listMine(user: User, page = 1, limit = 20) {
const take = Math.min(Math.max(limit, 1), 100);
const skip = (Math.max(page, 1) - 1) * take;
const where = { userId: user.id };
const [rows, total] = await Promise.all([
this.prisma.bookmark.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
include: { article: { include: bookmarkArticleInclude } },
}),
this.prisma.bookmark.count({ where }),
]);
return { items: rows, total, page, limit: take };
}
async add(user: User, articleId: string) {
const article = await this.prisma.article.findFirst({
where: { id: articleId, status: ArticleStatus.PUBLISHED },
});
if (!article) throw new NotFoundException('Article not found');
return this.prisma.bookmark.upsert({
where: {
userId_articleId: { userId: user.id, articleId },
},
create: { userId: user.id, articleId },
update: {},
include: { article: { include: bookmarkArticleInclude } },
});
}
async remove(user: User, articleId: string) {
const res = await this.prisma.bookmark.deleteMany({
where: { userId: user.id, articleId },
});
if (res.count === 0) {
throw new NotFoundException('Bookmark not found');
}
return { deleted: true };
}
}
@@ -0,0 +1,55 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UserRole } from '@prisma/client';
import { Roles } from '../../shared/decorators/roles.decorator';
import { RolesGuard } from '../../shared/guards/roles.guard';
import { UserProvisioningGuard } from '../users/user-provisioning.guard';
import { CategoriesService } from './categories.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Get()
tree() {
return this.categoriesService.tree();
}
@Get('flat')
flat() {
return this.categoriesService.findAllFlat();
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR)
@Post()
create(@Body() dto: CreateCategoryDto) {
return this.categoriesService.create(dto);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR)
@Patch(':id')
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCategoryDto) {
return this.categoriesService.update(id, dto);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR)
@Delete(':id')
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.categoriesService.remove(id);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'keycloak' }),
AuthModule,
UsersModule,
],
controllers: [CategoriesController],
providers: [CategoriesService],
})
export class CategoriesModule {}
+109
View File
@@ -0,0 +1,109 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { slugify } from '../../shared/utils/slug';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
async tree() {
const all = await this.prisma.category.findMany({
orderBy: [{ parentId: 'asc' }, { name: 'asc' }],
});
const byParent = new Map<string | null, typeof all>();
for (const c of all) {
const key = c.parentId;
if (!byParent.has(key)) byParent.set(key, []);
byParent.get(key)!.push(c);
}
const build = (parentId: string | null): unknown[] => {
const nodes = byParent.get(parentId) ?? [];
return nodes.map((n) => ({
...n,
children: build(n.id),
}));
};
return build(null);
}
async findAllFlat() {
return this.prisma.category.findMany({ orderBy: { name: 'asc' } });
}
async create(dto: CreateCategoryDto) {
const slug = dto.slug?.trim() || slugify(dto.name);
if (dto.parentId) {
const parent = await this.prisma.category.findUnique({
where: { id: dto.parentId },
});
if (!parent) throw new NotFoundException('Parent category not found');
}
return this.prisma.category.create({
data: {
name: dto.name,
slug,
parentId: dto.parentId ?? null,
},
});
}
async update(id: string, dto: UpdateCategoryDto) {
await this.ensureExists(id);
if (dto.parentId === id) {
throw new BadRequestException('Category cannot be its own parent');
}
if (dto.parentId) {
const parent = await this.prisma.category.findUnique({
where: { id: dto.parentId },
});
if (!parent) throw new NotFoundException('Parent category not found');
if (await this.isDescendant(dto.parentId, id)) {
throw new BadRequestException('Cannot set parent to a descendant');
}
}
return this.prisma.category.update({
where: { id },
data: {
name: dto.name,
slug: dto.slug?.trim() ?? undefined,
parentId: dto.parentId === undefined ? undefined : dto.parentId,
},
});
}
private async isDescendant(ancestorId: string, nodeId: string): Promise<boolean> {
let current = await this.prisma.category.findUnique({
where: { id: nodeId },
select: { parentId: true },
});
const visited = new Set<string>();
while (current?.parentId) {
if (current.parentId === ancestorId) return true;
if (visited.has(current.parentId)) break;
visited.add(current.parentId);
current = await this.prisma.category.findUnique({
where: { id: current.parentId },
select: { parentId: true },
});
}
return false;
}
async remove(id: string) {
await this.ensureExists(id);
const children = await this.prisma.category.count({ where: { parentId: id } });
if (children > 0) {
throw new BadRequestException('Remove or reassign child categories first');
}
await this.prisma.category.delete({ where: { id } });
return { deleted: true };
}
private async ensureExists(id: string) {
const row = await this.prisma.category.findUnique({ where: { id } });
if (!row) throw new NotFoundException('Category not found');
return row;
}
}
@@ -0,0 +1,17 @@
import { IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class CreateCategoryDto {
@IsString()
@MinLength(1)
@MaxLength(120)
name: string;
@IsOptional()
@IsString()
@MaxLength(140)
slug?: string;
@IsOptional()
@IsUUID()
parentId?: string | null;
}
@@ -0,0 +1,18 @@
import { IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class UpdateCategoryDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(140)
slug?: string;
@IsOptional()
@IsUUID()
parentId?: string | null;
}
@@ -0,0 +1,42 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { CurrentDbUser } from '../../shared/decorators/current-db-user.decorator';
import { User } from '@prisma/client';
import { UserProvisioningGuard } from '../users/user-provisioning.guard';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto/create-comment.dto';
@Controller('comments')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Get('article/:articleId')
listForArticle(@Param('articleId', ParseUUIDPipe) articleId: string) {
return this.commentsService.listForArticle(articleId);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard)
@Post('article/:articleId')
create(
@Param('articleId', ParseUUIDPipe) articleId: string,
@CurrentDbUser() user: User,
@Body() dto: CreateCommentDto,
) {
return this.commentsService.create(articleId, user, dto);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard)
@Delete(':id')
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentDbUser() user: User) {
return this.commentsService.remove(id, user);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { CommentsController } from './comments.controller';
import { CommentsService } from './comments.service';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'keycloak' }),
AuthModule,
UsersModule,
],
controllers: [CommentsController],
providers: [CommentsService],
})
export class CommentsModule {}
+80
View File
@@ -0,0 +1,80 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ArticleStatus, User, UserRole } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { CreateCommentDto } from './dto/create-comment.dto';
@Injectable()
export class CommentsService {
constructor(private readonly prisma: PrismaService) {}
async listForArticle(articleId: string) {
await this.ensurePublishedArticle(articleId);
const rows = await this.prisma.comment.findMany({
where: { articleId },
include: {
user: { select: { id: true, displayName: true, avatarKey: true } },
},
orderBy: { createdAt: 'asc' },
});
const byParent = new Map<string | null, typeof rows>();
for (const row of rows) {
const key = row.parentId;
if (!byParent.has(key)) byParent.set(key, []);
byParent.get(key)!.push(row);
}
const build = (parentId: string | null): unknown[] =>
(byParent.get(parentId) ?? []).map((c) => ({
...c,
replies: build(c.id),
}));
return build(null);
}
async create(articleId: string, user: User, dto: CreateCommentDto) {
await this.ensurePublishedArticle(articleId);
if (dto.parentId) {
const parent = await this.prisma.comment.findUnique({
where: { id: dto.parentId },
});
if (!parent || parent.articleId !== articleId) {
throw new BadRequestException('Invalid parent comment');
}
}
return this.prisma.comment.create({
data: {
content: dto.content,
articleId,
userId: user.id,
parentId: dto.parentId ?? null,
},
include: {
user: { select: { id: true, displayName: true, avatarKey: true } },
},
});
}
async remove(commentId: string, user: User) {
const comment = await this.prisma.comment.findUnique({ where: { id: commentId } });
if (!comment) throw new NotFoundException('Comment not found');
const isOwner = comment.userId === user.id;
const isStaff = user.role === UserRole.ADMIN || user.role === UserRole.EDITOR;
if (!isOwner && !isStaff) {
throw new ForbiddenException('Cannot delete this comment');
}
await this.prisma.comment.delete({ where: { id: commentId } });
return { deleted: true };
}
private async ensurePublishedArticle(articleId: string) {
const article = await this.prisma.article.findUnique({ where: { id: articleId } });
if (!article || article.status !== ArticleStatus.PUBLISHED) {
throw new NotFoundException('Article not found');
}
return article;
}
}
@@ -0,0 +1,12 @@
import { IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
@MinLength(1)
@MaxLength(10_000)
content: string;
@IsOptional()
@IsUUID()
parentId?: string;
}
+27
View File
@@ -0,0 +1,27 @@
import {
Controller,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { AuthGuard } from '@nestjs/passport';
import { UserRole } from '@prisma/client';
import { Roles } from '../../shared/decorators/roles.decorator';
import { RolesGuard } from '../../shared/guards/roles.guard';
import { UserProvisioningGuard } from '../users/user-provisioning.guard';
import { ImagesService } from './images.service';
@Controller('images')
export class ImagesController {
constructor(private readonly imagesService: ImagesService) {}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR, UserRole.AUTHOR)
@Post('upload')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 15 * 1024 * 1024 } }))
upload(@UploadedFile() file: Express.Multer.File) {
return this.imagesService.uploadOriginal(file);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { ImagesController } from './images.controller';
import { ImagesService } from './images.service';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'keycloak' }),
AuthModule,
UsersModule,
],
controllers: [ImagesController],
providers: [ImagesService],
})
export class ImagesModule {}
+32
View File
@@ -0,0 +1,32 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { MinioService } from '../../infrastructure/storage/minio.service';
import { ThumborUrlService } from '../../infrastructure/storage/thumbor-url.service';
@Injectable()
export class ImagesService {
constructor(
private readonly prisma: PrismaService,
private readonly minio: MinioService,
private readonly thumbor: ThumborUrlService,
) {}
async uploadOriginal(file: Express.Multer.File | undefined) {
if (!file?.buffer?.length) {
throw new BadRequestException('File is required');
}
if (!this.minio.isConfigured()) {
throw new BadRequestException(
'MinIO is not configured. Set MINIO_ACCESS_KEY and MINIO_SECRET_KEY.',
);
}
const contentType = file.mimetype || 'application/octet-stream';
const key = this.minio.buildOriginalsKey('originals/articles', file.originalname);
await this.minio.putObject(key, file.buffer, contentType);
const image = await this.prisma.image.create({
data: { fileKey: key },
});
const preview = this.thumbor.imageUrl(key, { width: 800, height: 0, smart: true });
return { ...image, urls: preview };
}
}
+24 -13
View File
@@ -1,24 +1,35 @@
import { KeycloakAuthGuard } from '../auth/keycloak.guard';
import { ProfileService } from './profile.service';
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { User } from '@prisma/client';
import { Request } from 'express';
import { UserProvisioningGuard } from '../users/user-provisioning.guard';
type ProfileRequest = Request & {
user: {
email?: string;
name?: string;
picture?: string;
email_verified?: boolean;
roles: string[];
};
dbUser?: User;
};
@Controller('profile')
export class ProfileController {
constructor(private readonly profileService: ProfileService) {}
@UseGuards(AuthGuard('keycloak'))
@UseGuards(KeycloakAuthGuard, UserProvisioningGuard)
@Get()
getProfile(@Request() req) {
// The 'user' object here is exactly what you returned from validate()
getProfile(@Request() req: ProfileRequest) {
const kc = req.user;
return {
email: req.user.email,
name: req.user.name,
picture: req.user.picture,
email_verified: req.user.email_verified,
roles: req.user.roles,
keycloak: {
email: kc.email,
name: kc.name,
picture: kc.picture,
email_verified: kc.email_verified,
roles: kc.roles,
},
user: req.dbUser,
};
}
}
+5 -4
View File
@@ -1,16 +1,17 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { ProfileController } from './profile.controller';
import { ProfileService } from './profile.service';
import { KeycloakStrategy } from '../auth/keycloak.strategy';
@Module({
imports: [
// Registers the 'keycloak' strategy as a default if needed
PassportModule.register({ defaultStrategy: 'keycloak' }),
AuthModule,
UsersModule,
],
controllers: [ProfileController],
providers: [ProfileService, KeycloakStrategy],
exports: [],
providers: [ProfileService],
})
export class ProfileModule {}
+13
View File
@@ -0,0 +1,13 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateTagDto {
@IsString()
@MinLength(1)
@MaxLength(80)
name: string;
@IsOptional()
@IsString()
@MaxLength(100)
slug?: string;
}
+14
View File
@@ -0,0 +1,14 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateTagDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
name?: string;
@IsOptional()
@IsString()
@MaxLength(100)
slug?: string;
}
+50
View File
@@ -0,0 +1,50 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UserRole } from '@prisma/client';
import { Roles } from '../../shared/decorators/roles.decorator';
import { RolesGuard } from '../../shared/guards/roles.guard';
import { UserProvisioningGuard } from '../users/user-provisioning.guard';
import { CreateTagDto } from './dto/create-tag.dto';
import { UpdateTagDto } from './dto/update-tag.dto';
import { TagsService } from './tags.service';
@Controller('tags')
export class TagsController {
constructor(private readonly tagsService: TagsService) {}
@Get()
findAll() {
return this.tagsService.findAll();
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR)
@Post()
create(@Body() dto: CreateTagDto) {
return this.tagsService.create(dto);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR)
@Patch(':id')
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTagDto) {
return this.tagsService.update(id, dto);
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.EDITOR)
@Delete(':id')
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.tagsService.remove(id);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { TagsController } from './tags.controller';
import { TagsService } from './tags.service';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'keycloak' }),
AuthModule,
UsersModule,
],
controllers: [TagsController],
providers: [TagsService],
})
export class TagsModule {}
+44
View File
@@ -0,0 +1,44 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { slugify } from '../../shared/utils/slug';
import { CreateTagDto } from './dto/create-tag.dto';
import { UpdateTagDto } from './dto/update-tag.dto';
@Injectable()
export class TagsService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.tag.findMany({ orderBy: { name: 'asc' } });
}
async create(dto: CreateTagDto) {
const slug = dto.slug?.trim() || slugify(dto.name);
return this.prisma.tag.create({
data: { name: dto.name, slug },
});
}
async update(id: string, dto: UpdateTagDto) {
await this.ensureExists(id);
return this.prisma.tag.update({
where: { id },
data: {
name: dto.name,
slug: dto.slug?.trim(),
},
});
}
async remove(id: string) {
await this.ensureExists(id);
await this.prisma.tag.delete({ where: { id } });
return { deleted: true };
}
private async ensureExists(id: string) {
const row = await this.prisma.tag.findUnique({ where: { id } });
if (!row) throw new NotFoundException('Tag not found');
return row;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateMeDto {
@IsOptional()
@IsString()
@MaxLength(120)
displayName?: string;
@IsOptional()
@IsString()
@MaxLength(512)
avatarKey?: string;
}
@@ -0,0 +1,23 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { KeycloakRequestUser } from '../../shared/decorators/current-user.decorator';
import { UsersService } from './users.service';
@Injectable()
export class UserProvisioningGuard implements CanActivate {
constructor(private readonly usersService: UsersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const keycloakUser = req.user as KeycloakRequestUser | undefined;
if (!keycloakUser?.userId) {
throw new UnauthorizedException();
}
req.dbUser = await this.usersService.ensureUser(keycloakUser);
return true;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { CurrentDbUser } from '../../shared/decorators/current-db-user.decorator';
import { User } from '@prisma/client';
import { UpdateMeDto } from './dto/update-me.dto';
import { UsersService } from './users.service';
import { UserProvisioningGuard } from './user-provisioning.guard';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard)
@Get('me')
me(@CurrentDbUser() user: User) {
return user;
}
@UseGuards(AuthGuard('keycloak'), UserProvisioningGuard)
@Patch('me')
updateMe(@CurrentDbUser() user: User, @Body() dto: UpdateMeDto) {
return this.usersService.updateProfile(user.id, dto);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthModule } from '../auth/auth.module';
import { UserProvisioningGuard } from './user-provisioning.guard';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'keycloak' }),
AuthModule,
],
controllers: [UsersController],
providers: [UsersService, UserProvisioningGuard],
exports: [UsersService, UserProvisioningGuard],
})
export class UsersModule {}
+64
View File
@@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { Prisma, User, UserRole } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { KeycloakRequestUser } from '../../shared/decorators/current-user.decorator';
function mapKeycloakRolesToUserRole(realmRoles: string[]): UserRole {
const lower = realmRoles.map((r) => r.toLowerCase());
if (lower.includes('admin')) return UserRole.ADMIN;
if (lower.includes('editor')) return UserRole.EDITOR;
if (lower.includes('author')) return UserRole.AUTHOR;
return UserRole.READER;
}
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async ensureUser(kc: KeycloakRequestUser): Promise<User> {
const email = kc.email ?? `${kc.userId}@placeholder.local`;
const role = mapKeycloakRolesToUserRole(kc.roles ?? []);
const existing = await this.prisma.user.findUnique({
where: { keycloakId: kc.userId },
});
if (existing) {
const data: Prisma.UserUpdateInput = {};
if (kc.email && kc.email !== existing.email) data.email = kc.email;
if (kc.name && kc.name !== existing.displayName) {
data.displayName = kc.name;
}
if (Object.keys(data).length === 0) {
return existing;
}
return this.prisma.user.update({ where: { id: existing.id }, data });
}
return this.prisma.user.create({
data: {
keycloakId: kc.userId,
email,
displayName: kc.name ?? null,
role,
},
});
}
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
async updateProfile(userId: string, dto: { displayName?: string; avatarKey?: string }) {
return this.prisma.user.update({
where: { id: userId },
data: {
displayName: dto.displayName,
avatarKey: dto.avatarKey,
},
});
}
async setRole(userId: string, role: UserRole) {
return this.prisma.user.update({ where: { id: userId }, data: { role } });
}
}
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from '@prisma/client';
export const CurrentDbUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): User => {
const req = ctx.switchToHttp().getRequest();
return req.dbUser as User;
},
);
@@ -0,0 +1,18 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export type KeycloakRequestUser = {
userId: string;
email?: string;
name?: string;
email_verified?: boolean;
picture?: string;
roles: string[];
raw?: unknown;
};
export const CurrentKeycloakUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): KeycloakRequestUser => {
const req = ctx.switchToHttp().getRequest();
return req.user as KeycloakRequestUser;
},
);
+6
View File
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+17
View File
@@ -0,0 +1,17 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class PaginationQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
+30
View File
@@ -0,0 +1,30 @@
import {
CanActivate,
ExecutionContext,
Injectable,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) {
return true;
}
const req = context.switchToHttp().getRequest();
const role = req.dbUser?.role as UserRole | undefined;
if (!role || !required.includes(role)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+30
View File
@@ -0,0 +1,30 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
private readonly pool: Pool;
constructor(private readonly config: ConfigService) {
const connectionString = config.getOrThrow<string>('databaseUrl');
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
super({ adapter });
this.pool = pool;
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
await this.pool.end();
}
}
+10
View File
@@ -0,0 +1,10 @@
export function slugify(input: string): string {
return input
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 120) || 'item';
}
+11
View File
@@ -0,0 +1,11 @@
import { User } from '@prisma/client';
declare global {
namespace Express {
interface Request {
dbUser?: User;
}
}
}
export {};