mirror of
https://github.com/PeterMaquiran/tvone-api.git
synced 2026-04-18 16:27:51 +00:00
add moodules
This commit is contained in:
@@ -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 } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user