Переработана схема привязки авторов к статьям, авторы трех типов.

This commit is contained in:
anibilag 2025-06-03 22:31:07 +03:00
parent 35b470c498
commit 810599c718
12 changed files with 298 additions and 102 deletions

View File

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "AuthorRole" AS ENUM ('WRITER', 'PHOTOGRAPHER', 'EDITOR', 'TRANSLATOR');
-- AlterTable
ALTER TABLE "Author" ADD COLUMN "roles" "AuthorRole"[] DEFAULT ARRAY[]::"AuthorRole"[];

View File

@ -0,0 +1,17 @@
-- DropForeignKey
ALTER TABLE "Article" DROP CONSTRAINT "Article_authorId_fkey";
-- CreateTable
CREATE TABLE "ArticleAuthor" (
"articleId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"role" "AuthorRole" NOT NULL,
CONSTRAINT "ArticleAuthor_pkey" PRIMARY KEY ("articleId","authorId")
);
-- AddForeignKey
ALTER TABLE "ArticleAuthor" ADD CONSTRAINT "ArticleAuthor_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArticleAuthor" ADD CONSTRAINT "ArticleAuthor_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `authorId` on the `Article` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Article" DROP COLUMN "authorId";

View File

@ -0,0 +1,9 @@
/*
Warnings:
- The primary key for the `ArticleAuthor` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- AlterTable
ALTER TABLE "ArticleAuthor" DROP CONSTRAINT "ArticleAuthor_pkey",
ADD CONSTRAINT "ArticleAuthor_pkey" PRIMARY KEY ("articleId", "authorId", "role");

View File

@ -43,9 +43,9 @@ model Author {
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
articles Article[]
isActive Boolean @default(true)
roles AuthorRole[] @default([])
articles ArticleAuthor[]
}
model Article {
@ -61,10 +61,20 @@ model Article {
likes Int @default(0)
dislikes Int @default(0)
publishedAt DateTime @default(now())
author Author @relation(fields: [authorId], references: [id])
authorId String
gallery GalleryImage[]
isActive Boolean @default(false)
authors ArticleAuthor[]
}
model ArticleAuthor {
articleId String
authorId String
role AuthorRole
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
author Author @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@id([articleId, authorId, role])
}
model GalleryImage {

View File

@ -7,7 +7,9 @@ import { AuthRequest } from '../../../middleware/auth';
import { checkPermission } from '../../../utils/permissions';
import { logger } from '../../../config/logger';
import { Article } from "../../../types";
import { bucketName, uploadBufferToS3 } from "../../../services/s3Service";
import { uploadBufferToS3 } from "../../../services/s3Service";
import { AuthorRole } from "@prisma/client";
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
@ -15,14 +17,19 @@ export async function getArticle(req: Request, res: Response) : Promise<void> {
try {
const article = await prisma.article.findUnique({
where: { id: req.params.id },
include: {
authors: {
include: {
author: {
select: {
id: true,
displayName: true,
avatarUrl: true,
email: true
}
email: true,
order: true,
},
},
},
},
gallery: {
orderBy: {order: "asc"}, // Сортировка изображений по порядку
@ -95,6 +102,7 @@ export async function createArticle(req: AuthRequest, res: Response) : Promise<v
return
}
// создаём статью и добавляем автора с ролью WRITER
const article = await prisma.article.create({
data: {
title,
@ -104,17 +112,29 @@ export async function createArticle(req: AuthRequest, res: Response) : Promise<v
cityId,
coverImage,
readTime,
authorId: author.id
publishedAt: new Date(),
authors: {
create: [
{
author: { connect: { id: author.id } },
role: AuthorRole.WRITER, // 👈 по умолчанию WRITER
},
],
},
},
include: {
authors: {
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
email: true,
},
},
},
},
},
});
logger.info(`Создана статья: ${article.id} пользователем ${req.user.id}`);
@ -127,21 +147,21 @@ export async function createArticle(req: AuthRequest, res: Response) : Promise<v
export async function updateArticle(req: AuthRequest, res: Response) : Promise<void> {
try {
const { title, excerpt, content, categoryId, cityId, coverImage, readTime, author } = req.body;
const { title, excerpt, content, categoryId, cityId, coverImage, readTime, authors } = req.body;
if (!req.user) {
res.status(401).json({ error: 'Пользователь не вошел в систему' });
return
}
const article = await prisma.article.findUnique({
const existingArticle = await prisma.article.findUnique({
where: { id: req.params.id },
select: { authorId: true } // Берём только автора
select: { id: true },
});
if (!article) {
if (!existingArticle) {
res.status(404).json({ error: 'Статья не найдена' });
return
return;
}
if (!checkPermission(req.user, categoryId, 'edit')) {
@ -159,22 +179,34 @@ export async function updateArticle(req: AuthRequest, res: Response) : Promise<v
readTime
};
if (author?.id && author.id !== article.authorId) {
updateData.author = { connect: { id: author.id } };
// Обновляем список авторов, если он передан
if (Array.isArray(authors)) {
updateData.authors = {
// Удалим всех существующих авторов и добавим заново
deleteMany: {},
create: authors.map((a ) => ({
author: { connect: { id: a.author?.id } },
role: a.role as AuthorRole
})),
};
}
const updatedArticle = await prisma.article.update({
where: { id: req.params.id },
data: updateData,
include: {
authors: {
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
}
email: true,
},
},
},
},
},
});
res.json(updatedArticle);
@ -207,14 +239,18 @@ export async function activeArticle(req: AuthRequest, res: Response) : Promise<v
data: {
isActive: isActive
},
include: {
authors: {
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
email: true,
},
},
},
},
}
});
@ -241,14 +277,18 @@ export async function reactArticle(req: AuthRequest, res: Response) : Promise<vo
likes: newLikes,
dislikes: newDisLikes
},
include: {
authors: {
include: {
author: {
select: {
id: true,
displayName: true,
email: true
}
}
email: true,
},
},
},
},
}
});
@ -315,7 +355,7 @@ export async function importArticles(req: AuthRequest, res: Response) : Promise<
let importedCount = 0;
// Получение списка всех авторов для находдения автора по имени
// Получение списка всех авторов для нахождения автора по имени
const allAuthors = await prisma.author.findMany({
select: { id: true, displayName: true },
});
@ -326,7 +366,34 @@ export async function importArticles(req: AuthRequest, res: Response) : Promise<
for (const article of articles) {
try {
// Шаг 1: Создание статьи
const authorId = authorMap.get(article.authorName) || article.author.id;
const authorsToConnect = [];
// Добавление писателя
const writerId = authorMap.get(article.authorName);
if (writerId) {
authorsToConnect.push({
author: { connect: { id: writerId } },
role: AuthorRole.WRITER,
});
}
// Добавление соавтора
const coWriterId = authorMap.get(article.coAuthorName);
if (coWriterId) {
authorsToConnect.push({
author: { connect: { id: coWriterId } },
role: AuthorRole.WRITER,
});
}
// Добавление фотографа
const photographerId = authorMap.get(article.photographerName);
if (photographerId) {
authorsToConnect.push({
author: { connect: { id: photographerId } },
role: AuthorRole.PHOTOGRAPHER,
});
}
const newArticle = await prisma.article.create({
data: {
@ -341,8 +408,8 @@ export async function importArticles(req: AuthRequest, res: Response) : Promise<
publishedAt: new Date(article.publishedAt),
likes: article.likes || 0,
dislikes: article.dislikes || 0,
author: {
connect: { id: authorId },
authors: {
create: authorsToConnect,
},
},
});

View File

@ -33,13 +33,18 @@ export async function listArticles(req: Request, res: Response) {
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
authors: {
include: {
author: {
select: {
id: true,
displayName: true,
avatarUrl: true,
email: true
email: true,
order: true,
},
},
},
},
},

View File

@ -1,10 +1,10 @@
import { Request, Response } from 'express';
import { prisma } from '../../../lib/prisma';
import { Prisma } from '@prisma/client';
import { AuthorRole, Prisma } from '@prisma/client';
export async function searchArticles(req: Request, res: Response) {
try {
const { q, author, page = 1, limit = 9 } = req.query;
const { q, author, role, page = 1, limit = 9 } = req.query;
const skip = ((Number(page) || 1) - 1) * (Number(limit) || 9);
// Формируем where-условие
@ -19,7 +19,16 @@ export async function searchArticles(req: Request, res: Response) {
}
: {}),
...(typeof author === 'string' && author.trim()
? { authorId: author }
? {
authors: {
some: {
authorId: author,
...(typeof role === 'string' && role.trim()
? { role: role as AuthorRole } // 👈 проверяем и добавляем роль
: {}),
},
},
}
: {}),
};
@ -27,6 +36,8 @@ export async function searchArticles(req: Request, res: Response) {
const [articles, total] = await Promise.all([
prisma.article.findMany({
where,
include: {
authors: {
include: {
author: {
select: {
@ -34,6 +45,9 @@ export async function searchArticles(req: Request, res: Response) {
displayName: true,
avatarUrl: true,
email: true,
order: true,
},
},
},
},
},
@ -49,6 +63,7 @@ export async function searchArticles(req: Request, res: Response) {
articles,
totalPages: Math.ceil(total / (Number(limit) || 9)),
currentPage: Number(page) || 1,
total: total,
});
} catch (error) {
console.error('Ошибка поиска по статьям:', error);

View File

@ -3,11 +3,13 @@ import { AuthRequest } from '../../../middleware/auth';
import { authorService } from '../../../services/authorService';
import { logger } from "../../../config/logger";
import { prisma } from "../../../lib/prisma";
import { AuthorRole } from "@prisma/client";
export async function getAuthors(req: AuthRequest, res: Response): Promise<void> {
try {
const authors = await authorService.getAuthors();
const { role, page } = req.query;
const authors = await authorService.getAuthors(Number(page), role as string);
res.json(authors);
} catch {
res.status(500).json({ error: 'Server error' });
@ -22,7 +24,14 @@ export async function createAuthor(req: AuthRequest, res: Response): Promise<voi
return
}
const { email, displayName, bio, avatarUrl, order } = req.body;
const { email, displayName, bio, avatarUrl, okUrl, vkUrl, websiteUrl, roles } = req.body;
// Проверим, что пришедшие роли валидны
const validRoles = Array.isArray(roles)
? roles.filter((role: any): role is AuthorRole =>
Object.values(AuthorRole).includes(role as AuthorRole)
)
: [];
const lastAuthor = await prisma.author.findFirst({
orderBy: {
@ -38,7 +47,11 @@ export async function createAuthor(req: AuthRequest, res: Response): Promise<voi
displayName,
bio,
avatarUrl,
order: nextOrder
order: nextOrder,
okUrl,
vkUrl,
websiteUrl,
roles: validRoles,
},
select: {
id: true,
@ -47,6 +60,10 @@ export async function createAuthor(req: AuthRequest, res: Response): Promise<voi
bio: true,
avatarUrl: true,
order: true,
okUrl: true,
vkUrl: true,
websiteUrl: true,
roles: true,
}
});
@ -67,7 +84,14 @@ export async function updateAuthor(req: AuthRequest, res: Response): Promise<voi
}
const { id } = req.params;
const { email, displayName, bio, avatarUrl, order, okUrl, vkUrl, websiteUrl } = req.body;
const { email, displayName, bio, avatarUrl, order, okUrl, vkUrl, websiteUrl, roles } = req.body;
// Проверим, что пришедшие роли валидны
const validRoles = Array.isArray(roles)
? roles.filter((role: any): role is AuthorRole =>
Object.values(AuthorRole).includes(role as AuthorRole)
)
: [];
// Подготовка данных для изменения
const updateData: any = {
@ -79,6 +103,7 @@ export async function updateAuthor(req: AuthRequest, res: Response): Promise<voi
okUrl,
vkUrl,
websiteUrl,
roles: validRoles,
};
// Обновление данных автора
@ -95,6 +120,7 @@ export async function updateAuthor(req: AuthRequest, res: Response): Promise<voi
okUrl: true,
vkUrl: true,
websiteUrl: true,
roles: true,
}
});

View File

@ -6,7 +6,8 @@ import { AuthRequest } from "../../../middleware/auth";
// Изменить количество лайков
export async function updateLikes(req: AuthRequest, res: Response): Promise<void> {
try {
const authors = await authorService.getAuthors();
const { role, page } = req.query;
const authors = await authorService.getAuthors(Number(page), role as string);
res.json(authors);
} catch {
res.status(500).json({ error: 'Серверная ошибка' });

View File

@ -1,10 +1,29 @@
import { PrismaClient } from '@prisma/client';
import { AuthorRole } from '@prisma/client';
import { Author } from '../types/auth';
const prisma = new PrismaClient();
export const authorService = {
getAuthors: async (): Promise<Author[]> => {
getAuthors: async (page: number, role?: string): Promise<Author[]> => {
if (role && !Object.values(AuthorRole).includes(role as AuthorRole)) {
throw new Error(`Недопустимая роль: ${role}`);
}
const pageSize = 10;
const sortRoles = role
? {
roles: {
has: role as AuthorRole, // Prisma оператор для enum[]
},
}
: {};
const skip = page ? (page - 1) * pageSize : undefined;
const take = page ? pageSize : undefined;
try {
// 1. Получаем авторов
const authors = await prisma.author.findMany({
@ -20,8 +39,15 @@ export const authorService = {
email: true,
userId: true,
isActive: true,
roles: true,
_count: {
select: {articles: true}, // Подсчёт количества статей автора
select: {
articles: {
where: {
role: role as AuthorRole, // 👈 учитываются только WRITER-авторы
},
},
},
},
user: {
select: {
@ -29,24 +55,28 @@ export const authorService = {
},
}
},
where: {order: {not: 0}},
where: {
order: {not: 0},
...sortRoles, // 👈 добавляем условие по ролям
},
orderBy: {
order: 'asc',
},
skip,
take,
});
const sql = `
SELECT "authorId", SUM("Article"."likes") AS "totalLikes"
FROM "ArticleAuthor"
JOIN "Article" ON "ArticleAuthor"."articleId" = "Article"."id"
WHERE "ArticleAuthor"."role" = $1::"AuthorRole"
GROUP BY "authorId"
`;
// 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])
);
const rawLikes: { authorId: string; totalLikes: number | null }[] = await prisma.$queryRawUnsafe(sql, role);
const likesMap = new Map(rawLikes.map(item => [item.authorId, item.totalLikes ?? 0]));
return authors.map(author => ({
id: author.id,
@ -62,7 +92,8 @@ export const authorService = {
userId: author.userId,
userDisplayName: author.user?.displayName || null,
isActive: author.isActive,
totalLikes: likesMap.get(author.id) ?? 0,
roles: author.roles,
totalLikes: Number(likesMap.get(author.id) ?? 0),
}));
} catch (error) {
console.error('Ошибка получения авторов:', error);

View File

@ -10,6 +10,8 @@ export interface Article {
cityId: number;
author: Author;
authorName: string;
coAuthorName: string;
photographerName: string;
coverImage: string;
images?: string[];
imageSubs: string[];