From 35b470c498e7f0f497efbc46823f15fd54b081b8 Mon Sep 17 00:00:00 2001 From: anibilag Date: Wed, 21 May 2025 23:00:36 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=B5=D0=B9,=20=D1=81=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20250513200815_add_authors/migration.sql | 16 ++ .../migration.sql | 8 + .../migration.sql | 5 + .../migration.sql | 14 + .../migration.sql | 2 + prisma/schema.prisma | 40 ++- src/routes/articles/controllers/crud.ts | 32 ++- src/routes/authors/controllers/authors.ts | 267 +++++++++++++++++- src/routes/authors/index.ts | 21 +- src/routes/other/controllers/other.ts | 4 +- src/services/authorService.ts | 72 +++++ src/services/userService.ts | 40 +-- src/types/auth.ts | 9 +- src/types/index.ts | 9 +- 14 files changed, 481 insertions(+), 58 deletions(-) create mode 100644 prisma/migrations/20250513200815_add_authors/migration.sql create mode 100644 prisma/migrations/20250514182724_authors_avatar/migration.sql create mode 100644 prisma/migrations/20250515144306_user_to_author/migration.sql create mode 100644 prisma/migrations/20250515152556_user_link_author/migration.sql create mode 100644 prisma/migrations/20250519145250_is_active_author/migration.sql create mode 100644 src/services/authorService.ts diff --git a/prisma/migrations/20250513200815_add_authors/migration.sql b/prisma/migrations/20250513200815_add_authors/migration.sql new file mode 100644 index 0000000..928f019 --- /dev/null +++ b/prisma/migrations/20250513200815_add_authors/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Author" ( + "id" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "bio" TEXT, + "avatarUrl" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + "okUrl" TEXT, + "vkUrl" TEXT, + "websiteUrl" TEXT, + "email" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Author_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20250514182724_authors_avatar/migration.sql b/prisma/migrations/20250514182724_authors_avatar/migration.sql new file mode 100644 index 0000000..47cb302 --- /dev/null +++ b/prisma/migrations/20250514182724_authors_avatar/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `avatarUrl` on table `Author` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Author" ALTER COLUMN "avatarUrl" SET NOT NULL; diff --git a/prisma/migrations/20250515144306_user_to_author/migration.sql b/prisma/migrations/20250515144306_user_to_author/migration.sql new file mode 100644 index 0000000..f25f3c0 --- /dev/null +++ b/prisma/migrations/20250515144306_user_to_author/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "Article" DROP CONSTRAINT "Article_authorId_fkey"; + +-- AddForeignKey +ALTER TABLE "Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250515152556_user_link_author/migration.sql b/prisma/migrations/20250515152556_user_link_author/migration.sql new file mode 100644 index 0000000..737ac63 --- /dev/null +++ b/prisma/migrations/20250515152556_user_link_author/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId]` on the table `Author` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Author" ADD COLUMN "userId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Author_userId_key" ON "Author"("userId"); + +-- AddForeignKey +ALTER TABLE "Author" ADD CONSTRAINT "Author_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20250519145250_is_active_author/migration.sql b/prisma/migrations/20250519145250_is_active_author/migration.sql new file mode 100644 index 0000000..fcc8be9 --- /dev/null +++ b/prisma/migrations/20250519145250_is_active_author/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Author" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ed49c61..bb0e369 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,18 +8,44 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique + id String @id @default(uuid()) + email String @unique password String displayName String avatarUrl String bio String? - isAdmin Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + isAdmin Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt permissions Json + order Int @default(0) + Author Author? +} + +enum AuthorRole { + WRITER + PHOTOGRAPHER + EDITOR + TRANSLATOR +} + +model Author { + id String @id @default(uuid()) + displayName String + bio String? + avatarUrl String + order Int @default(0) + okUrl String? + vkUrl String? + websiteUrl String? + email String? + userId String? @unique + user User? @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt articles Article[] - order Int @default(0) + isActive Boolean @default(true) + roles AuthorRole[] @default([]) } model Article { @@ -35,7 +61,7 @@ model Article { likes Int @default(0) dislikes Int @default(0) publishedAt DateTime @default(now()) - author User @relation(fields: [authorId], references: [id]) + author Author @relation(fields: [authorId], references: [id]) authorId String gallery GalleryImage[] isActive Boolean @default(false) diff --git a/src/routes/articles/controllers/crud.ts b/src/routes/articles/controllers/crud.ts index 0a6b8a3..a98e224 100644 --- a/src/routes/articles/controllers/crud.ts +++ b/src/routes/articles/controllers/crud.ts @@ -76,6 +76,25 @@ export async function createArticle(req: AuthRequest, res: Response) : Promise [a.displayName, a.id])); + try { for (const article of articles) { try { // Шаг 1: Создание статьи + const authorId = authorMap.get(article.authorName) || article.author.id; + const newArticle = await prisma.article.create({ data: { importId: article.importId, @@ -314,7 +342,7 @@ export async function importArticles(req: AuthRequest, res: Response) : Promise< likes: article.likes || 0, dislikes: article.dislikes || 0, author: { - connect: { id: article.author.id }, + connect: { id: authorId }, }, }, }); diff --git a/src/routes/authors/controllers/authors.ts b/src/routes/authors/controllers/authors.ts index 7d46e38..ddb06fc 100644 --- a/src/routes/authors/controllers/authors.ts +++ b/src/routes/authors/controllers/authors.ts @@ -1,14 +1,275 @@ import { Response } from 'express'; import { AuthRequest } from '../../../middleware/auth'; -import { userService } from '../../../services/userService'; +import { authorService } from '../../../services/authorService'; +import { logger } from "../../../config/logger"; +import { prisma } from "../../../lib/prisma"; -// Список авторов - без permissions и без чистых админов export async function getAuthors(req: AuthRequest, res: Response): Promise { try { - const authors = await userService.getAuthors(); + const authors = await authorService.getAuthors(); res.json(authors); } catch { res.status(500).json({ error: 'Server error' }); } } + +export async function createAuthor(req: AuthRequest, res: Response): Promise { + try { + if (!req.user?.permissions.isAdmin) { + logger.warn(`Не администратор ${req.user?.id} пытается создать автора`); + res.status(403).json({ error: 'Требуются права администратора' }); + return + } + + const { email, displayName, bio, avatarUrl, order } = req.body; + + const lastAuthor = await prisma.author.findFirst({ + orderBy: { + order: 'desc', + }, + }); + + const nextOrder = lastAuthor ? lastAuthor.order + 1 : 1; + + const author = await prisma.author.create({ + data: { + email, + displayName, + bio, + avatarUrl, + order: nextOrder + }, + select: { + id: true, + email: true, + displayName: true, + bio: true, + avatarUrl: true, + order: true, + } + }); + + logger.info(`Успешное создание автора: ${author.id}`); + res.status(201).json(author); + } catch (error) { + logger.error('Ошибка создания автора:', error); + res.status(500).json({ error: 'Ошибка создания автора' }); + } +} + +export async function updateAuthor(req: AuthRequest, res: Response): Promise { + try { + if (!req.user?.permissions.isAdmin) { + logger.warn(`Не администратор ${req.user?.id} пытается обновить автора`); + res.status(403).json({ error: 'Требуются права администратора' }); + return + } + + const { id } = req.params; + const { email, displayName, bio, avatarUrl, order, okUrl, vkUrl, websiteUrl } = req.body; + + // Подготовка данных для изменения + const updateData: any = { + email, + displayName, + bio, + avatarUrl, + order, + okUrl, + vkUrl, + websiteUrl, + }; + + // Обновление данных автора + const author = await prisma.author.update({ + where: { id }, + data: updateData, + select: { + id: true, + email: true, + displayName: true, + bio: true, + avatarUrl: true, + order: true, + okUrl: true, + vkUrl: true, + websiteUrl: true, + } + }); + + logger.info(`Успешное обновление автора: ${author.id}`); + res.json(author); + } catch (error) { + logger.error('Ошибка обновления автора:', error); + res.status(500).json({ error: 'Ошибка обновления автора' }); + } +} + +export async function deleteAuthor(req: AuthRequest, res: Response): Promise { + try { + if (!req.user?.permissions.isAdmin) { + logger.warn(`Не администратор ${req.user?.id} пытается удалить пользователя`); + res.status(403).json({ error: 'Требуются права администратора' }); + return + } + + const { id } = req.params; + + const existingUser = await prisma.author.findUnique({ + where: { id } + }); + + if (!existingUser) { + res.status(404).json({ error: 'Автор не найден' }); + return + } + + await prisma.author.delete({ + where: { id } + }); + + logger.info(`Успешное удаление автора: ${id}`); + res.json({ message: 'Успешное удаление автора' }); + } catch (error) { + logger.error('Ошибка удаления автора:', error); + res.status(500).json({ error: 'Ошибка удаления автора' }); + } +} + +export async function linkAuthorToUser(req: AuthRequest, res: Response): Promise { + try { + if (!req.user?.permissions.isAdmin) { + logger.warn(`Не администратор ${req.user?.id} пытается обновить автора`); + res.status(403).json({ error: 'Требуются права администратора' }); + return + } + + const { id } = req.params; + const { userId } = req.body; + + const author = await prisma.author.update({ + where: { id }, + data: { userId }, + select: { + id: true, + userId: true, + } + }); + + logger.info(`Успешное связывание автора: ${author.id} с пользователем ${author.userId}`); + res.json(author); + } catch (error) { + logger.error('Ошибка связывания автора:', error); + res.status(500).json({ error: 'Ошибка связывания автора' }); + } +} + +export async function unlinkAuthorFromUser(req: AuthRequest, res: Response): Promise { + try { + if (!req.user?.permissions.isAdmin) { + logger.warn(`Не администратор ${req.user?.id} пытается обновить автора`); + res.status(403).json({ error: 'Требуются права администратора' }); + return + } + + const { id } = req.params; + + const author = await prisma.author.update({ + where: { id }, + data: { userId: null }, + select: { + id: true, + userId: true, + } + }); + + logger.info(`Успешное отвязывание автора: ${author.id} от пользователя ${author.userId}`); + res.json(author); + } catch (error) { + logger.error('Ошибка отвязывания автора:', error); + res.status(500).json({ error: 'Ошибка отвязывания автора' }); + } +} + +export async function toggleActiveAuthor(req: AuthRequest, res: Response) : Promise { + try { + const { isActive } = req.body; + + if (!req.user) { + res.status(401).json({ error: 'Пользователь не вошел в систему' }); + return + } + + const author = await prisma.author.findUnique({ + where: { id: req.params.id } + }); + + if (!author) { + res.status(404).json({ error: 'Автор не найден' }); + return + } + + const updatedAuthor = await prisma.author.update({ + where: { id: req.params.id }, + data: { isActive: isActive }, + }); + + res.json(updatedAuthor); + } catch (error) { + logger.error('Ошибка активирования автора:', error); + res.status(500).json({ error: 'Серверная ошибка' }); + } +} + +export async function reorderAuthor(req: AuthRequest, res: Response) : Promise { + try { + if (!req.user) { + res.status(401).json({ error: 'Пользователь не вошел в систему' }); + return + } + + const { id } = req.params; + const { direction } = req.body; // 'up' или 'down' + + if (!['up', 'down'].includes(direction)) { + res.status(400).json({ error: 'Неправильное направление' }); + return + } + + const current = await prisma.author.findUnique({ where: { id } }); + if (!current) { + res.status(404).json({ error: 'Автор не найден' }); + return + } + const neighbor = await prisma.author.findFirst({ + where: { + order: direction === 'up' ? { lt: current.order } : { gt: current.order }, + }, + orderBy: { + order: direction === 'up' ? 'desc' : 'asc', + }, + }); + + if (!neighbor) { + res.status(200).json({ message: 'Уже достигли границы', author: current }); + return + } + + await prisma.$transaction([ + prisma.author.update({ + where: { id: current.id }, + data: { order: neighbor.order }, + }), + prisma.author.update({ + where: { id: neighbor.id }, + data: { order: current.order }, + }), + ]); + + const updated = await prisma.author.findUnique({ where: { id } }); + res.json(updated); + } catch (error) { + console.error('Ошибка при изменении порядка авторов:', error); + res.status(500).json({ error: 'Ошибка при изменении порядка авторов' }); } +} diff --git a/src/routes/authors/index.ts b/src/routes/authors/index.ts index 10e406c..645bc11 100644 --- a/src/routes/authors/index.ts +++ b/src/routes/authors/index.ts @@ -1,9 +1,26 @@ import express from 'express'; -import { getAuthors } from './controllers/authors'; +import { auth } from '../../middleware/auth'; +import { + getAuthors, + createAuthor, + updateAuthor, + deleteAuthor, + linkAuthorToUser, + unlinkAuthorFromUser, + toggleActiveAuthor, + reorderAuthor +} from './controllers/authors'; + const router = express.Router(); - router.get('/', getAuthors); +router.post('/', auth, createAuthor); +router.put('/:id', auth, updateAuthor); +router.delete('/:id', auth, deleteAuthor); +router.put('/:id/link-user', auth, linkAuthorToUser); +router.put('/:id/unlink-user', auth, unlinkAuthorFromUser); +router.put('/:id/toggle-active', auth, toggleActiveAuthor); +router.put('/:id/reorder', auth, reorderAuthor); export default router; \ No newline at end of file diff --git a/src/routes/other/controllers/other.ts b/src/routes/other/controllers/other.ts index fc3a940..de90af4 100644 --- a/src/routes/other/controllers/other.ts +++ b/src/routes/other/controllers/other.ts @@ -1,12 +1,12 @@ import { Response } from 'express'; -import { userService } from '../../../services/userService'; +import { authorService } from '../../../services/authorService'; import { AuthRequest } from "../../../middleware/auth"; // Изменить количество лайков export async function updateLikes(req: AuthRequest, res: Response): Promise { try { - const authors = await userService.getAuthors(); + const authors = await authorService.getAuthors(); res.json(authors); } catch { res.status(500).json({ error: 'Серверная ошибка' }); diff --git a/src/services/authorService.ts b/src/services/authorService.ts new file mode 100644 index 0000000..b3e7bc7 --- /dev/null +++ b/src/services/authorService.ts @@ -0,0 +1,72 @@ +import { PrismaClient } from '@prisma/client'; +import { Author } from '../types/auth'; + +const prisma = new PrismaClient(); + +export const authorService = { + getAuthors: async (): Promise => { + try { + // 1. Получаем авторов + const authors = await prisma.author.findMany({ + select: { + id: true, + displayName: true, + avatarUrl: true, + bio: true, + order: true, + okUrl: true, + vkUrl: true, + websiteUrl: true, + email: true, + userId: true, + isActive: true, + _count: { + select: {articles: true}, // Подсчёт количества статей автора + }, + user: { + select: { + displayName: true, + }, + } + }, + where: {order: {not: 0}}, + orderBy: { + order: 'asc', + }, + }); + + // 2. Считаем сумму лайков по авторам + const likesPerAuthor = await prisma.article.groupBy({ + by: ['authorId'], + _sum: { + likes: true, + }, + }); + + // 3. Строим map для быстрого доступа + const likesMap = new Map( + likesPerAuthor.map((item) => [item.authorId, item._sum.likes ?? 0]) + ); + + return authors.map(author => ({ + id: author.id, + displayName: author.displayName, + avatarUrl: author.avatarUrl, + bio: author.bio, + order: author.order, + okUrl: author.okUrl, + vkUrl: author.vkUrl, + websiteUrl: author.websiteUrl, + email: author.email, + articlesCount: author._count.articles, // Количество статей + userId: author.userId, + userDisplayName: author.user?.displayName || null, + isActive: author.isActive, + totalLikes: likesMap.get(author.id) ?? 0, + })); + } catch (error) { + console.error('Ошибка получения авторов:', error); + throw new Error('Ошибка получения авторов'); + } + } +} diff --git a/src/services/userService.ts b/src/services/userService.ts index 7b3ad77..88f8471 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -14,6 +14,7 @@ export const userService = { avatarUrl: string; bio: string | null; permissions: JsonValue; + Author: { id: string } | null; }> = await prisma.user.findMany({ select: { id: true, @@ -22,6 +23,9 @@ export const userService = { avatarUrl: true, bio: true, permissions: true, + Author: { + select: { id: true } // достаточно только id для проверки связи + } }, }); @@ -32,7 +36,8 @@ export const userService = { return { ...user, permissions: permissions as UserPermissions, - } as User; + isLinkedToAuthor: !!user.Author, + } as User & { isLinkedToAuthor: boolean }; } else { throw new Error(`Invalid permissions format for user ${user.id}`); } @@ -43,39 +48,6 @@ export const userService = { } }, - getAuthors: async (): Promise => { - try { - const authors = await prisma.user.findMany({ - select: { - id: true, - email: true, - displayName: true, - avatarUrl: true, - bio: true, - _count: { - select: { articles: true }, // Подсчёт количества статей автора - }, - }, - where: { order: { not: 0 } }, - orderBy: { - order: 'asc', - }, - }); - - return authors.map(author => ({ - id: author.id, - email: author.email, - displayName: author.displayName, - avatarUrl: author.avatarUrl, - bio: author.bio, - articlesCount: author._count.articles, // Количество статей - })); - } catch (error) { - console.error('Ошибка получения авторов:', error); - throw new Error('Ошибка получения авторов'); - } - }, - updateUserPermissions: async ( userId: string, permissions: User['permissions'] diff --git a/src/types/auth.ts b/src/types/auth.ts index 552f460..ac42d2f 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -19,9 +19,14 @@ export interface User { export interface Author { id: string; - email: string; + email: string | null; displayName: string; avatarUrl: string; - articlesCount: number; bio: string | null; + order: number; + okUrl: string | null; + vkUrl: string | null; + websiteUrl: string | null; + articlesCount: number; + userId?: string | null; } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index e5a6ec6..930b756 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,5 @@ +import { Author } from "./auth"; + export interface Article { id: string; importId: number; @@ -7,6 +9,7 @@ export interface Article { categoryId: number; cityId: number; author: Author; + authorName: string; coverImage: string; images?: string[]; imageSubs: string[]; @@ -26,9 +29,3 @@ export interface GalleryImage { alt: string; } -export interface Author { - id: string; - name: string; - avatar: string; - bio: string; -}