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,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"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\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
@@ -21,12 +24,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@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",
|
"jwks-rsa": "^4.0.1",
|
||||||
|
"minio": "^8.0.6",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -38,14 +48,18 @@
|
|||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/pg": "^8.15.5",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^7.7.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"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 { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
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 { 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 { 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({
|
@Module({
|
||||||
imports: [AuthModule, ProfileModule],
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
|
||||||
|
PrismaModule,
|
||||||
|
StorageModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
ProfileModule,
|
||||||
|
CategoriesModule,
|
||||||
|
TagsModule,
|
||||||
|
ArticlesModule,
|
||||||
|
ImagesModule,
|
||||||
|
CommentsModule,
|
||||||
|
BookmarksModule,
|
||||||
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
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 { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: ["http://localhost:3000"], // 👈 array is safer
|
origin: ['http://localhost:3000'],
|
||||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
allowedHeaders: ["Content-Type", "Authorization"],
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
await app.listen(process.env.PORT ?? 3001);
|
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({
|
@Module({
|
||||||
imports: [PassportModule],
|
imports: [PassportModule],
|
||||||
providers: [KeycloakStrategy], // 👈 THIS IS THE FIX
|
providers: [KeycloakStrategy],
|
||||||
exports: [PassportModule],
|
exports: [PassportModule, KeycloakStrategy],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
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 { KeycloakAuthGuard } from '../auth/keycloak.guard';
|
||||||
import { ProfileService } from './profile.service';
|
|
||||||
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
|
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')
|
@Controller('profile')
|
||||||
export class ProfileController {
|
export class ProfileController {
|
||||||
constructor(private readonly profileService: ProfileService) {}
|
@UseGuards(KeycloakAuthGuard, UserProvisioningGuard)
|
||||||
|
|
||||||
@UseGuards(AuthGuard('keycloak'))
|
|
||||||
@Get()
|
@Get()
|
||||||
getProfile(@Request() req) {
|
getProfile(@Request() req: ProfileRequest) {
|
||||||
// The 'user' object here is exactly what you returned from validate()
|
const kc = req.user;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: req.user.email,
|
keycloak: {
|
||||||
name: req.user.name,
|
email: kc.email,
|
||||||
picture: req.user.picture,
|
name: kc.name,
|
||||||
email_verified: req.user.email_verified,
|
picture: kc.picture,
|
||||||
roles: req.user.roles,
|
email_verified: kc.email_verified,
|
||||||
|
roles: kc.roles,
|
||||||
|
},
|
||||||
|
user: req.dbUser,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
import { ProfileController } from './profile.controller';
|
import { ProfileController } from './profile.controller';
|
||||||
import { ProfileService } from './profile.service';
|
import { ProfileService } from './profile.service';
|
||||||
import { KeycloakStrategy } from '../auth/keycloak.strategy';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
// Registers the 'keycloak' strategy as a default if needed
|
|
||||||
PassportModule.register({ defaultStrategy: 'keycloak' }),
|
PassportModule.register({ defaultStrategy: 'keycloak' }),
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [ProfileController],
|
controllers: [ProfileController],
|
||||||
providers: [ProfileService, KeycloakStrategy],
|
providers: [ProfileService],
|
||||||
exports: [],
|
|
||||||
})
|
})
|
||||||
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