Добавлена работа с пользователями - создание, редактирование, удаление.

This commit is contained in:
anibilag 2025-02-10 20:05:31 +03:00
parent a99fa47470
commit c5cb7c2984
14 changed files with 351 additions and 858 deletions

816
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.734.0",
"@aws-sdk/s3-request-presigner": "^3.734.0",
"@prisma/client": "^6.2.1",
"@prisma/client": "^6.3.1",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
@ -39,7 +39,7 @@
"@types/multer-s3": "^3.0.3",
"@types/node": "^22.10.7",
"@types/winston": "^2.4.4",
"prisma": "^6.2.1",
"prisma": "^6.3.1",
"ts-node": "^10.9.2",
"typescript": "^5.7.3"
}

View File

@ -0,0 +1,87 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT NOT NULL,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"permissions" JSONB NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Article" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"excerpt" TEXT NOT NULL,
"content" TEXT NOT NULL,
"categoryId" INTEGER NOT NULL,
"city" TEXT NOT NULL,
"coverImage" TEXT NOT NULL,
"readTime" INTEGER NOT NULL,
"likes" INTEGER NOT NULL DEFAULT 0,
"dislikes" INTEGER NOT NULL DEFAULT 0,
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"authorId" TEXT NOT NULL,
CONSTRAINT "Article_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GalleryImage" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"caption" TEXT NOT NULL,
"alt" TEXT NOT NULL,
"width" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
"size" INTEGER NOT NULL,
"format" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "GalleryImage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserReaction" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"reaction" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserReaction_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
-- CreateIndex
CREATE UNIQUE INDEX "UserReaction_userId_articleId_key" ON "UserReaction"("userId", "articleId");
-- AddForeignKey
ALTER TABLE "Article" ADD CONSTRAINT "Article_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GalleryImage" ADD CONSTRAINT "GalleryImage_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -12,6 +12,7 @@ model User {
email String @unique
password String
displayName String
avatarUrl String
isAdmin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -92,10 +92,10 @@ 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, city, coverImage, readTime } = req.body;
const { title, excerpt, content, category, city, coverImage, readTime } = req.body;
if (!req.user) {
res.status(401).json({ error: 'Not authenticated' });
res.status(401).json({ error: 'Пользователь не вошел в систему' });
return
}
@ -105,21 +105,12 @@ export async function updateArticle(req: AuthRequest, res: Response) : Promise<v
});
if (!article) {
res.status(404).json({ error: 'Article not found' });
res.status(404).json({ error: 'Статья не найдена' });
return
}
const category = await prisma.category.findUnique({
where: { id: parseInt(categoryId) }
});
if (!category) {
res.status(400).json({ error: 'Invalid category' });
return
}
if (!checkPermission(req.user, categoryId, 'edit')) {
res.status(403).json({ error: 'Permission denied' });
if (!checkPermission(req.user, category, 'edit')) {
res.status(403).json({ error: 'Нет прав на выполнение этой операции' });
return
}
@ -129,7 +120,7 @@ export async function updateArticle(req: AuthRequest, res: Response) : Promise<v
title,
excerpt,
content,
categoryId: parseInt(categoryId),
categoryId: parseInt(category),
city,
coverImage,
readTime

View File

@ -1,15 +1,18 @@
import { Request, Response } from 'express';
import { prisma } from '../../../lib/prisma';
import { Prisma } from '@prisma/client';
import { CategoryMap } from '../../../types';
export async function listArticles(req: Request, res: Response) {
try {
const { page = 1, category, city } = req.query;
const perPage = 6;
const categoryId = CategoryMap[category as string];
// Проверка и преобразование параметров в строковые значения
const where: Prisma.ArticleWhereInput = {
...(category && { category: { name: category as string } }),
...(categoryId && { categoryId: categoryId }),
...(city && { city: city as string }),
};

View File

@ -14,8 +14,8 @@ export async function login(req: Request, res: Response) {
export async function signIn(req: Request, res: Response) {
try {
const { email, password, displayName } = req.body;
const user = await authService.createUser({email : email, password : password, displayName : displayName, permissions : {}});
const { email, password, displayName, avatarUrl } = req.body;
const user = await authService.createUser({email : email, password : password, displayName : displayName, avatarUrl : avatarUrl, permissions : {}});
res.json({ user });
} catch {
res.status(401).json({ error: 'Invalid signIn credentials' });

View File

@ -1,6 +1,11 @@
import { Response } from 'express';
import { AuthRequest } from '../../../middleware/auth';
import { userService } from '../../../services/userService';
import { logger } from '../../../config/logger';
import bcrypt from 'bcryptjs';
import { prisma } from '../../../lib/prisma';
import { getDefaultPermissions } from '../../../utils/permissions';
export async function getUsers(req: AuthRequest, res: Response): Promise<void> {
try {
@ -18,6 +23,109 @@ export async function getUsers(req: AuthRequest, res: Response): Promise<void> {
}
}
export async function createUser(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user?.permissions.isAdmin) {
logger.warn(`Non-admin user ${req.user?.id} attempted to create user`);
res.status(403).json({ error: 'Admin access required' });
return
}
const { email, password, displayName, avatarUrl } = req.body;
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email }
});
if (existingUser) {
res.status(400).json({ error: 'User already exists' });
return
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user with default permissions
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
displayName,
avatarUrl,
permissions: getDefaultPermissions()
},
select: {
id: true,
email: true,
displayName: true,
avatarUrl: true,
permissions: true
}
});
logger.info(`User created successfully: ${user.id}`);
res.status(201).json(user);
} catch (error) {
logger.error('Error creating user:', error);
res.status(500).json({ error: 'Failed to create user' });
}
}
export async function updateUser(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user?.permissions.isAdmin) {
logger.warn(`Non-admin user ${req.user?.id} attempted to update user`);
res.status(403).json({ error: 'Admin access required' });
return
}
const { id } = req.params;
const { email, password, displayName, avatarUrl } = req.body;
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { id }
});
if (!existingUser) {
res.status(404).json({ error: 'User not found' });
return
}
// Prepare update data
const updateData: any = {
email,
displayName,
avatarUrl
};
// Only update password if provided
if (password) {
updateData.password = await bcrypt.hash(password, 10);
}
// Update user
const user = await prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
email: true,
displayName: true,
avatarUrl: true,
permissions: true
}
});
logger.info(`User updated successfully: ${user.id}`);
res.json(user);
} catch (error) {
logger.error('Error updating user:', error);
res.status(500).json({ error: 'Failed to update user' });
}
}
export async function updateUserPermissions(req: AuthRequest, res: Response): Promise<void> {
try {
// Проверка прав администратора
@ -36,4 +144,37 @@ export async function updateUserPermissions(req: AuthRequest, res: Response): Pr
} catch {
res.status(500).json({ error: 'Server error' });
}
}
export async function deleteUser(req: AuthRequest, res: Response): Promise<void> {
try {
if (!req.user?.permissions.isAdmin) {
logger.warn(`Non-admin user ${req.user?.id} attempted to delete user`);
res.status(403).json({ error: 'Admin access required' });
return
}
const { id } = req.params;
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { id }
});
if (!existingUser) {
res.status(404).json({ error: 'User not found' });
return
}
// Delete user
await prisma.user.delete({
where: { id }
});
logger.info(`User deleted successfully: ${id}`);
res.json({ message: 'User deleted successfully' });
} catch (error) {
logger.error('Error deleting user:', error);
res.status(500).json({ error: 'Failed to delete user' });
}
}

