mirror of
https://github.com/PeterMaquiran/tvone-api.git
synced 2026-04-18 08:17:52 +00:00
add moodules
This commit is contained in:
+15
-1
@@ -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",
|
||||
|
||||
Generated
+1167
-49
File diff suppressed because it is too large
Load Diff
@@ -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'),
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
export class ProfileModule {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
dbUser?: User;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user