View File

@ -1,10 +1,22 @@
import express from 'express';
import { auth } from '../../middleware/auth';
import { getUsers, updateUserPermissions } from './controllers/users';
import {
getUsers,
createUser,
updateUser,
updateUserPermissions,
deleteUser
} from './controllers/users';
const router = express.Router();
// All routes require authentication
router.use(auth);
router.get('/', auth, getUsers);
router.post('/', createUser);
router.put('/:id', updateUser);
router.put('/:id/permissions', auth, updateUserPermissions);
router.delete('/:id', deleteUser);
export default router;

View File

@ -17,6 +17,7 @@ export const authService = {
password: string;
id: string;
displayName: string;
avatarUrl: string;
permissions: JsonValue;
} | null = await prisma.user.findUnique({
where: { email },
@ -24,6 +25,7 @@ export const authService = {
id: true,
email: true,
displayName: true,
avatarUrl: true,
password: true,
permissions: true
}
@ -76,6 +78,7 @@ export const authService = {
email: string;
password: string;
displayName: string;
avatarUrl: string;
permissions: any;
}) => {
try {
@ -86,6 +89,7 @@ export const authService = {
email: string;
id: string;
displayName: string;
avatarUrl: string;
permissions: JsonValue;
} = await prisma.user.create({
data: {
@ -96,6 +100,7 @@ export const authService = {
id: true,
email: true,
displayName: true,
avatarUrl: true,
permissions: true
}
});

View File

@ -1,78 +0,0 @@
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import sharp from 'sharp';
import { logger } from '../config/logger';
import { imageResolutions } from '../config/imageResolutions';
const s3Client = new S3Client({
region: process.env.AWS_REGION || 'ru-central1',
endpoint: process.env.AWS_ENDPOINT || '',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
},
});
// Создание клиента S3
export const createS3Client = () => {
return new S3Client({
region: process.env.AWS_REGION || 'ru-central1',
endpoint: process.env.AWS_ENDPOINT || '',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
},
});
};
const BUCKET_NAME = process.env.AWS_S3_BUCKET || '';
export const s3Service = {
uploadOriginalFile: async (file: Express.MulterS3.File) => {
logger.info(`Оригинальный файл загружен в S3: ${file.key}`);
return file.key;
},
optimizeAndUpload: async (fileBuffer: Buffer, originalKey: string, resolutionId: string) => {
const selectedResolution = imageResolutions.find(r => r.id === resolutionId);
if (!selectedResolution) {
throw new Error('Недопустимое разрешение');
}
try {
// Оптимизация изображения
const optimizedBuffer = await sharp(fileBuffer)
.resize(selectedResolution.width, selectedResolution.height, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 80 })
.toBuffer();
// Генерация нового ключа для оптимизированного файла
const optimizedKey = originalKey.replace(/\.[^/.]+$/, '.webp');
// Загрузка оптимизированного файла
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: optimizedKey,
Body: optimizedBuffer,
ContentType: 'image/webp',
})
);
logger.info(`Оптимизированное изображение загружено в S3: ${optimizedKey}`);
return {
key: optimizedKey,
width: selectedResolution.width,
height: selectedResolution.height,
format: 'webp',
size: optimizedBuffer.length,
};
} catch (error) {
logger.error('Ошибка при оптимизации и загрузке файла:', error);
throw error;
}
},
};

View File

@ -36,4 +36,15 @@ export interface Category {
}
export type CategoryName = 'Film' | 'Theater' | 'Music' | 'Sports' | 'Art' | 'Legends' | 'Anniversaries' | 'Memory';
export type City = 'New York' | 'London';
export type City = 'New York' | 'London';
export const CategoryMap: Record<string, number> = {
'Film': 1,
'Theater': 2,
'Music': 3,
'Sports': 4,
'Art': 5,
'Legends': 6,
'Anniversaries': 7,
'Memory': 8,
};

View File

@ -13,4 +13,19 @@ export const checkPermission = (
export const checkCityAccess = (user: User, city: City): boolean => {
if (user.permissions.isAdmin) return true;
return user.permissions.cities.includes(city);
};
};
export const getDefaultPermissions = () => ({
categories: {
Film: { create: false, edit: false, delete: false },
Theater: { create: false, edit: false, delete: false },
Music: { create: false, edit: false, delete: false },
Sports: { create: false, edit: false, delete: false },
Art: { create: false, edit: false, delete: false },
Legends: { create: false, edit: false, delete: false },
Anniversaries: { create: false, edit: false, delete: false },
Memory: { create: false, edit: false, delete: false }
},
cities: [],
isAdmin: false
});